Merge branch 'stable-3.2'

* stable-3.2:
  Update git submodules
  Update git submodules
  Update git submodules
  Update git submodules
  Elasticsearch: Remove support for EOL 6.6 version
  ElasticContainer: Upgrade V7_8 to elasticsearch 7.8.1
  ElasticContainer: Upgrade V6_8 to elasticsearch 6.8.11
  Fix gr-hovercard-behavior under Firefox
  Add entries to .gitignore of stable-3.0 that exist on master

Change-Id: I40a59490a898a1774918e48d174c1066c3059805
diff --git a/.gitignore b/.gitignore
index 0a0b48c..a3fb4dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,7 +19,6 @@
 /.settings/org.eclipse.ltk.core.refactoring.prefs
 /.settings/org.eclipse.m2e.core.prefs
 /.settings/org.maven.ide.eclipse.prefs
-/.ts-out
 /.vscode
 /bazel-*
 /bin/
@@ -32,6 +31,8 @@
 /node_modules/
 /package-lock.json
 /plugins/*
+!/plugins/package.json
+!/plugins/yarn.lock
 !/plugins/BUILD
 !/plugins/codemirror-editor
 !/plugins/commit-message-length-validator
@@ -49,3 +50,5 @@
 /tools/format
 /tools/node_tools
 /tools/polygerrit-updater
+/.ts-out/*
+!/.ts-out/README.md
diff --git a/.ts-out/README.md b/.ts-out/README.md
new file mode 100644
index 0000000..dada30d
--- /dev/null
+++ b/.ts-out/README.md
@@ -0,0 +1,4 @@
+This directory contains compiled js code. Typescript uses subdirectories
+as output directories when runs under IDE.
+
+Bazel doesn't use this directory
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 71c9330..8bb5d54 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -186,6 +186,10 @@
 project "`All-Projects`".  This inheritance can be configured
 through link:cmd-set-project-parent.html[gerrit set-project-parent].
 
+When projects are set as parent projects, the child projects inherit
+all of the parent's access rights. "`All-Projects`" is treated as a
+parent of all projects.
+
 Per-project access control lists are also supported.
 
 Users are permitted to use the maximum range granted to any of their
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 5ac6ea6..2b7fd90 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -828,6 +828,49 @@
 +
 If 0 or negative, disk storage for the cache is disabled.
 
+[[cache.name.expireAfterWrite]]cache.<name>.expireAfterWrite::
++
+Duration after which a cached value will be evicted and not
+read anymore.
++
+Values should use common unit suffixes to express their setting:
++
+* ms, milliseconds
+* s, sec, second, seconds
+* m, min, minute, minutes
+* h, hr, hour, hours
++
+Disabled by default.
+
+[[cache.name.refreshAfterWrite]]cache.<name>.refreshAfterWrite::
++
+Duration after which we asynchronously refresh the cached value.
++
+Values should use common unit suffixes to express their setting:
++
+* ms, milliseconds
+* s, sec, second, seconds
+* m, min, minute, minutes
+* h, hr, hour, hours
++
+This applies only to these caches that support refreshing:
++
+* `"projects"`: Caching project information in-memory. Defaults to 15 minutes.
+
+[[cache.refreshThreadPoolSize]]cache.refreshThreadPoolSize::
++
+Number of threads that are available to refresh cached values that became
+out of date. This applies only to these caches that support refreshing:
++
+* `"projects"`: Caching project information in-memory
++
+Refreshes will only be scheduled on this executor if the values are
+out of sync.
+The check if they are is cheap and always happens on the thread that
+inquires for a cached value.
++
+Defaults to 2.
+
 ==== [[cache_names]]Standard Caches
 
 cache `"accounts"`::
@@ -1125,22 +1168,6 @@
 +
 Default is true, enabled.
 
-[[cache.projects.checkFrequency]]cache.projects.checkFrequency::
-+
-How often project configuration should be checked for update from Git.
-Gerrit Code Review caches project access rules and configuration in
-memory, checking the refs/meta/config branch every checkFrequency
-minutes to see if a new revision should be loaded and used for future
-access. Values can be specified using standard time unit abbreviations
-('ms', 'sec', 'min', etc.).
-+
-If set to 0, checks occur every time, which may slow down operations.
-If set to 'disabled' or 'off', no check will ever be done.
-Administrators may force the cache to flush with
-link:cmd-flush-caches.html[gerrit flush-caches].
-+
-Default is 5 minutes.
-
 [[cache.projects.loadOnStartup]]cache.projects.loadOnStartup::
 +
 If the project cache should be loaded during server startup.
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 5d22c0b..85006dc 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -109,10 +109,10 @@
 reverting a change.  It is a `ChangeEmail`: see `ChangeSubject.soy` and
 ChangeFooter.
 
-=== RegisterNewEmail.soy
+=== RegisterNewEmail.soy and RegisterNewEmailHtml.soy
 
-The `RegisterNewEmail.soy` template will determine the contents of the email
-related to registering new email accounts.
+Those templates will determine the contents of the email related to registering
+new email accounts.
 
 === ReplacePatchSet.soy and ReplacePatchSetHtml.soy
 
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index aa6e400..01cd494 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -21,7 +21,7 @@
 * A JDK for Java 8|9|10|11|...
 * Python 2 or 3
 * link:https://github.com/nodesource/distributions/blob/master/README.md[Node.js (including npm),role=external,window=_blank]
-* Bower (`sudo npm install -g bower`)
+* Bower (`npm install -g bower`)
 * link:https://docs.bazel.build/versions/master/install.html[Bazel,role=external,window=_blank] -launched with
 link:https://github.com/bazelbuild/bazelisk[Bazelisk,role=external,window=_blank]
 * Maven
@@ -32,12 +32,11 @@
 [[bazel]]
 === Bazel
 
-link:https://github.com/bazelbuild/bazelisk[Bazelisk,role=external,window=_blank] includes a
-link:https://bazel.build/[Bazel,role=external,window=_blank] version check and downloads the correct
-`bazel` version for the git project/repository. Bazelisk is the recommended
-`bazel` launcher for Gerrit. Once Bazelisk is installed locally, a `bazel`
-symlink can be created towards it. This is so that every `bazel` command
-seamlessly uses Bazelisk, which then runs the proper `bazel` binary version.
+link:https://github.com/bazelbuild/bazelisk[Bazelisk,role=external,window=_blank] is a version
+manager for link:https://bazel.build/[Bazel,role=external,window=_blank], similar to how `nvm`
+manages `npm` versions. It takes care of downloading and installing Bazel itself, so you don't have
+to worry about using the correct version of Bazel. Bazelisk can be installed in different
+ways: link:https://docs.bazel.build/install-bazelisk.html[Install,role=external,window=_blank]
 
 [[java]]
 === Java
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 23ecd67..477641b 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -144,7 +144,7 @@
   and everyone can comment and raise concerns.
 * Design docs should stay open for a minimum of 10 calendar days so
   that everyone has a fair chance to join the review.
-* Within 14 calendar days the contributor should hear back from the
+* Within 30 calendar days the contributor should hear back from the
   link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank]
   whether the proposed feature is in scope of the project and if it can
   be accepted.
diff --git a/Documentation/dev-design-docs.txt b/Documentation/dev-design-docs.txt
index e658910..7954c54 100644
--- a/Documentation/dev-design-docs.txt
+++ b/Documentation/dev-design-docs.txt
@@ -132,7 +132,7 @@
 
 For proposed features the contributor should hear back from the
 link:dev-processes.html#steering-committee[engineering steering
-committee] within 14 calendar days whether the proposed feature is in
+committee] within 30 calendar days whether the proposed feature is in
 scope of the project and if it can be accepted.
 
 [[watch-designs]]
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index 3699a18..b7dd259 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -15,7 +15,7 @@
 * ensuring timely design reviews
 * ensuring that new features are compatible with the project vision and
   are well aligned with other features (give feedback on new
-  link:dev-design-docs.html[design docs] within 14 calendar days)
+  link:dev-design-docs.html[design docs] within 30 calendar days)
 * approving/rejecting link:dev-design-docs.html[designs], vetoing new
   features
 * assigning link:dev-roles.html#mentor[mentors] for approved features
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index 2ca7f22..cecaedc 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -213,10 +213,10 @@
 [[maintainer-election]]
 Maintainers can nominate new maintainers by posting a nomination on the
 non-public maintainers mailing list. Nominations should stay open for
-at least 14 calendar days so that all maintainers have a chance to
+at least 10 calendar days so that all maintainers have a chance to
 vote. To be approved as maintainer a minimum of 5 positive votes and no
 negative votes is required. This means if 5 positive votes without
-negative votes have been reached and 14 calendar days have passed, any
+negative votes have been reached and 10 calendar days have passed, any
 maintainer can close the vote and welcome the new maintainer. Extending
 the voting period during holiday season or if there are not enough
 votes is possible, but the voting period should not exceed 1 month. If
diff --git a/Documentation/images/user-attention-set-dashboard.png b/Documentation/images/user-attention-set-dashboard.png
new file mode 100644
index 0000000..2bf7ccd
--- /dev/null
+++ b/Documentation/images/user-attention-set-dashboard.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-hovercard.png b/Documentation/images/user-attention-set-hovercard.png
new file mode 100644
index 0000000..b5638fd
--- /dev/null
+++ b/Documentation/images/user-attention-set-hovercard.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-icon.png b/Documentation/images/user-attention-set-icon.png
new file mode 100644
index 0000000..a1d5ac5
--- /dev/null
+++ b/Documentation/images/user-attention-set-icon.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-reply-modify.png b/Documentation/images/user-attention-set-reply-modify.png
new file mode 100644
index 0000000..cc12753
--- /dev/null
+++ b/Documentation/images/user-attention-set-reply-modify.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-reply-select.png b/Documentation/images/user-attention-set-reply-select.png
new file mode 100644
index 0000000..14b2967
--- /dev/null
+++ b/Documentation/images/user-attention-set-reply-select.png
Binary files differ
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
index 92732d0..6565ba4 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -296,6 +296,10 @@
 * Review the link:intro-project-owner.html[Project Owners guide] to learn more
   about configuring projects in Gerrit, including setting user permissions and
   configuring verification checks
+* Read through the Git and Gerrit training slides that explain concepts and
+  workflows in detail. They are meant for self-studying how Git and Gerrit work:
+** link:https://docs.google.com/presentation/d/1IQCRPHEIX-qKo7QFxsD3V62yhyGA9_5YsYXFOiBpgkk/edit?usp=sharing[Git explained: Git Concepts and Workflows]
+** link:https://docs.google.com/presentation/d/1C73UgQdzZDw0gzpaEqIC6SPujZJhqamyqO1XOHjH-uk/edit?usp=sharing[Gerrit explained: Gerrit Concepts and Workflows]
 
 GERRIT
 ------
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index bd26190..ad3443b 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -279,36 +279,6 @@
 ----
 
 
-[[es6-promise]]
-es6-promise
-
-* es6-promise
-
-[[es6-promise_license]]
-----
-Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
 [[Polymer-2018]]
 Polymer-2018
 
@@ -356,227 +326,49 @@
 ----
 
 
-[[font-roboto-local-fonts-robotomono]]
-font-roboto-local-fonts-robotomono
+[[Polymer-2017]]
+Polymer-2017
 
-* @polymer/font-roboto-local - only the following file(s):
-** fonts/robotomono/LICENSE.txt
-** fonts/robotomono/METADATA.json
-** fonts/robotomono/RobotoMono-Bold.ttf
-** fonts/robotomono/RobotoMono-BoldItalic.ttf
-** fonts/robotomono/RobotoMono-Italic.ttf
-** fonts/robotomono/RobotoMono-Light.ttf
-** fonts/robotomono/RobotoMono-LightItalic.ttf
-** fonts/robotomono/RobotoMono-Medium.ttf
-** fonts/robotomono/RobotoMono-MediumItalic.ttf
-** fonts/robotomono/RobotoMono-Regular.ttf
-** fonts/robotomono/RobotoMono-Thin.ttf
-** fonts/robotomono/RobotoMono-ThinItalic.ttf
+* @polymer/decorators
+* @polymer/polymer
+* @webcomponents/shadycss
 
-[[font-roboto-local-fonts-robotomono_license]]
+[[Polymer-2017_license]]
 ----
+Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
 
-                                 Apache License
-                           Version 2.0, January 2004
-                        http://www.apache.org/licenses/
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
 
-   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
 
-   1. Definitions.
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
 
-      "License" shall mean the terms and conditions for use, reproduction,
-      and distribution as defined by Sections 1 through 9 of this document.
-
-      "Licensor" shall mean the copyright owner or entity authorized by
-      the copyright owner that is granting the License.
-
-      "Legal Entity" shall mean the union of the acting entity and all
-      other entities that control, are controlled by, or are under common
-      control with that entity. For the purposes of this definition,
-      "control" means (i) the power, direct or indirect, to cause the
-      direction or management of such entity, whether by contract or
-      otherwise, or (ii) ownership of fifty percent (50%) or more of the
-      outstanding shares, or (iii) beneficial ownership of such entity.
-
-      "You" (or "Your") shall mean an individual or Legal Entity
-      exercising permissions granted by this License.
-
-      "Source" form shall mean the preferred form for making modifications,
-      including but not limited to software source code, documentation
-      source, and configuration files.
-
-      "Object" form shall mean any form resulting from mechanical
-      transformation or translation of a Source form, including but
-      not limited to compiled object code, generated documentation,
-      and conversions to other media types.
-
-      "Work" shall mean the work of authorship, whether in Source or
-      Object form, made available under the License, as indicated by a
-      copyright notice that is included in or attached to the work
-      (an example is provided in the Appendix below).
-
-      "Derivative Works" shall mean any work, whether in Source or Object
-      form, that is based on (or derived from) the Work and for which the
-      editorial revisions, annotations, elaborations, or other modifications
-      represent, as a whole, an original work of authorship. For the purposes
-      of this License, Derivative Works shall not include works that remain
-      separable from, or merely link (or bind by name) to the interfaces of,
-      the Work and Derivative Works thereof.
-
-      "Contribution" shall mean any work of authorship, including
-      the original version of the Work and any modifications or additions
-      to that Work or Derivative Works thereof, that is intentionally
-      submitted to Licensor for inclusion in the Work by the copyright owner
-      or by an individual or Legal Entity authorized to submit on behalf of
-      the copyright owner. For the purposes of this definition, "submitted"
-      means any form of electronic, verbal, or written communication sent
-      to the Licensor or its representatives, including but not limited to
-      communication on electronic mailing lists, source code control systems,
-      and issue tracking systems that are managed by, or on behalf of, the
-      Licensor for the purpose of discussing and improving the Work, but
-      excluding communication that is conspicuously marked or otherwise
-      designated in writing by the copyright owner as "Not a Contribution."
-
-      "Contributor" shall mean Licensor and any individual or Legal Entity
-      on behalf of whom a Contribution has been received by Licensor and
-      subsequently incorporated within the Work.
-
-   2. Grant of Copyright License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      copyright license to reproduce, prepare Derivative Works of,
-      publicly display, publicly perform, sublicense, and distribute the
-      Work and such Derivative Works in Source or Object form.
-
-   3. Grant of Patent License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      (except as stated in this section) patent license to make, have made,
-      use, offer to sell, sell, import, and otherwise transfer the Work,
-      where such license applies only to those patent claims licensable
-      by such Contributor that are necessarily infringed by their
-      Contribution(s) alone or by combination of their Contribution(s)
-      with the Work to which such Contribution(s) was submitted. If You
-      institute patent litigation against any entity (including a
-      cross-claim or counterclaim in a lawsuit) alleging that the Work
-      or a Contribution incorporated within the Work constitutes direct
-      or contributory patent infringement, then any patent licenses
-      granted to You under this License for that Work shall terminate
-      as of the date such litigation is filed.
-
-   4. Redistribution. You may reproduce and distribute copies of the
-      Work or Derivative Works thereof in any medium, with or without
-      modifications, and in Source or Object form, provided that You
-      meet the following conditions:
-
-      (a) You must give any other recipients of the Work or
-          Derivative Works a copy of this License; and
-
-      (b) You must cause any modified files to carry prominent notices
-          stating that You changed the files; and
-
-      (c) You must retain, in the Source form of any Derivative Works
-          that You distribute, all copyright, patent, trademark, and
-          attribution notices from the Source form of the Work,
-          excluding those notices that do not pertain to any part of
-          the Derivative Works; and
-
-      (d) If the Work includes a "NOTICE" text file as part of its
-          distribution, then any Derivative Works that You distribute must
-          include a readable copy of the attribution notices contained
-          within such NOTICE file, excluding those notices that do not
-          pertain to any part of the Derivative Works, in at least one
-          of the following places: within a NOTICE text file distributed
-          as part of the Derivative Works; within the Source form or
-          documentation, if provided along with the Derivative Works; or,
-          within a display generated by the Derivative Works, if and
-          wherever such third-party notices normally appear. The contents
-          of the NOTICE file are for informational purposes only and
-          do not modify the License. You may add Your own attribution
-          notices within Derivative Works that You distribute, alongside
-          or as an addendum to the NOTICE text from the Work, provided
-          that such additional attribution notices cannot be construed
-          as modifying the License.
-
-      You may add Your own copyright statement to Your modifications and
-      may provide additional or different license terms and conditions
-      for use, reproduction, or distribution of Your modifications, or
-      for any such Derivative Works as a whole, provided Your use,
-      reproduction, and distribution of the Work otherwise complies with
-      the conditions stated in this License.
-
-   5. Submission of Contributions. Unless You explicitly state otherwise,
-      any Contribution intentionally submitted for inclusion in the Work
-      by You to the Licensor shall be under the terms and conditions of
-      this License, without any additional terms or conditions.
-      Notwithstanding the above, nothing herein shall supersede or modify
-      the terms of any separate license agreement you may have executed
-      with Licensor regarding such Contributions.
-
-   6. Trademarks. This License does not grant permission to use the trade
-      names, trademarks, service marks, or product names of the Licensor,
-      except as required for reasonable and customary use in describing the
-      origin of the Work and reproducing the content of the NOTICE file.
-
-   7. Disclaimer of Warranty. Unless required by applicable law or
-      agreed to in writing, Licensor provides the Work (and each
-      Contributor provides its Contributions) on an "AS IS" BASIS,
-      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-      implied, including, without limitation, any warranties or conditions
-      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
-      PARTICULAR PURPOSE. You are solely responsible for determining the
-      appropriateness of using or redistributing the Work and assume any
-      risks associated with Your exercise of permissions under this License.
-
-   8. Limitation of Liability. In no event and under no legal theory,
-      whether in tort (including negligence), contract, or otherwise,
-      unless required by applicable law (such as deliberate and grossly
-      negligent acts) or agreed to in writing, shall any Contributor be
-      liable to You for damages, including any direct, indirect, special,
-      incidental, or consequential damages of any character arising as a
-      result of this License or out of the use or inability to use the
-      Work (including but not limited to damages for loss of goodwill,
-      work stoppage, computer failure or malfunction, or any and all
-      other commercial damages or losses), even if such Contributor
-      has been advised of the possibility of such damages.
-
-   9. Accepting Warranty or Additional Liability. While redistributing
-      the Work or Derivative Works thereof, You may choose to offer,
-      and charge a fee for, acceptance of support, warranty, indemnity,
-      or other liability obligations and/or rights consistent with this
-      License. However, in accepting such obligations, You may act only
-      on Your own behalf and on Your sole responsibility, not on behalf
-      of any other Contributor, and only if You agree to indemnify,
-      defend, and hold each Contributor harmless for any liability
-      incurred by, or claims asserted against, such Contributor by reason
-      of your accepting any such warranty or additional liability.
-
-   END OF TERMS AND CONDITIONS
-
-   APPENDIX: How to apply the Apache License to your work.
-
-      To apply the Apache License to your work, attach the following
-      boilerplate notice, with the fields enclosed by brackets "[]"
-      replaced with your own identifying information. (Don't include
-      the brackets!)  The text should be enclosed in the appropriate
-      comment syntax for the file format. We also recommend that a
-      file or class name and description of purpose be included on the
-      same "printed page" as the copyright notice for easier
-      identification within third-party archives.
-
-   Copyright [yyyy] [name of copyright owner]
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 ----
 
@@ -747,70 +539,6 @@
 ----
 
 
-[[whatwg-fetch]]
-whatwg-fetch
-
-* whatwg-fetch
-
-[[whatwg-fetch_license]]
-----
-Copyright (c) 2014-2016 GitHub, Inc.
-
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-"Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-----
-
-
-[[moment]]
-moment
-
-* moment
-
-[[moment_license]]
-----
-Copyright (c) JS Foundation and other contributors
-
-Permission is hereby granted, free of charge, to any person
-obtaining a copy of this software and associated documentation
-files (the "Software"), to deal in the Software without
-restriction, including without limitation the rights to use,
-copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following
-conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
-OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
-HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
-OTHER DEALINGS IN THE SOFTWARE.
-
-----
-
-
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
@@ -1039,48 +767,227 @@
 ----
 
 
-[[Polymer-2017]]
-Polymer-2017
+[[font-roboto-local-fonts-robotomono]]
+font-roboto-local-fonts-robotomono
 
-* @polymer/polymer
-* @webcomponents/shadycss
+* @polymer/font-roboto-local - only the following file(s):
+** fonts/robotomono/LICENSE.txt
+** fonts/robotomono/METADATA.json
+** fonts/robotomono/RobotoMono-Bold.ttf
+** fonts/robotomono/RobotoMono-BoldItalic.ttf
+** fonts/robotomono/RobotoMono-Italic.ttf
+** fonts/robotomono/RobotoMono-Light.ttf
+** fonts/robotomono/RobotoMono-LightItalic.ttf
+** fonts/robotomono/RobotoMono-Medium.ttf
+** fonts/robotomono/RobotoMono-MediumItalic.ttf
+** fonts/robotomono/RobotoMono-Regular.ttf
+** fonts/robotomono/RobotoMono-Thin.ttf
+** fonts/robotomono/RobotoMono-ThinItalic.ttf
 
-[[Polymer-2017_license]]
+[[font-roboto-local-fonts-robotomono_license]]
 ----
-Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
 
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
 
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
 
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
+   1. Definitions.
 
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
 
 ----
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 63c569a..98e99d4 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -3221,36 +3221,6 @@
 ----
 
 
-[[es6-promise]]
-es6-promise
-
-* es6-promise
-
-[[es6-promise_license]]
-----
-Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
 [[Polymer-2018]]
 Polymer-2018
 
@@ -3298,227 +3268,49 @@
 ----
 
 
-[[font-roboto-local-fonts-robotomono]]
-font-roboto-local-fonts-robotomono
+[[Polymer-2017]]
+Polymer-2017
 
-* @polymer/font-roboto-local - only the following file(s):
-** fonts/robotomono/LICENSE.txt
-** fonts/robotomono/METADATA.json
-** fonts/robotomono/RobotoMono-Bold.ttf
-** fonts/robotomono/RobotoMono-BoldItalic.ttf
-** fonts/robotomono/RobotoMono-Italic.ttf
-** fonts/robotomono/RobotoMono-Light.ttf
-** fonts/robotomono/RobotoMono-LightItalic.ttf
-** fonts/robotomono/RobotoMono-Medium.ttf
-** fonts/robotomono/RobotoMono-MediumItalic.ttf
-** fonts/robotomono/RobotoMono-Regular.ttf
-** fonts/robotomono/RobotoMono-Thin.ttf
-** fonts/robotomono/RobotoMono-ThinItalic.ttf
+* @polymer/decorators
+* @polymer/polymer
+* @webcomponents/shadycss
 
-[[font-roboto-local-fonts-robotomono_license]]
+[[Polymer-2017_license]]
 ----
+Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
 
-                                 Apache License
-                           Version 2.0, January 2004
-                        http://www.apache.org/licenses/
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
 
-   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
 
-   1. Definitions.
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
 
-      "License" shall mean the terms and conditions for use, reproduction,
-      and distribution as defined by Sections 1 through 9 of this document.
-
-      "Licensor" shall mean the copyright owner or entity authorized by
-      the copyright owner that is granting the License.
-
-      "Legal Entity" shall mean the union of the acting entity and all
-      other entities that control, are controlled by, or are under common
-      control with that entity. For the purposes of this definition,
-      "control" means (i) the power, direct or indirect, to cause the
-      direction or management of such entity, whether by contract or
-      otherwise, or (ii) ownership of fifty percent (50%) or more of the
-      outstanding shares, or (iii) beneficial ownership of such entity.
-
-      "You" (or "Your") shall mean an individual or Legal Entity
-      exercising permissions granted by this License.
-
-      "Source" form shall mean the preferred form for making modifications,
-      including but not limited to software source code, documentation
-      source, and configuration files.
-
-      "Object" form shall mean any form resulting from mechanical
-      transformation or translation of a Source form, including but
-      not limited to compiled object code, generated documentation,
-      and conversions to other media types.
-
-      "Work" shall mean the work of authorship, whether in Source or
-      Object form, made available under the License, as indicated by a
-      copyright notice that is included in or attached to the work
-      (an example is provided in the Appendix below).
-
-      "Derivative Works" shall mean any work, whether in Source or Object
-      form, that is based on (or derived from) the Work and for which the
-      editorial revisions, annotations, elaborations, or other modifications
-      represent, as a whole, an original work of authorship. For the purposes
-      of this License, Derivative Works shall not include works that remain
-      separable from, or merely link (or bind by name) to the interfaces of,
-      the Work and Derivative Works thereof.
-
-      "Contribution" shall mean any work of authorship, including
-      the original version of the Work and any modifications or additions
-      to that Work or Derivative Works thereof, that is intentionally
-      submitted to Licensor for inclusion in the Work by the copyright owner
-      or by an individual or Legal Entity authorized to submit on behalf of
-      the copyright owner. For the purposes of this definition, "submitted"
-      means any form of electronic, verbal, or written communication sent
-      to the Licensor or its representatives, including but not limited to
-      communication on electronic mailing lists, source code control systems,
-      and issue tracking systems that are managed by, or on behalf of, the
-      Licensor for the purpose of discussing and improving the Work, but
-      excluding communication that is conspicuously marked or otherwise
-      designated in writing by the copyright owner as "Not a Contribution."
-
-      "Contributor" shall mean Licensor and any individual or Legal Entity
-      on behalf of whom a Contribution has been received by Licensor and
-      subsequently incorporated within the Work.
-
-   2. Grant of Copyright License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      copyright license to reproduce, prepare Derivative Works of,
-      publicly display, publicly perform, sublicense, and distribute the
-      Work and such Derivative Works in Source or Object form.
-
-   3. Grant of Patent License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      (except as stated in this section) patent license to make, have made,
-      use, offer to sell, sell, import, and otherwise transfer the Work,
-      where such license applies only to those patent claims licensable
-      by such Contributor that are necessarily infringed by their
-      Contribution(s) alone or by combination of their Contribution(s)
-      with the Work to which such Contribution(s) was submitted. If You
-      institute patent litigation against any entity (including a
-      cross-claim or counterclaim in a lawsuit) alleging that the Work
-      or a Contribution incorporated within the Work constitutes direct
-      or contributory patent infringement, then any patent licenses
-      granted to You under this License for that Work shall terminate
-      as of the date such litigation is filed.
-
-   4. Redistribution. You may reproduce and distribute copies of the
-      Work or Derivative Works thereof in any medium, with or without
-      modifications, and in Source or Object form, provided that You
-      meet the following conditions:
-
-      (a) You must give any other recipients of the Work or
-          Derivative Works a copy of this License; and
-
-      (b) You must cause any modified files to carry prominent notices
-          stating that You changed the files; and
-
-      (c) You must retain, in the Source form of any Derivative Works
-          that You distribute, all copyright, patent, trademark, and
-          attribution notices from the Source form of the Work,
-          excluding those notices that do not pertain to any part of
-          the Derivative Works; and
-
-      (d) If the Work includes a "NOTICE" text file as part of its
-          distribution, then any Derivative Works that You distribute must
-          include a readable copy of the attribution notices contained
-          within such NOTICE file, excluding those notices that do not
-          pertain to any part of the Derivative Works, in at least one
-          of the following places: within a NOTICE text file distributed
-          as part of the Derivative Works; within the Source form or
-          documentation, if provided along with the Derivative Works; or,
-          within a display generated by the Derivative Works, if and
-          wherever such third-party notices normally appear. The contents
-          of the NOTICE file are for informational purposes only and
-          do not modify the License. You may add Your own attribution
-          notices within Derivative Works that You distribute, alongside
-          or as an addendum to the NOTICE text from the Work, provided
-          that such additional attribution notices cannot be construed
-          as modifying the License.
-
-      You may add Your own copyright statement to Your modifications and
-      may provide additional or different license terms and conditions
-      for use, reproduction, or distribution of Your modifications, or
-      for any such Derivative Works as a whole, provided Your use,
-      reproduction, and distribution of the Work otherwise complies with
-      the conditions stated in this License.
-
-   5. Submission of Contributions. Unless You explicitly state otherwise,
-      any Contribution intentionally submitted for inclusion in the Work
-      by You to the Licensor shall be under the terms and conditions of
-      this License, without any additional terms or conditions.
-      Notwithstanding the above, nothing herein shall supersede or modify
-      the terms of any separate license agreement you may have executed
-      with Licensor regarding such Contributions.
-
-   6. Trademarks. This License does not grant permission to use the trade
-      names, trademarks, service marks, or product names of the Licensor,
-      except as required for reasonable and customary use in describing the
-      origin of the Work and reproducing the content of the NOTICE file.
-
-   7. Disclaimer of Warranty. Unless required by applicable law or
-      agreed to in writing, Licensor provides the Work (and each
-      Contributor provides its Contributions) on an "AS IS" BASIS,
-      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-      implied, including, without limitation, any warranties or conditions
-      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
-      PARTICULAR PURPOSE. You are solely responsible for determining the
-      appropriateness of using or redistributing the Work and assume any
-      risks associated with Your exercise of permissions under this License.
-
-   8. Limitation of Liability. In no event and under no legal theory,
-      whether in tort (including negligence), contract, or otherwise,
-      unless required by applicable law (such as deliberate and grossly
-      negligent acts) or agreed to in writing, shall any Contributor be
-      liable to You for damages, including any direct, indirect, special,
-      incidental, or consequential damages of any character arising as a
-      result of this License or out of the use or inability to use the
-      Work (including but not limited to damages for loss of goodwill,
-      work stoppage, computer failure or malfunction, or any and all
-      other commercial damages or losses), even if such Contributor
-      has been advised of the possibility of such damages.
-
-   9. Accepting Warranty or Additional Liability. While redistributing
-      the Work or Derivative Works thereof, You may choose to offer,
-      and charge a fee for, acceptance of support, warranty, indemnity,
-      or other liability obligations and/or rights consistent with this
-      License. However, in accepting such obligations, You may act only
-      on Your own behalf and on Your sole responsibility, not on behalf
-      of any other Contributor, and only if You agree to indemnify,
-      defend, and hold each Contributor harmless for any liability
-      incurred by, or claims asserted against, such Contributor by reason
-      of your accepting any such warranty or additional liability.
-
-   END OF TERMS AND CONDITIONS
-
-   APPENDIX: How to apply the Apache License to your work.
-
-      To apply the Apache License to your work, attach the following
-      boilerplate notice, with the fields enclosed by brackets "[]"
-      replaced with your own identifying information. (Don't include
-      the brackets!)  The text should be enclosed in the appropriate
-      comment syntax for the file format. We also recommend that a
-      file or class name and description of purpose be included on the
-      same "printed page" as the copyright notice for easier
-      identification within third-party archives.
-
-   Copyright [yyyy] [name of copyright owner]
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 ----
 
@@ -3689,70 +3481,6 @@
 ----
 
 
-[[whatwg-fetch]]
-whatwg-fetch
-
-* whatwg-fetch
-
-[[whatwg-fetch_license]]
-----
-Copyright (c) 2014-2016 GitHub, Inc.
-
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-"Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
-----
-
-
-[[moment]]
-moment
-
-* moment
-
-[[moment_license]]
-----
-Copyright (c) JS Foundation and other contributors
-
-Permission is hereby granted, free of charge, to any person
-obtaining a copy of this software and associated documentation
-files (the "Software"), to deal in the Software without
-restriction, including without limitation the rights to use,
-copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the
-Software is furnished to do so, subject to the following
-conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
-OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
-HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
-OTHER DEALINGS IN THE SOFTWARE.
-
-----
-
-
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
@@ -3981,48 +3709,227 @@
 ----
 
 
-[[Polymer-2017]]
-Polymer-2017
+[[font-roboto-local-fonts-robotomono]]
+font-roboto-local-fonts-robotomono
 
-* @polymer/polymer
-* @webcomponents/shadycss
+* @polymer/font-roboto-local - only the following file(s):
+** fonts/robotomono/LICENSE.txt
+** fonts/robotomono/METADATA.json
+** fonts/robotomono/RobotoMono-Bold.ttf
+** fonts/robotomono/RobotoMono-BoldItalic.ttf
+** fonts/robotomono/RobotoMono-Italic.ttf
+** fonts/robotomono/RobotoMono-Light.ttf
+** fonts/robotomono/RobotoMono-LightItalic.ttf
+** fonts/robotomono/RobotoMono-Medium.ttf
+** fonts/robotomono/RobotoMono-MediumItalic.ttf
+** fonts/robotomono/RobotoMono-Regular.ttf
+** fonts/robotomono/RobotoMono-Thin.ttf
+** fonts/robotomono/RobotoMono-ThinItalic.ttf
 
-[[Polymer-2017_license]]
+[[font-roboto-local-fonts-robotomono_license]]
 ----
-Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
 
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
 
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
 
-   * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-   * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
-   * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
+   1. Definitions.
 
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
 
 ----
 
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 7e6799be..3040348 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -64,6 +64,7 @@
 * `caches/memory_eviction_count`: Memory eviction count.
 * `caches/disk_cached`: Disk entries used by persistent cache.
 * `caches/disk_hit_ratio`: Disk hit ratio for persistent cache.
+* `caches/refresh_count`: The number of refreshes per cache with an indicator if a reload was necessary.
 
 === Change
 
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index 1ce1d61..adb5d20 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -11,34 +11,27 @@
 [[loading]]
 == Plugin loading and initialization
 
-link:js-api.html#_entry_point[Entry point] for the plugin and the loading method
-is based on link:http://w3c.github.io/webcomponents/spec/imports/[HTML Imports,role=external,window=_blank]
-spec.
+link:js-api.html#_entry_point[Entry point] for the plugin.
 
-* The plugin provides pluginname.html, and can be a standalone file or a static
+* The plugin provides pluginname.js, and can be a standalone file or a static
   asset in a jar as a link:dev-plugins.html#deployment[Web UI plugin].
-* pluginname.html contains a `dom-module` tag with a script that uses
-  `Gerrit.install()`. There should only be single `Gerrit.install()` per file.
-* PolyGerrit imports pluginname.html along with all required resources defined in it
-  (fonts, styles, etc).
-* For standalone plugins, the entry point file is a `pluginname.html` file
+* pluginname.js contains a call to `Gerrit.install()`. There should
+  only be single `Gerrit.install()` per file.
+* PolyGerrit imports pluginname.js.
+* For standalone plugins, the entry point file is a `pluginname.js` file
   located in `gerrit-site/plugins` folder, where `pluginname` is an alphanumeric
   plugin name.
 
 Note: Code examples target modern browsers (Chrome, Firefox, Safari, Edge).
 
-Here's a recommended starter `myplugin.html`:
+Here's a recommended starter `myplugin.js`:
 
-``` html
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      'use strict';
+``` js
+Gerrit.install(plugin => {
+  'use strict';
 
-      // Your code here.
-    });
-  </script>
-</dom-module>
+  // Your code here.
+});
 ```
 
 [[low-level-api-concepts]]
@@ -103,34 +96,31 @@
 === Styling DOM Elements
 
 A plugin may provide Polymer's
-https://www.polymer-project.org/2.0/docs/devguide/style-shadow-dom#style-modules[style
+https://polymer-library.polymer-project.org/3.0/docs/devguide/style-shadow-dom[style
 modules,role=external,window=_blank] to style individual endpoints using
 `plugin.registerStyleModule(endpointName, moduleName)`. A style must be defined
-as a standalone `<dom-module>` defined in the same .html file.
+as a standalone `<dom-module>` defined in the same .js file.
+
+See `samples/theme-plugin.js` for examples.
 
 Note: TODO: Insert link to the full styling API.
 
-``` html
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.registerStyleModule('change-metadata', 'some-style-module');
-    });
-  </script>
-</dom-module>
-
-<dom-module id="some-style-module">
-  <style>
+``` js
+const styleElement = document.createElement('dom-module');
+styleElement.innerHTML =
+ `<template>
+    <style>
     html {
-      --change-metadata-label-status: {
-        display: none;
-      }
-      --change-metadata-strategy: {
-        display: none;
-      }
+      --primary-text-color: red;
     }
-  </style>
-</dom-module>
+   </style>
+ </template>`;
+
+styleElement.register('some-style-module');
+
+Gerrit.install(plugin => {
+  plugin.registerStyleModule('change-metadata', 'some-style-module');
+});
 ```
 
 [[high-level-api-concepts]]
@@ -152,11 +142,11 @@
 `plugin.attributeHelper(element)`
 
 Alternative for
-link:https://www.polymer-project.org/1.0/docs/devguide/data-binding[Polymer data
+link:https://polymer-library.polymer-project.org/3.0/docs/devguide/data-binding[Polymer data
 binding,role=external,window=_blank] for plugins that don't use Polymer. Can be used to bind element
 attribute changes to callbacks.
 
-See `samples/bind-parameters.html` for examples on both Polymer data bindings
+See `samples/bind-parameters.js` for examples on both Polymer data bindings
 and `attibuteHelper` usage.
 
 === eventHelper
@@ -253,26 +243,29 @@
 
 Here's the recommended approach that uses Polymer for generating custom elements:
 
-``` html
-<dom-module id="some-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.registerCustomComponent(
-        'change-view-integration', 'some-ci-module');
-    });
-  </script>
-</dom-module>
+``` js
+class SomeCiModule extends Polymer.Element {
+  static get is() {
+    return "some-ci-module";
+  }
+  static get template() {
+    return Polymer.html`
+      Sample link: <a href="http://some.com/foo">Foo</a>
+    `;
+  }
+}
 
-<dom-module id="some-ci-module">
-  <template>
-    Sample link: <a href="http://some.com/foo">Foo</a>
-  </template>
-  <script>
-    Polymer({is: 'some-ci-module'});
-  </script>
-</dom-module>
+// Register this element
+customElements.define(SomeCiModule.is, SomeCiModule);
+
+// Install the plugin
+Gerrit.install(plugin => {
+  plugin.registerCustomComponent('change-view-integration', 'some-ci-module');
+});
 ```
 
+See `samples/` for more examples.
+
 Here's a minimal example that uses low-level DOM Hooks API for the same purpose:
 
 ``` js
diff --git a/Documentation/prolog-change-facts.txt b/Documentation/prolog-change-facts.txt
index ac69616..51aef70 100644
--- a/Documentation/prolog-change-facts.txt
+++ b/Documentation/prolog-change-facts.txt
@@ -54,6 +54,17 @@
 |`commit_stats/3`   |`commit_stats(5,20,50).`
     |Number of files modified, number of insertions and the number of deletions.
 
+|`files/3` |`files(file('modules/jgit', 'A', 'SUBMODULE')).`
+
+           |`files(file('a.txt', 'M', 'REGULAR')).'
+
+    |A list of tuples: The first argument is a file name of the current patchset.
+    The second argument is the modification type of this file, with the options being
+    'A' for 'added', 'M' for 'modified', 'D' for 'deleted', 'R' for 'renamed', 'C' for
+    'COPIED' and 'W' for 'rewrite'.
+    The third argument is the type of file, with the options being a submodule file
+    'SUBMODULE' and a non-submodule file being 'REGULAR'.
+
 |`pure_revert/1`     |`pure_revert(1).`
     |link:rest-api-changes.html#get-pure-revert[Pure revert] as integer atom (1 if
         the change is a pure revert, 0 otherwise)
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 21b8c9f..32c30b8 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -951,40 +951,40 @@
 sum_list([], S, S).
 ----
 
-=== Example 14: Master and apprentice
-The master and apprentice example allow you to specify a user (the `master`)
-that must approve all changes done by another user (the `apprentice`).
+=== Example 14: Mentor and Mentee
+The mentor and mentee example allow you to specify a user (the `mentor`)
+that must approve all changes done by another user (the `mentee`).
 
 The code first checks if the commit author is in the apprentice database.
-If the commit is done by an `apprentice`, it will check if there is a `+2`
-review by the associated `master`.
+If the commit is done by a `mentee`, it will check if there is a `+2`
+review by the associated `mentor`.
 
 `rules.pl`
 [source,prolog]
 ----
-% master_apprentice(Master, Apprentice).
-% Extend this with appropriate user-id for your master/apprentice setup.
-master_apprentice(user(1000064), user(1000000)).
+% mentor_mentee(Mentor, Mentee).
+% Extend this with appropriate user-id for your mentor/mentee setup.
+mentor_mentee(user(1000064), user(1000000)).
 
 submit_rule(S) :-
     gerrit:default_submit(In),
     In =.. [submit | Ls],
-    add_apprentice_master(Ls, R),
+    add_mentee_mentor(Ls, R),
     S =.. [submit | R].
 
-check_master_approval(S1, S2, Master) :-
+check_mentor_approval(S1, S2, Mentor) :-
     gerrit:commit_label(label('Code-Review', 2), R),
-    R = Master, !,
-    S2 = [label('Master-Approval', ok(R)) | S1].
-check_master_approval(S1, [label('Master-Approval', need(_)) | S1], _).
+    R = Mentor, !,
+    S2 = [label('Mentor-Approval', ok(R)) | S1].
+check_mentor_approval(S1, [label('Mentor-Approval', need(_)) | S1], _).
 
-add_apprentice_master(S1, S2) :-
+add_mentee_mentor(S1, S2) :-
     gerrit:commit_author(Id),
-    master_apprentice(Master, Id),
+    mentor_mentee(Mentor, Id),
     !,
-    check_master_approval(S1, S2, Master).
+    check_mentor_approval(S1, S2, Mentor).
 
-add_apprentice_master(S, S).
+add_mentee_mentor(S, S).
 ----
 
 === Example 15: Make change submittable if all comments have been resolved
@@ -1083,6 +1083,35 @@
 indicate to the user that the change has to be a pure revert in order
 to become submittable.
 
+=== Example 17: Make a change submittable if it doesn't include specific files
+
+We can block any change which contains a submodule file change:
+
+`rules.pl`
+[source,prolog]
+----
+submit_rule(submit(R)) :-
+  gerrit:includes_file(file(_,_,'SUBMODULE')),
+  !,
+  R = label('All-Submodules-Resolved', need(_)).
+submit_rule(submit(label('All-Submodules-Resolved', ok(A)))) :-
+  gerrit:commit_author(A).
+----
+
+We can also block specific files, modification type, or file type,
+by changing include_files/1 to a different parameter. E.g,
+include_files('a.txt',_,_) includes any update to "a.txt", and
+('a.txt','D',_) includes any deletion to "a.txt". Also, (_,_,_) includes
+any file (other than magic file).
+
+An inclusive list of possible arguments using the code above with variations
+of include_file:
+The first parameter is the file name.
+The second is the modification type ('A' for 'added', 'M' for 'modified',
+'D' for 'deleted', 'R' for 'renamed', 'C' for 'COPIED' and 'W' for 'rewrite').
+The third argument is the type of file, with the options being a submodule
+file 'SUBMODULE' and a non-submodule file being 'REGULAR'.
+
 == Examples - Submit Type
 The following examples show how to implement own submit type rules.
 
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 6fbedb0..86f546d 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1284,6 +1284,7 @@
   )]}'
   {
     "changes_per_page": 25,
+    "theme": "LIGHT",
     "date_format": "STD",
     "time_format": "HHMM_12",
     "diff_view": "SIDE_BY_SIDE",
@@ -1336,6 +1337,7 @@
 
   {
     "changes_per_page": 50,
+    "theme": "DARK",
     "expand_inline_diffs": true,
     "date_format": "STD",
     "time_format": "HHMM_12",
@@ -1383,6 +1385,7 @@
   )]}'
   {
     "changes_per_page": 50,
+    "theme" "DARK",
     "expand_inline_diffs": true,
     "date_format": "STD",
     "time_format": "HHMM_12",
@@ -2759,6 +2762,9 @@
 |`changes_per_page`             ||
 The number of changes to show on each page.
 Allowed values are `10`, `25`, `50`, `100`.
+|`theme`                        ||
+Which theme to use.
+Allowed values are `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (PolyGerrit only).
@@ -2821,6 +2827,9 @@
 |`changes_per_page`             |optional|
 The number of changes to show on each page.
 Allowed values are `10`, `25`, `50`, `100`.
+|`theme`                        |optional|
+Which theme to use.
+Allowed values are `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (PolyGerrit only).
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 0f76c3d..6212b01 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -100,6 +100,15 @@
       "id": "demo~master~Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
       "project": "demo",
       "branch": "master",
+      "attention_set": [
+        {
+          "account": {
+            "name": "John Doe"
+          },
+         "last_update": "2012-07-17 07:19:27.766000000",
+         "reason": "reviewer or cc replied"
+        }
+      ]
       "change_id": "Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
       "subject": "One change",
       "status": "NEW",
@@ -519,6 +528,15 @@
     "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
     "project": "myProject",
     "branch": "master",
+    "attention_set": [
+      {
+        "account": {
+          "name": "John Doe"
+        },
+       "last_update": "2013-02-21 11:16:36.775000000",
+       "reason": "reviewer or cc replied"
+      }
+    ]
     "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
     "subject": "Implementing Feature X",
     "status": "NEW",
@@ -571,6 +589,18 @@
     "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
     "project": "myProject",
     "branch": "master",
+    "attention_set": [
+      {
+        "account": {
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "username": "jdoe"
+        },
+       "last_update": "2013-02-21 11:16:36.775000000",
+       "reason": "reviewer or cc replied"
+      }
+    ]
     "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
     "subject": "Implementing Feature X",
     "status": "NEW",
@@ -1126,6 +1156,8 @@
 The request body does not need to include a link:#abandon-input[
 AbandonInput] entity if no review comment is added.
 
+Abandoning a change also removes all users from the link:#attention-set[attention set].
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/abandon HTTP/1.0
@@ -1614,6 +1646,8 @@
 The request body only needs to include a link:#submit-input[
 SubmitInput] entity if submitting on behalf of another user.
 
+Submitting a change also removes all users from the link:#attention-set[attention set].
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/submit HTTP/1.0
@@ -2272,6 +2306,9 @@
 is added. Actions that create a new patch set in a WIP change default to
 notifying *OWNER* instead of *ALL*.
 
+Marking a change work in progress also removes all users from the
+link:#attention-set[attention set].
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/wip HTTP/1.0
@@ -2300,6 +2337,9 @@
 to include a link:#work-in-progress-input[WorkInProgressInput] entity
 if no review comment is added.
 
+Marking a change ready for review also adds all of the reviewers of the change
+to the link:#attention-set[attention set].
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/ready HTTP/1.0
@@ -3214,6 +3254,10 @@
 a CC on the change is added as reviewer, the reviewer state of that
 user is updated to reviewer.
 
+Adding a new reviewer also adds that reviewer to the attention set, unless
+the change is work in progress.
+Also, moving a reviewer to CC removes that user from the attention set.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers HTTP/1.0
@@ -3355,6 +3399,7 @@
 --
 
 Deletes a reviewer from a change.
+Deleting a reviewer also removes that user from the attention set.
 
 .Request
 ----
@@ -3913,6 +3958,33 @@
 added as a reviewer, otherwise (if they only commented) they are added to
 the CC list.
 
+Some updates to the attention set occur here. If more than one update should
+occur, only the first update in the order of the below documentation occurs:
+
+If a user is part of remove_from_attention_set, the user will be explicitly
+removed from the attention set.
+
+If a user is part of add_to_attention_set, the user will be explicitly
+added to the attention set.
+
+If the boolean ignore_default_attention_set_rules is set to true, all
+other rules below will be ignored:
+
+The user who created the review is removed from the attention set.
+
+If the change is ready for review, the following also apply:
+
+When the uploader replies, the owner is added to the attention set.
+
+When the owner or uploader replies, all the reviewers are added to
+the attention set.
+
+When neither the owner nor the uploader replies, add the owner and the
+uploader to the attention set.
+
+Then, new reviewers are added to the attention set, and removed reviewers
+(by becoming CC) are removed from the attention set.
+
 A review cannot be set on a change edit. Trying to post a review for a
 change edit fails with `409 Conflict`.
 
@@ -5670,6 +5742,172 @@
   HTTP/1.1 204 No Content
 ----
 
+[[attention-set-endpoints]]
+== Attention Set Endpoints
+
+[[get-attention-set]]
+=== Get Attention Set
+--
+'GET /changes/link:#change-id[\{change-id\}]/attention'
+--
+
+Returns all users that are currently in the attention set.
+As response a list of link:#attention-set-info[AttentionSetInfo]
+entity is returned.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "account": {
+        "_account_id": 1000096,
+        "name": "John Doe",
+        "email": "john.doe@example.com",
+        "username": "jdoe"
+      },
+      "last_update": "2013-02-01 09:59:32.126000000",
+      "reason": "reviewer or cc replied"
+    },
+    {
+      "account": {
+        "_account_id": 1000097,
+        "name": "Jane Doe",
+        "email": "jane.doe@example.com",
+        "username": "janedoe"
+      },
+      "last_update": "2013-02-01 09:59:32.126000000",
+      "reason": "Reviewer was added"
+    }
+  ]
+----
+
+[[add-to-attention-set]]
+=== Add To Attention Set
+--
+'POST /changes/link:#change-id[\{change-id\}]/attention'
+--
+
+Adds a single user to the attention set of a change.
+
+A user can only be added if they are not in the attention set.
+If a user is added while already in the attention set, the
+request is silently ignored.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention HTTP/1.0
+----
+
+Details should be provided in the request body as an
+link:#attention-set-input[AttentionSetInput] entity.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "user": "John Doe",
+    "reason": "reason"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "_account_id": 1000096,
+    "name": "John Doe",
+    "email": "john.doe@example.com",
+    "username": "jdoe"
+  }
+----
+
+[[remove-from-attention-set]]
+=== Remove from Attention Set
+--
+'DELETE /changes/link:#change-id[\{change-id\}]/attention/link:rest-api-accounts.html#account-id[\{account-id\}]' +
+'POST /changes/link:#change-id[\{change-id\}]/attention/link:rest-api-accounts.html#account-id[\{account-id\}]/delete'
+--
+
+Deletes a single user from the attention set of a change.
+
+A user can only be removed from the attention set if they
+are currently in the attention set. Otherwise, the request
+is silently ignored.
+
+.Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention/John%20Doe HTTP/1.0
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention/John%20Doe/delete HTTP/1.0
+----
+
+Reason can be provided in the request body as an
+link:#attention-set-input[AttentionSetInput] entity.
+
+User must be left empty, or the user must be exactly
+the same user as in the request header.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention/John%20Doe/delete HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "reason": "reason"
+  }
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
+[[attention-set]]
+== Attention Set
+Attention Set is the set of users that should perform some action on the
+change. E.g, reviewers should review the change, owner/uploader should
+add a new patchset or respond to comments.
+
+Users are added to the attention set if one the following apply:
+
+* They are manually added in link:#review-input[ReviewInput] in
+ add_to_attention_set.
+* They are added as reviewers.
+* The change is marked ready for review.
+* As an owner/uploader, when someone replies on your change.
+* As a reviewer, when the owner/uploader replies.
+
+Users are removed from the attention set if one the following apply:
+
+* They are manually removed in link:#review-input[ReviewInput] in
+ remove_from_attention_set.
+* They are removed from reviewers.
+* The change is marked work in progress, abandoned, or submitted.
+* When the user replies on a change.
+
+If the ignore_default_attention_set_rules in link:#review-input[ReviewInput]
+is set to true, no other changes to the attention set will occur during the
+link:#set-review[set-review].
+Also, users specified in the list will occur instead of any of the implicit
+changes to the attention set. E.g, if a user is added by add_to_attention_set
+in link:#review-input[ReviewInput], but also the change is marked work in
+progress, the user will still be added.
+
 [[ids]]
 == IDs
 
@@ -5724,6 +5962,11 @@
 The list of commits that are being integrated into the destination
 branch by submitting the merge commit.
 
+* `/PATCHSET_LEVEL`:
++
+This file path is used exclusively for posting and indicating
+patchset-level comments, thus not relevant for other use-cases.
+
 [[fix-id]]
 === \{fix-id\}
 UUID of a suggested fix.
@@ -5761,7 +6004,8 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[action-info]]
@@ -5862,6 +6106,42 @@
 should be added as assignee.
 |===========================
 
+[[attention-set-info]]
+=== AttentionSetInfo
+The `AttentionSetInfo` entity contains details of users that are in
+the link:#attention-set[attention set].
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name    ||Description
+|`account`     || link:rest-api-accounts.html#account-info[AccountInfo] entity.
+|`last_update` || The link:rest-api.html#timestamp[timestamp] of the last update.
+|`reason`      || The reason of for adding or removing the user.
+
+|===========================
+[[attention-set-input]]
+=== AttentionSetInput
+The `AttentionSetInput` entity contains details for adding users to the
+link:#attention-set[attention set] and removing them from it.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name        ||Description
+|`user`            |optional| link:rest-api-accounts.html#account-id[ID]
+of the account that should be added to the attention set. For removals,
+this field should be empty or the same as the field in the request header.
+|`reason`          || The reason of for adding or removing the user.
+|`notify`          |optional|
+Notify handling that defines to whom email notifications should be sent
+after the change is created. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `OWNER`.
+|`notify_details`  |optional|
+Additional information about whom to notify about the change creation
+as a map of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
+|===========================
+
 [[blame-info]]
 === BlameInfo
 The `BlameInfo` entity stores the commit metadata with the row coordinates where
@@ -5918,6 +6198,9 @@
 The name of the target branch. +
 The `refs/heads/` prefix is omitted.
 |`topic`              |optional|The topic to which this change belongs.
+|`attention_set`      |optional|
+The map that maps link:rest-api-accounts.html#account-id[account IDs]
+to link:#attention-set-info[AttentionSetInfo] of that account.
 |`assignee`           |optional|
 The assignee of the change as an link:rest-api-accounts.html#account-info[
 AccountInfo] entity.
@@ -6125,7 +6408,8 @@
 If not set, the default is `ALL`.
 |`notify_details`     |optional|
 Additional information about whom to notify about the change creation
-as a map of recipient type to link:#notify-info[NotifyInfo] entity.
+as a map of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |==================================
 
 [[change-message-info]]
@@ -6181,7 +6465,8 @@
 If not set, the default is `ALL`.
 |`notify_details`   |optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |`keep_reviewers`   |optional, defaults to false|
 If `true`, carries reviewers and ccs over from original change to newly created one.
 |`allow_conflicts`  |optional, defaults to false|
@@ -6218,7 +6503,7 @@
 comments may be returned for multiple patch sets.
 |`id`          ||The URL encoded UUID of the comment.
 |`path`        |optional|
-The path of the file for which the inline comment was done. +
+link:#file-id[The file path] for which the inline comment was done. +
 Not set if returned in a map where the key is the file path.
 |`side`        |optional|
 The side on which the comment was added. +
@@ -6257,6 +6542,9 @@
 Available with published comments. Contains the
 link:rest-api-changes.html#change-message-info[id] of the change message
 that this comment is linked to.
+|`commit_id` |optional|
+Hex commit SHA1 (40 characters string) of the commit of the patchset to which
+this comment applies.
 |===========================
 
 [[comment-input]]
@@ -6271,7 +6559,7 @@
 The URL encoded UUID of the comment if an existing draft comment should
 be updated.
 |`path`        |optional|
-The path of the file for which the inline comment should be added. +
+link:#file-id[The file path] for which the inline comment should be added. +
 Doesn't need to be set if contained in a map where the key is the file
 path.
 |`side`        |optional|
@@ -6371,7 +6659,8 @@
 If not set, the default is `OWNER` for WIP changes and `ALL` otherwise.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[delete-change-message-input]]
@@ -6417,7 +6706,8 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[delete-vote-input]]
@@ -6438,7 +6728,8 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[description-input]]
@@ -6902,8 +7193,8 @@
 be notified about an update. These notifications are sent out even if a
 `notify` option in the request input disables normal notifications.
 `NotifyInfo` entities are normally contained in a `notify_details` map
-in the request input where the key is the recipient type. The recipient
-type can be `TO`, `CC` and `BCC`.
+in the request input where the key is the
+link:user-notify.html#recipient-types[recipient type].
 
 [options="header",cols="1,^1,5"]
 |=======================
@@ -6958,7 +7249,8 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[pure-revert-info]]
@@ -7094,7 +7386,8 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the revert as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |`topic`         |optional|
 Name of the topic for the revert change. If not set, the default for Revert
 endpoint is the topic of the change being reverted, and the default for the
@@ -7154,24 +7447,24 @@
 
 [options="header",cols="1,^1,5"]
 |============================
-|Field Name               ||Description
-|`message`                |optional|
+|Field Name                             ||Description
+|`message`                              |optional|
 The message to be added as review comment.
-|`tag`                    |optional|
+|`tag`                                  |optional|
 Apply this tag to the review comment message, votes, and inline
 comments. Tags may be used by CI or other automated systems to
 distinguish them from human reviews. Votes/comments that contain `tag` with
 'autogenerated:' prefix can be filtered out in the web UI.
-|`labels`                 |optional|
+|`labels`                               |optional|
 The votes that should be added to the revision as a map that maps the
 label names to the voting values.
-|`comments`               |optional|
+|`comments`                             |optional|
 The comments that should be added as a map that maps a file path to a
 list of link:#comment-input[CommentInput] entities.
-|`robot_comments`         |optional|
+|`robot_comments`                       |optional|
 The robot comments that should be added as a map that maps a file path
 to a list of link:#robot-comment-input[RobotCommentInput] entities.
-|`drafts`                 |optional|
+|`drafts`                               |optional|
 Draft handling that defines how draft comments are handled that are
 already in the database but that were not also described in this
 input. +
@@ -7180,29 +7473,40 @@
 Only `KEEP` is allowed when used in conjunction with `on_behalf_of`. +
 If not set, the default is `KEEP`. If `on_behalf_of` is set, then no other value
 besides `KEEP` is allowed.
-|`notify`                 |optional|
+|`notify`                              |optional|
 Notify handling that defines to whom email notifications should be sent
 after the review is stored. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|`notify_details`         |optional|
+|`notify_details`                      |optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
-|`omit_duplicate_comments`|optional|
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
+|`omit_duplicate_comments`             |optional|
 If `true`, comments with the same content at the same place will be omitted.
-|`on_behalf_of`           |optional|
+|`on_behalf_of`                        |optional|
 link:rest-api-accounts.html#account-id[\{account-id\}] the review
 should be posted on behalf of. To use this option the caller must
 have been granted `labelAs-NAME` permission for all keys of labels.
-|`reviewers`              |optional|
+|`reviewers`                           |optional|
 A list of link:rest-api-changes.html#reviewer-input[ReviewerInput]
 representing reviewers that should be added to the change.
-|`ready`                  |optional|
+|`ready`                               |optional|
 If true, and if the change is work in progress, then start review.
 It is an error for both `ready` and `work_in_progress` to be true.
-|`work_in_progress`         |optional|
+|`work_in_progress`                    |optional|
 If true, mark the change as work in progress. It is an error for both
 `ready` and `work_in_progress` to be true.
+|`add_to_attention_set`                |optional|
+list of link:#attention-set-input[AttentionSetInput] entities to add
+to the link:#attention-set[attention set].
+|`remove_from_attention_set`           |optional|
+list of link:#attention-set-input[AttentionSetInput] entities to remove
+from the link:#attention-set[attention set].
+|`ignore_automatic_attention_set_rules`|optional|
+If set to true, ignore all automatic attention set rules described in the
+link:#attention-set[attention set]. Updates in add_to_attention_set
+and remove_from_attention_set are not ignored.
 |============================
 
 [[review-result]]
@@ -7280,7 +7584,8 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[revision-info]]
@@ -7427,7 +7732,8 @@
 If not set, the default is `ALL`.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
 |=============================
 
 [[submit-record]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index f76e0b8..4473a8d 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -2011,7 +2011,7 @@
 UserConfigInfo] entity.
 |`default_theme`           |optional|
 URL to a default PolyGerrit UI theme plugin, if available.
-Located in `/static/gerrit-theme.html` by default.
+Located in `/static/gerrit-theme.js` by default.
 |=======================================
 
 [[sshd-info]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index a4e27b3..ff022c5 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1253,6 +1253,57 @@
   }
 ----
 
+[[create-change]]
+=== Create Change for review.
+
+This endpoint is functionally equivalent to
+link:rest-api-changes.html#create-change[create change in the change
+API], but it has the project name in the URL, which is easier to route
+in sharded deployments.
+
+.Request
+----
+  POST /projects/myProject/create.change HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "subject" : "Let's support 100% Gerrit workflow direct in browser",
+    "branch" : "master",
+    "topic" : "create-change-in-browser",
+    "status" : "NEW"
+  }
+----
+
+As response a link:#change-info[ChangeInfo] entity is returned that describes
+the resulting change.
+
+.Response
+----
+  HTTP/1.1 201 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "project": "myProject",
+    "branch": "master",
+    "topic": "create-change-in-browser",
+    "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "subject": "Let's support 100% Gerrit workflow direct in browser",
+    "status": "NEW",
+    "created": "2014-05-05 07:15:44.639000000",
+    "updated": "2014-05-05 07:15:44.639000000",
+    "mergeable": true,
+    "insertions": 0,
+    "deletions": 0,
+    "_number": 4711,
+    "owner": {
+      "name": "John Doe"
+    }
+  }
+----
+
 [[create-access-change]]
 === Create Access Rights Change for review.
 --
@@ -3586,7 +3637,7 @@
 |`plugin_config`                           |optional|
 Plugin configuration as map which maps the plugin name to a map of
 parameter names to link:#config-parameter-info[ConfigParameterInfo]
-entities.
+entities. Only filled for users who have read access to `refs/meta/config`.
 |`actions`                                 |optional|
 Actions the caller might be able to perform on this project. The
 information is a map of view names to
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
new file mode 100644
index 0000000..bf6f52e
--- /dev/null
+++ b/Documentation/user-attention-set.txt
@@ -0,0 +1,133 @@
+= Gerrit Code Review - Attention Set
+
+The Attention Set will be part of the upcoming 3.3 release (due late 2020).
+We are testing at on some hosts on `googlesource.com` right now. If you build
+your Gerrit from master, you can enable it using
+link:config-gerrit.html#change.enableAttentionSet[enableAttentionSet].
+
+Report a bug or send feedback using
+link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set[this Monorail template].
+
+[[whose-turn]]
+== Whose turn is it?
+
+Code Review is a turn-based workflow going back and forth between the change
+owner and reviewers. For every change Gerrit maintains an "Attention Set" with
+users that are currently expected to act on the change. Both on the dashboard
+and on the change page, this is expressed by an arrow icon before the user name:
+
+image::images/user-attention-set-icon.png["account chip with attention icon", align="center"]
+
+While the attention set brings clarity to the process it also comes with
+responsibilities and expectations. To provide the best outcome for all users, we
+suggest following these principles:
+
+* Reviewers are expected to respond in a timely manner when it is their turn. If
+  you don't plan to respond within ~24h, then you should either remove yourself
+  from the attention set or you should at least send a clarification message to
+  the change owner.
+* Change owners are expected to manage the attention set of their changes
+  carefully. They should make sure that reviewers are only in the attention set
+  when the owner waits for a response from them.
+
+On the plus side you can strictly ignore everyone else's changes, if you are not
+in the attention set. :-)
+
+=== Rules
+
+To help with the back and forth, Gerrit applies some basic automated rules for
+changing the attention set:
+
+* If reviewers are added to a change, then they are added to the attention set.
+* If an active change is submitted, abandoned or reset to "work in progress",
+  then all users are removed from the attention set.
+* Replying (commenting, voting or just writing a change message) removes the
+  replying user from the attention set. And it adds all participants of comment
+  conversations that the user is replying to.
+* If a *reviewer* replies, then the change owner (and uploader) are added to the
+  attention set.
+* Only owner, uploader, reviewers and ccs can be in the attention set.
+
+*!IMPORTANT!* These rules are not meant to be super smart and to always do the
+right thing, e.g. if the change owner sends a reply, then they are often
+expected to individually select whose turn it is instead of adding *all*
+reviewers to the attention set.
+
+Note that just uploading a new patchset is not a relevant event for the
+attention set to change.
+
+=== Interaction
+
+There are two ways to interact with the attention set: The hovercard of owner
+and reviewer chips and the "Reply" dialog.
+
+*The hovercard* (on both the Dashboard and Change page) contains information
+about whether, why and when a user was added to the attention set. It also
+contains an action for adding/removing the user to/from the attention set.
+
+image::images/user-attention-set-hovercard.png["user hovercard with info and action", align="center"]
+
+*The reply dialog* contains a section for controlling to whom the turn should be
+passed.
+
+image::images/user-attention-set-reply-modify.png["reply dialog section for modifying", align="center"]
+
+If you do not click "MODIFY", then the backend will just apply the
+automated rules as stated above. If you click "MODIFY", then the section will
+expand and you can select and de-select users by clicking on their chips.
+Whatever you select here will be the new state of the attention set for this
+change. As a change owner make sure to remove reviewers that you don't expect to
+take action.
+
+image::images/user-attention-set-reply-select.png["reply dialog section for selecting users", align="center"]
+
+=== Bots
+
+[Caveat: This is not fully implemented yet!]
+
+The attention set is meant for human reviews only. Triggering bots and reacting
+to their results is a different workflow and not in scope of the attenion set.
+Thus members of the "Non-Interactive Users" group will never be added to the
+attention set. And replies by such users will not add the change owner to the
+attention set.
+
+=== Dashboard
+
+The default *dashboard* contains a new section at the top called "Your Turn". It
+lists all changes where the logged-in user is in the attention set. As an active
+developer one of your daily goals will be to iterate over this list and clear
+it.
+
+image::images/user-attention-set-dashboard.png["dashboard with Your Turn section", align="center"]
+
+Note that you can also navigate to other users' dashboards to check their
+"Your Turn" section.
+
+=== Assignee
+
+While the "Assignee" feature can still be used together with the attention set,
+we do not recommend doing so. Using both features is likely confusing. The
+distinct feature of the "Assignee" compared to the attention set is that only
+one user can be the assignee at the same time. So the assignee can be used to
+single out one person or escalate, if there are multiple reviewers. Since
+*every* reviewer in the attention set is expected to take action, singling out
+is not likely to be important and also still achievable with the attention set.
+Otherwise "Assignee" and "Attention Set" are very much overlapping, so we
+recommend to only use one of them.
+
+The "Assignee" feature can be turned on/off with the
+link:config-gerrit.html#change.enableAttentionSet[enableAssignee] config option.
+
+=== Bold Changes / Mark Reviewed
+
+Before the attention set feature, changes were bolded in the dashboard when
+*something* happened and you could explicitly "mark a change reviewed" on the
+change page. This former way of keeping track of what you should look at has
+been replaced by the attention set.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 5346b2e..5ee3136 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -6,6 +6,15 @@
 uploaded for review, after comments have been posted on a change,
 or after the change has been submitted to a branch.
 
+[[recipient-types]]
+== Recipient Type
+
+Those are the available recipient types:
++
+* `to`: The standard To field is used; addresses are visible to all.
+* `cc`: The standard CC field is used; addresses are visible to all.
+* `bcc`: SMTP RCPT TO is used to hide the address.
+
 [[user]]
 == User Level Settings
 
@@ -114,10 +123,8 @@
 Email header used to list the destination. If not set BCC is used.
 Only one value may be specified. To use different headers for each
 address list them in different notify blocks.
-+
-* `to`: The standard To field is used; addresses are visible to all.
-* `cc`: The standard CC field is used; addresses are visible to all.
-* `bcc`: SMTP RCPT TO is used to hide the address.
+
+The possible options are the link:#recipient-types[recipient types].
 
 [[notify.name.filter]]notify.<name>.filter::
 +
diff --git a/README.md b/README.md
index a76dac6..30b7d0b 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,8 @@
 [Gerrit](https://www.gerritcodereview.com) is a code review and project
 management tool for Git based projects.
 
-[![Build Status](https://gerrit-ci.gerritforge.com/job/Gerrit-master/badge/icon)](https://gerrit-ci.gerritforge.com/job/Gerrit-master/)
+[![Build Status](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-master/badge/icon)](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-master/)
+![Maven Central](https://img.shields.io/maven-central/v/com.google.gerrit/gerrit-war)
 
 ## Objective
 
@@ -76,13 +77,13 @@
 
 Docker images of Gerrit are available on [DockerHub](https://hub.docker.com/u/gerritforge/)
 
-To run a CentOS 7 based Gerrit image:
+To run a CentOS 8 based Gerrit image:
 
-        docker run -p 8080:8080 gerritforge/gerrit-centos7[:version]
+        docker run -p 8080:8080 gerritcodereview/gerrit[:version]-centos8
 
-To run a Ubuntu 15.04 based Gerrit image:
+To run a Ubuntu 20.04 based Gerrit image:
 
-        docker run -p 8080:8080 gerritforge/gerrit-ubuntu15.04[:version]
+        docker run -p 8080:8080 gerritcodereview/gerrit[:version]-ubuntu20
 
 _NOTE: release is optional. Last released package of the version is installed if the release
 number is omitted._
diff --git a/WORKSPACE b/WORKSPACE
index 5eb7af6..6be35cf 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -10,6 +10,8 @@
 #    @ui_npm folder must not have devDependencies. All dev dependencies must be placed in @ui_dev_npm
 # 4. @ui_dev_npm (polygerrit-ui/node_modules) - devDependencies for polygerrit. The packages from these
 #    folder can be used for testing, but must not be included in the final bundle.
+# 5. @plugins_npm (plugins/node_modules) - plugin dependencies for polygerrit plugins.
+#    The packages here are expected to be used in plugins.
 # Note: separation between @ui_npm and @ui_dev_npm is necessary because with bazel we can't generate
 #    two managed directories from the same package.json. At the same time we want to avoid accidental
 #    usages of code from devDependencies in polygerrit bundle.
@@ -20,6 +22,7 @@
         "@ui_npm": ["polygerrit-ui/app/node_modules"],
         "@ui_dev_npm": ["polygerrit-ui/node_modules"],
         "@tools_npm": ["tools/node_tools/node_modules"],
+        "@plugins_npm": ["plugins/node_modules"],
     },
 )
 
@@ -46,55 +49,32 @@
 # otherwise refer to RBE docs.
 rbe_autoconfig(name = "rbe_default")
 
-# TODO(davido): Switch to upstream again, when this PR is merged:
-# https://github.com/bazelbuild/rules_closure/pull/478
 http_archive(
-    name = "io_bazel_rules_closure",
-    sha256 = "b9c2bc6ba377aa497eb7c31681d34404febf9d4e3c9c7d98ce0d78238a0af20f",
-    strip_prefix = "rules_closure-0.31",
+    name = "com_google_protobuf",
+    sha256 = "71030a04aedf9f612d2991c1c552317038c3c5a2b578ac4745267a45e7037c29",
+    strip_prefix = "protobuf-3.12.3",
     urls = [
-        "https://github.com/davido/rules_closure/archive/V0.31.tar.gz",
-        "https://gerrit-ci.gerritforge.com/lib/V0.31.tar.gz",
+        "https://github.com/protocolbuffers/protobuf/archive/v3.12.3.tar.gz",
     ],
 )
 
+load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps")
+
+protobuf_deps()
+
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "d0c4bb8b902c1658f42eb5563809c70a06e46015d64057d25560b0eb4bdc9007",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/1.5.0/rules_nodejs-1.5.0.tar.gz"],
+    sha256 = "5bf77cc2d13ddf9124f4c1453dd96063774d755d4fc75d922471540d1c9a8ea8",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/2.0.0/rules_nodejs-2.0.0.tar.gz"],
 )
 
-# File is specific to Polymer and copied from the Closure Github -- should be
-# synced any time there are major changes to Polymer.
-# https://github.com/google/closure-compiler/blob/master/contrib/externs/polymer-1.0.js
-http_file(
-    name = "polymer_closure",
-    downloaded_file_path = "polymer_closure.js",
-    sha256 = "4d63a36dcca040475bd6deb815b9a600bd686e1413ac1ebd4b04516edd675020",
-    urls = ["https://raw.githubusercontent.com/google/closure-compiler/35d2b3340ff23a69441f10fa3bc820691c2942f2/contrib/externs/polymer-1.0.js"],
-)
-
-load("@io_bazel_rules_closure//closure:repositories.bzl", "rules_closure_dependencies", "rules_closure_toolchains")
-
-# Prevent redundant loading of dependencies.
-# TODO(davido): Omit re-fetching ancient args4j version when these PRs are merged:
-# https://github.com/bazelbuild/rules_closure/pull/262
-# https://github.com/google/closure-templates/pull/155
-rules_closure_dependencies(
-    omit_aopalliance = True,
-    omit_javax_inject = True,
-    omit_rules_cc = True,
-)
-
-rules_closure_toolchains()
-
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
-    sha256 = "b34cbe1a7514f5f5487c3bfee7340a4496713ddf4f119f7a225583d6cafd793a",
+    sha256 = "a8d6b1b354d371a646d2f7927319974e0f9e52f73a2452d2b3877118169eb6bb",
     urls = [
-        "https://storage.googleapis.com/bazel-mirror/github.com/bazelbuild/rules_go/releases/download/v0.21.1/rules_go-v0.21.1.tar.gz",
-        "https://github.com/bazelbuild/rules_go/releases/download/v0.21.1/rules_go-v0.21.1.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
+        "https://github.com/bazelbuild/rules_go/releases/download/v0.23.3/rules_go-v0.23.3.tar.gz",
     ],
 )
 
@@ -106,8 +86,11 @@
 
 http_archive(
     name = "bazel_gazelle",
-    sha256 = "3c681998538231a2d24d0c07ed5a7658cb72bfb5fd4bf9911157c0e9ac6a2687",
-    urls = ["https://github.com/bazelbuild/bazel-gazelle/releases/download/0.17.0/bazel-gazelle-0.17.0.tar.gz"],
+    sha256 = "cdb02a887a7187ea4d5a27452311a75ed8637379a1287d8eeb952138ea485f7d",
+    urls = [
+        "https://mirror.bazel.build/github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
+        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.21.1/bazel-gazelle-v0.21.1.tar.gz",
+    ],
 )
 
 load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies", "go_repository")
@@ -321,7 +304,7 @@
 )
 
 maven_jar(
-    name = "args4j-intern",
+    name = "args4j",
     artifact = "args4j:args4j:2.33",
     sha1 = "bd87a75374a6d6523de82fef51fc3cfe9baf9fc9",
 )
@@ -986,6 +969,40 @@
     sha1 = "639033469776fd37c08358c6b92a4761feb2af4b",
 )
 
+load("@build_bazel_rules_nodejs//:index.bzl", "yarn_install")
+
+yarn_install(
+    name = "npm",
+    package_json = "//:package.json",
+    yarn_lock = "//:yarn.lock",
+)
+
+yarn_install(
+    name = "ui_npm",
+    args = ["--prod"],
+    package_json = "//:polygerrit-ui/app/package.json",
+    yarn_lock = "//:polygerrit-ui/app/yarn.lock",
+)
+
+yarn_install(
+    name = "ui_dev_npm",
+    package_json = "//:polygerrit-ui/package.json",
+    yarn_lock = "//:polygerrit-ui/yarn.lock",
+)
+
+yarn_install(
+    name = "tools_npm",
+    package_json = "//:tools/node_tools/package.json",
+    yarn_lock = "//:tools/node_tools/yarn.lock",
+)
+
+yarn_install(
+    name = "plugins_npm",
+    args = ["--prod"],
+    package_json = "//:plugins/package.json",
+    yarn_lock = "//:plugins/yarn.lock",
+)
+
 load("//tools/bzl:js.bzl", "bower_archive", "npm_binary")
 
 # NPM binaries bundled along with their dependencies.
@@ -1177,40 +1194,4 @@
     version = "6.5.1",
 )
 
-load("@build_bazel_rules_nodejs//:index.bzl", "yarn_install")
-
-yarn_install(
-    name = "npm",
-    package_json = "//:package.json",
-    yarn_lock = "//:yarn.lock",
-)
-
-yarn_install(
-    name = "ui_npm",
-    args = ["--prod"],
-    package_json = "//:polygerrit-ui/app/package.json",
-    yarn_lock = "//:polygerrit-ui/app/yarn.lock",
-)
-
-yarn_install(
-    name = "ui_dev_npm",
-    package_json = "//:polygerrit-ui/package.json",
-    yarn_lock = "//:polygerrit-ui/yarn.lock",
-)
-
-yarn_install(
-    name = "tools_npm",
-    package_json = "//:tools/node_tools/package.json",
-    yarn_lock = "//:tools/node_tools/yarn.lock",
-)
-
-# Install all Bazel dependencies needed for npm packages that supply Bazel rules
-load("@npm//:install_bazel_dependencies.bzl", "install_bazel_dependencies")
-
-install_bazel_dependencies()
-
-load("@npm_bazel_typescript//:index.bzl", "ts_setup_workspace")
-
-ts_setup_workspace()
-
 external_plugin_deps()
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 89cc724..fe1a140 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -49,21 +49,23 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
@@ -89,8 +91,6 @@
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -802,6 +802,24 @@
   private static final List<Character> RANDOM =
       Chars.asList('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h');
 
+  protected PushOneCommit.Result amendChangeWithUploader(
+      PushOneCommit.Result change, Project.NameKey projectName, TestAccount account)
+      throws Exception {
+    TestRepository<InMemoryRepository> repo = cloneProject(projectName, account);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(change.getCommit());
+    PushOneCommit.Result result =
+        amendChange(
+            change.getChangeId(),
+            "refs/for/master",
+            account,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    return result;
+  }
+
   protected PushOneCommit.Result amendChange(String changeId) throws Exception {
     return amendChange(changeId, "refs/for/master", admin, testRepo);
   }
@@ -983,7 +1001,7 @@
   protected void setUseSignedOffBy(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig config = projectConfigFactory.read(md);
-      config.getProject().setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, value);
+      config.updateProject(p -> p.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, value));
       config.commit(md);
       projectCache.evict(config.getProject());
     }
@@ -992,7 +1010,7 @@
   protected void setRequireChangeId(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig config = projectConfigFactory.read(md);
-      config.getProject().setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, value);
+      config.updateProject(p -> p.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, value));
       config.commit(md);
       projectCache.evict(config.getProject());
     }
@@ -1213,11 +1231,15 @@
       String ref,
       boolean exclusive,
       String... permissionNames) {
-    ProjectConfig cfg = projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
-    AccessSection accessSection = cfg.getAccessSection(ref);
-    assertThat(accessSection).isNotNull();
+    Optional<AccessSection> accessSection =
+        projectCache
+            .get(project)
+            .orElseThrow(illegalState(project))
+            .getConfig()
+            .getAccessSection(ref);
+    assertThat(accessSection).isPresent();
     for (String permissionName : permissionNames) {
-      Permission permission = accessSection.getPermission(permissionName);
+      Permission permission = accessSection.get().getPermission(permissionName);
       assertPermission(permission, permissionName, exclusive, null);
       assertPermissionRule(
           permission.getRule(groupReference), groupReference, Action.ALLOW, false, 0, 0);
@@ -1257,7 +1279,7 @@
 
   protected GroupReference groupRef(AccountGroup.UUID groupUuid) {
     GroupDescription.Basic groupDescription = groupBackend.get(groupUuid);
-    return new GroupReference(groupDescription.getGroupUUID(), groupDescription.getName());
+    return GroupReference.create(groupDescription.getGroupUUID(), groupDescription.getName());
   }
 
   protected InternalGroup group(String groupName) {
@@ -1269,7 +1291,7 @@
   protected GroupReference groupRef(String groupName) {
     InternalGroup group = groupCache.get(AccountGroup.nameKey(groupName)).orElse(null);
     assertThat(group).isNotNull();
-    return new GroupReference(group.getGroupUUID(), group.getName());
+    return GroupReference.create(group.getGroupUUID(), group.getName());
   }
 
   protected AccountGroup.UUID groupUuid(String groupName) {
@@ -1442,10 +1464,10 @@
       LabelValue... value)
       throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = label(label, value);
+      LabelType.Builder labelType = label(label, value).toBuilder();
       labelType.setFunction(func);
-      labelType.setRefPatterns(refPatterns);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      labelType.setRefPatterns(ImmutableList.copyOf(refPatterns));
+      u.getConfig().upsertLabelType(labelType.build());
       u.save();
     }
   }
@@ -1453,10 +1475,11 @@
   protected void enableCreateNewChangeForAllNotInTarget() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .getProject()
-          .setBooleanConfig(
-              BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
-              InheritableBoolean.TRUE);
+          .updateProject(
+              p ->
+                  p.setBooleanConfig(
+                      BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+                      InheritableBoolean.TRUE));
       u.save();
     }
   }
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index bb3901e..452df67 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -28,6 +28,10 @@
 import com.google.common.truth.Truth;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.EmailHeader.AddressList;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
@@ -36,10 +40,6 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
-import com.google.gerrit.mail.EmailHeader.AddressList;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 9d8bc57..db0dc84 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -64,6 +64,7 @@
     "//java/com/google/gerrit/acceptance/config",
     "//java/com/google/gerrit/acceptance/testsuite/project",
     "//java/com/google/gerrit/server/fixes/testing",
+    "//java/com/google/gerrit/server/data",
     "//java/com/google/gerrit/server/group/testing",
     "//java/com/google/gerrit/server/project/testing:project-test-util",
     "//java/com/google/gerrit/testing:gerrit-test-util",
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index cfe7964..a5d8d19 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeETagComputation;
+import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
@@ -79,6 +80,7 @@
   private final DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners;
   private final DynamicMap<CapabilityDefinition> capabilityDefinitions;
   private final DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions;
+  private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
 
   @Inject
   ExtensionRegistry(
@@ -107,7 +109,8 @@
       DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners,
       DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners,
       DynamicMap<CapabilityDefinition> capabilityDefinitions,
-      DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions) {
+      DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions,
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
@@ -134,6 +137,7 @@
     this.workInProgressStateChangedListeners = workInProgressStateChangedListeners;
     this.capabilityDefinitions = capabilityDefinitions;
     this.pluginProjectPermissionDefinitions = pluginProjectPermissionDefinitions;
+    this.pluginConfigEntries = pluginConfigEntries;
   }
 
   public Registration newRegistration() {
@@ -254,6 +258,10 @@
       return add(pluginProjectPermissionDefinitions, pluginProjectPermissionDefinition, exportName);
     }
 
+    public Registration add(ProjectConfigEntry pluginConfigEntry, String exportName) {
+      return add(pluginConfigEntries, pluginConfigEntry, exportName);
+    }
+
     private <T> Registration add(DynamicSet<T> dynamicSet, T extension) {
       return add(dynamicSet, extension, "gerrit");
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 0ef6ad5..2d62608 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -483,7 +483,6 @@
     cfg.setString("gerrit", null, "basePath", "git");
     cfg.setBoolean("sendemail", null, "enable", true);
     cfg.setInt("sendemail", null, "threadPoolSize", 0);
-    cfg.setInt("cache", "projects", "checkFrequency", 0);
     cfg.setInt("plugins", null, "checkFrequency", 0);
 
     cfg.setInt("sshd", null, "threads", 1);
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index 3ccbe4d..afd451a 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -25,6 +25,8 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -91,6 +93,14 @@
         @Assisted String subject,
         @Assisted Map<String, String> files);
 
+    @UsedAt(Project.PLUGIN_CODE_OWNERS)
+    PushOneCommit create(
+        PersonIdent i,
+        TestRepository<?> testRepo,
+        @Assisted("subject") String subject,
+        @Assisted Map<String, String> files,
+        @Assisted("changeId") String changeId);
+
     PushOneCommit create(
         PersonIdent i,
         TestRepository<?> testRepo,
@@ -227,15 +237,16 @@
         changeId);
   }
 
-  private PushOneCommit(
+  @AssistedInject
+  PushOneCommit(
       ChangeNotes.Factory notesFactory,
       ApprovalsUtil approvalsUtil,
       Provider<InternalChangeQuery> queryProvider,
-      PersonIdent i,
-      TestRepository<?> testRepo,
-      String subject,
-      Map<String, String> files,
-      String changeId)
+      @Assisted PersonIdent i,
+      @Assisted TestRepository<?> testRepo,
+      @Assisted("subject") String subject,
+      @Assisted Map<String, String> files,
+      @Nullable @Assisted("changeId") String changeId)
       throws Exception {
     this.testRepo = testRepo;
     this.notesFactory = notesFactory;
diff --git a/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java b/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
index b985e40..0b2282e 100644
--- a/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
+++ b/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
-import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
diff --git a/java/com/google/gerrit/acceptance/TestAccount.java b/java/com/google/gerrit/acceptance/TestAccount.java
index a7a4a89..e9c0899 100644
--- a/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/TestAccount.java
@@ -22,7 +22,7 @@
 import com.google.common.net.InetAddresses;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import java.net.InetSocketAddress;
 import java.util.Arrays;
 import org.apache.http.client.utils.URIBuilder;
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index f64d7a2..8c1eebd 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -15,7 +15,10 @@
 package com.google.gerrit.acceptance.testsuite.account;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountState;
@@ -60,8 +63,8 @@
 
   private Account.Id createAccount(TestAccountCreation accountCreation) throws Exception {
     AccountsUpdate.AccountUpdater accountUpdater =
-        (account, updateBuilder) ->
-            fillBuilder(updateBuilder, accountCreation, account.account().id());
+        (accountState, updateBuilder) ->
+            fillBuilder(updateBuilder, accountCreation, accountState.account().id());
     AccountState createdAccount = createAccount(accountUpdater);
     return createdAccount.account().id();
   }
@@ -82,6 +85,11 @@
     accountCreation.username().ifPresent(u -> setUsername(builder, accountId, u, httpPassword));
     accountCreation.status().ifPresent(builder::setStatus);
     accountCreation.active().ifPresent(builder::setActive);
+    accountCreation
+        .secondaryEmails()
+        .forEach(
+            secondaryEmail ->
+                builder.addExternalId(ExternalId.createEmail(accountId, secondaryEmail)));
   }
 
   private static InternalAccountUpdate.Builder setPreferredEmail(
@@ -136,6 +144,7 @@
           .fullname(Optional.ofNullable(account.fullName()))
           .username(accountState.userName())
           .active(accountState.account().isActive())
+          .emails(ExternalId.getEmails(accountState.externalIds()).collect(toImmutableSet()))
           .build();
     }
 
@@ -147,7 +156,7 @@
     private void updateAccount(TestAccountUpdate accountUpdate)
         throws IOException, ConfigInvalidException {
       AccountsUpdate.AccountUpdater accountUpdater =
-          (account, updateBuilder) -> fillBuilder(updateBuilder, accountUpdate, accountId);
+          (accountState, updateBuilder) -> fillBuilder(updateBuilder, accountUpdate, accountState);
       Optional<AccountState> updatedAccount = updateAccount(accountUpdater);
       checkState(updatedAccount.isPresent(), "Tried to update non-existing test account");
     }
@@ -160,13 +169,58 @@
     private void fillBuilder(
         InternalAccountUpdate.Builder builder,
         TestAccountUpdate accountUpdate,
-        Account.Id accountId) {
+        AccountState accountState) {
       accountUpdate.fullname().ifPresent(builder::setFullName);
       accountUpdate.preferredEmail().ifPresent(e -> setPreferredEmail(builder, accountId, e));
       String httpPassword = accountUpdate.httpPassword().orElse(null);
       accountUpdate.username().ifPresent(u -> setUsername(builder, accountId, u, httpPassword));
       accountUpdate.status().ifPresent(builder::setStatus);
       accountUpdate.active().ifPresent(builder::setActive);
+
+      ImmutableSet<String> secondaryEmails = getSecondaryEmails(accountUpdate, accountState);
+      ImmutableSet<String> newSecondaryEmails =
+          ImmutableSet.copyOf(accountUpdate.secondaryEmailsModification().apply(secondaryEmails));
+      if (!secondaryEmails.equals(newSecondaryEmails)) {
+        setSecondaryEmails(builder, accountUpdate, accountState, newSecondaryEmails);
+      }
+    }
+
+    private ImmutableSet<String> getSecondaryEmails(
+        TestAccountUpdate accountUpdate, AccountState accountState) {
+      ImmutableSet<String> allEmails =
+          ExternalId.getEmails(accountState.externalIds()).collect(toImmutableSet());
+      if (accountUpdate.preferredEmail().isPresent()) {
+        return ImmutableSet.copyOf(
+            Sets.difference(allEmails, ImmutableSet.of(accountUpdate.preferredEmail().get())));
+      } else if (accountState.account().preferredEmail() != null) {
+        return ImmutableSet.copyOf(
+            Sets.difference(allEmails, ImmutableSet.of(accountState.account().preferredEmail())));
+      }
+      return allEmails;
+    }
+
+    private void setSecondaryEmails(
+        InternalAccountUpdate.Builder builder,
+        TestAccountUpdate accountUpdate,
+        AccountState accountState,
+        ImmutableSet<String> newSecondaryEmails) {
+      // delete all external IDs of SCHEME_MAILTO scheme, then add back SCHEME_MAILTO external IDs
+      // for the new secondary emails and the preferred email
+      builder.deleteExternalIds(
+          accountState.externalIds().stream()
+              .filter(e -> e.isScheme(ExternalId.SCHEME_MAILTO))
+              .collect(toImmutableSet()));
+      builder.addExternalIds(
+          newSecondaryEmails.stream()
+              .map(secondaryEmail -> ExternalId.createEmail(accountId, secondaryEmail))
+              .collect(toImmutableSet()));
+      if (accountUpdate.preferredEmail().isPresent()) {
+        builder.addExternalId(
+            ExternalId.createEmail(accountId, accountUpdate.preferredEmail().get()));
+      } else if (accountState.account().preferredEmail() != null) {
+        builder.addExternalId(
+            ExternalId.createEmail(accountId, accountState.account().preferredEmail()));
+      }
     }
 
     @Override
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
index 2574d55..94b1cc4 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.testsuite.account;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
 import java.util.Optional;
 
@@ -30,6 +32,16 @@
 
   public abstract boolean active();
 
+  public abstract ImmutableSet<String> emails();
+
+  public ImmutableSet<String> secondaryEmails() {
+    if (!preferredEmail().isPresent()) {
+      return emails();
+    }
+
+    return ImmutableSet.copyOf(Sets.difference(emails(), ImmutableSet.of(preferredEmail().get())));
+  }
+
   static Builder builder() {
     return new AutoValue_TestAccount.Builder();
   }
@@ -46,6 +58,8 @@
 
     abstract Builder active(boolean active);
 
+    abstract Builder emails(ImmutableSet<String> emails);
+
     abstract TestAccount build();
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
index 983fec0..042dc9a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountCreation.java
@@ -14,10 +14,14 @@
 
 package com.google.gerrit.acceptance.testsuite.account;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.entities.Account;
 import java.util.Optional;
+import java.util.Set;
 
 @AutoValue
 public abstract class TestAccountCreation {
@@ -33,6 +37,8 @@
 
   public abstract Optional<Boolean> active();
 
+  public abstract ImmutableSet<String> secondaryEmails();
+
   abstract ThrowingFunction<TestAccountCreation, Account.Id> accountCreator();
 
   public static Builder builder(ThrowingFunction<TestAccountCreation, Account.Id> accountCreator) {
@@ -83,14 +89,29 @@
       return active(false);
     }
 
+    public abstract Builder secondaryEmails(Set<String> secondaryEmails);
+
+    abstract ImmutableSet.Builder<String> secondaryEmailsBuilder();
+
+    public Builder addSecondaryEmail(String secondaryEmail) {
+      secondaryEmailsBuilder().add(secondaryEmail);
+      return this;
+    }
+
     abstract Builder accountCreator(
         ThrowingFunction<TestAccountCreation, Account.Id> accountCreator);
 
     abstract TestAccountCreation autoBuild();
 
     public Account.Id create() {
-      TestAccountCreation accountUpdate = autoBuild();
-      return accountUpdate.accountCreator().applyAndThrowSilently(accountUpdate);
+      TestAccountCreation accountCreation = autoBuild();
+      if (accountCreation.preferredEmail().isPresent()) {
+        checkState(
+            !accountCreation.secondaryEmails().contains(accountCreation.preferredEmail().get()),
+            "preferred email %s cannot be secondary email at the same time",
+            accountCreation.preferredEmail().get());
+      }
+      return accountCreation.accountCreator().applyAndThrowSilently(accountCreation);
     }
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
index da599e7..46988eb 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccountUpdate.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.acceptance.testsuite.account;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
 
 @AutoValue
 public abstract class TestAccountUpdate {
@@ -32,11 +36,14 @@
 
   public abstract Optional<Boolean> active();
 
+  public abstract Function<ImmutableSet<String>, Set<String>> secondaryEmailsModification();
+
   abstract ThrowingConsumer<TestAccountUpdate> accountUpdater();
 
   public static Builder builder(ThrowingConsumer<TestAccountUpdate> accountUpdater) {
     return new AutoValue_TestAccountUpdate.Builder()
         .accountUpdater(accountUpdater)
+        .secondaryEmailsModification(in -> in)
         .httpPassword("http-pass");
   }
 
@@ -82,6 +89,37 @@
       return active(false);
     }
 
+    abstract Builder secondaryEmailsModification(
+        Function<ImmutableSet<String>, Set<String>> secondaryEmailsModification);
+
+    abstract Function<ImmutableSet<String>, Set<String>> secondaryEmailsModification();
+
+    public Builder clearSecondaryEmails() {
+      return secondaryEmailsModification(originalSecondaryEmail -> ImmutableSet.of());
+    }
+
+    public Builder addSecondaryEmail(String secondaryEmail) {
+      Function<ImmutableSet<String>, Set<String>> secondaryEmailsModification =
+          secondaryEmailsModification();
+      secondaryEmailsModification(
+          originalSecondaryEmails ->
+              Sets.union(
+                  secondaryEmailsModification.apply(originalSecondaryEmails),
+                  ImmutableSet.of(secondaryEmail)));
+      return this;
+    }
+
+    public Builder removeSecondaryEmail(String secondaryEmail) {
+      Function<ImmutableSet<String>, Set<String>> previousModification =
+          secondaryEmailsModification();
+      secondaryEmailsModification(
+          originalSecondaryEmails ->
+              Sets.difference(
+                  previousModification.apply(originalSecondaryEmails),
+                  ImmutableSet.of(secondaryEmail)));
+      return this;
+    }
+
     abstract Builder accountUpdater(ThrowingConsumer<TestAccountUpdate> accountUpdater);
 
     abstract TestAccountUpdate autoBuild();
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index 21bfcd1..c894059 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -26,11 +26,11 @@
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -151,51 +151,52 @@
         ProjectConfig projectConfig,
         ImmutableList<TestProjectUpdate.TestPermissionKey> removedPermissions) {
       for (TestProjectUpdate.TestPermissionKey p : removedPermissions) {
-        Permission permission =
-            projectConfig.getAccessSection(p.section(), true).getPermission(p.name(), true);
-        if (p.group().isPresent()) {
-          GroupReference group = new GroupReference(p.group().get(), p.group().get().get());
-          group = projectConfig.resolve(group);
-          permission.removeRule(group);
-        } else {
-          permission.clearRules();
-        }
+        projectConfig.upsertAccessSection(
+            p.section(),
+            as -> {
+              Permission.Builder permission = as.upsertPermission(p.name());
+              if (p.group().isPresent()) {
+                GroupReference group =
+                    GroupReference.create(p.group().get(), p.group().get().get());
+                group = projectConfig.resolve(group);
+                permission.removeRule(group);
+              } else {
+                permission.clearRules();
+              }
+            });
       }
     }
 
     private void addCapabilities(
         ProjectConfig projectConfig, ImmutableList<TestCapability> addedCapabilities) {
       for (TestCapability c : addedCapabilities) {
-        PermissionRule rule = newRule(projectConfig, c.group());
+        PermissionRule.Builder rule = newRule(projectConfig, c.group());
         rule.setRange(c.min(), c.max());
-        projectConfig
-            .getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-            .getPermission(c.name(), true)
-            .add(rule);
+        projectConfig.upsertAccessSection(
+            AccessSection.GLOBAL_CAPABILITIES, as -> as.upsertPermission(c.name()).add(rule));
       }
     }
 
     private void addPermissions(
         ProjectConfig projectConfig, ImmutableList<TestPermission> addedPermissions) {
       for (TestPermission p : addedPermissions) {
-        PermissionRule rule = newRule(projectConfig, p.group());
+        PermissionRule.Builder rule = newRule(projectConfig, p.group());
         rule.setAction(p.action());
         rule.setForce(p.force());
-        projectConfig.getAccessSection(p.ref(), true).getPermission(p.name(), true).add(rule);
+        projectConfig.upsertAccessSection(p.ref(), as -> as.upsertPermission(p.name()).add(rule));
       }
     }
 
     private void addLabelPermissions(
         ProjectConfig projectConfig, ImmutableList<TestLabelPermission> addedLabelPermissions) {
       for (TestLabelPermission p : addedLabelPermissions) {
-        PermissionRule rule = newRule(projectConfig, p.group());
+        PermissionRule.Builder rule = newRule(projectConfig, p.group());
         rule.setAction(p.action());
         rule.setRange(p.min(), p.max());
         String permissionName =
             p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
-        Permission permission =
-            projectConfig.getAccessSection(p.ref(), true).getPermission(permissionName, true);
-        permission.add(rule);
+        projectConfig.upsertAccessSection(
+            p.ref(), as -> as.upsertPermission(permissionName).add(rule));
       }
     }
 
@@ -204,10 +205,9 @@
         ImmutableMap<TestProjectUpdate.TestPermissionKey, Boolean> exclusiveGroupPermissions) {
       exclusiveGroupPermissions.forEach(
           (key, exclusive) ->
-              projectConfig
-                  .getAccessSection(key.section(), true)
-                  .getPermission(key.name(), true)
-                  .setExclusiveGroup(exclusive));
+              projectConfig.upsertAccessSection(
+                  key.section(),
+                  as -> as.upsertPermission(key.name()).setExclusiveGroup(exclusive)));
     }
 
     private RevCommit headOrNull(String branch) {
@@ -324,9 +324,10 @@
     }
   }
 
-  private static PermissionRule newRule(ProjectConfig project, AccountGroup.UUID groupUUID) {
-    GroupReference group = new GroupReference(groupUUID, groupUUID.get());
+  private static PermissionRule.Builder newRule(
+      ProjectConfig project, AccountGroup.UUID groupUUID) {
+    GroupReference group = GroupReference.create(groupUUID, groupUUID.get());
     group = project.resolve(group);
-    return new PermissionRule(group);
+    return PermissionRule.builder(group);
   }
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
index 734854b..ea20931 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.acceptance.testsuite.project;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.common.data.AccessSection.GLOBAL_CAPABILITIES;
+import static com.google.gerrit.entities.AccessSection.GLOBAL_CAPABILITIES;
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
@@ -23,11 +23,11 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.AllProjectsName;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 9f8b255..e38ad9a 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -34,6 +34,7 @@
     GOOGLE,
     COLLABNET,
     PLUGIN_CHECKS,
+    PLUGIN_CODE_OWNERS,
     PLUGIN_DELETE_PROJECT,
     PLUGIN_SERVICEUSER,
     PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
diff --git a/java/com/google/gerrit/common/data/AccessSection.java b/java/com/google/gerrit/common/data/AccessSection.java
deleted file mode 100644
index 0c9663b..0000000
--- a/java/com/google/gerrit/common/data/AccessSection.java
+++ /dev/null
@@ -1,189 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Project;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/** Portion of a {@link Project} describing access rules. */
-public final class AccessSection implements Comparable<AccessSection> {
-  /** Special name given to the global capabilities; not a valid reference. */
-  public static final String GLOBAL_CAPABILITIES = "GLOBAL_CAPABILITIES";
-  /** Pattern that matches all references in a project. */
-  public static final String ALL = "refs/*";
-
-  /** Pattern that matches all branches in a project. */
-  public static final String HEADS = "refs/heads/*";
-
-  /** Prefix that triggers a regular expression pattern. */
-  public static final String REGEX_PREFIX = "^";
-
-  /** Name of the access section. It could be a ref pattern or something else. */
-  private String name;
-
-  private List<Permission> permissions;
-
-  public AccessSection(String name) {
-    this.name = name;
-  }
-
-  /** @return true if the name is likely to be a valid reference section name. */
-  public static boolean isValidRefSectionName(String name) {
-    return name.startsWith("refs/") || name.startsWith("^refs/");
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public ImmutableList<Permission> getPermissions() {
-    return permissions == null ? ImmutableList.of() : ImmutableList.copyOf(permissions);
-  }
-
-  public void setPermissions(List<Permission> list) {
-    requireNonNull(list);
-
-    Set<String> names = new HashSet<>();
-    for (Permission p : list) {
-      if (!names.add(p.getName().toLowerCase())) {
-        throw new IllegalArgumentException();
-      }
-    }
-
-    permissions = new ArrayList<>(list);
-  }
-
-  @Nullable
-  public Permission getPermission(String name) {
-    return getPermission(name, false);
-  }
-
-  @Nullable
-  public Permission getPermission(String name, boolean create) {
-    requireNonNull(name);
-
-    if (permissions != null) {
-      for (Permission p : permissions) {
-        if (p.getName().equalsIgnoreCase(name)) {
-          return p;
-        }
-      }
-    }
-
-    if (create) {
-      if (permissions == null) {
-        permissions = new ArrayList<>();
-      }
-
-      Permission p = new Permission(name);
-      permissions.add(p);
-      return p;
-    }
-
-    return null;
-  }
-
-  public void addPermission(Permission permission) {
-    requireNonNull(permission);
-
-    if (permissions == null) {
-      permissions = new ArrayList<>();
-    }
-
-    for (Permission p : permissions) {
-      if (p.getName().equalsIgnoreCase(permission.getName())) {
-        throw new IllegalArgumentException();
-      }
-    }
-
-    permissions.add(permission);
-  }
-
-  public void remove(Permission permission) {
-    requireNonNull(permission);
-    removePermission(permission.getName());
-  }
-
-  public void removePermission(String name) {
-    requireNonNull(name);
-
-    if (permissions != null) {
-      permissions.removeIf(permission -> name.equalsIgnoreCase(permission.getName()));
-    }
-  }
-
-  public void mergeFrom(AccessSection section) {
-    requireNonNull(section);
-
-    for (Permission src : section.getPermissions()) {
-      Permission dst = getPermission(src.getName());
-      if (dst != null) {
-        dst.mergeFrom(src);
-      } else {
-        permissions.add(src);
-      }
-    }
-  }
-
-  @Override
-  public int compareTo(AccessSection o) {
-    return comparePattern().compareTo(o.comparePattern());
-  }
-
-  private String comparePattern() {
-    if (getName().startsWith(REGEX_PREFIX)) {
-      return getName().substring(REGEX_PREFIX.length());
-    }
-    return getName();
-  }
-
-  @Override
-  public String toString() {
-    return "AccessSection[" + getName() + "]";
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (!(obj instanceof AccessSection)) {
-      return false;
-    }
-
-    AccessSection other = (AccessSection) obj;
-    if (!getName().equals(other.getName())) {
-      return false;
-    }
-    return new HashSet<>(getPermissions())
-        .equals(new HashSet<>(((AccessSection) obj).getPermissions()));
-  }
-
-  @Override
-  public int hashCode() {
-    int hashCode = super.hashCode();
-    if (permissions != null) {
-      for (Permission permission : permissions) {
-        hashCode += permission.hashCode();
-      }
-    }
-    hashCode += getName().hashCode();
-    return hashCode;
-  }
-}
diff --git a/java/com/google/gerrit/common/data/CommentDetail.java b/java/com/google/gerrit/common/data/CommentDetail.java
index 55e0143..053764d 100644
--- a/java/com/google/gerrit/common/data/CommentDetail.java
+++ b/java/com/google/gerrit/common/data/CommentDetail.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import java.util.ArrayList;
 import java.util.List;
@@ -36,7 +37,7 @@
 
   protected CommentDetail() {}
 
-  public void include(Change.Id changeId, Comment p) {
+  public void include(Change.Id changeId, HumanComment p) {
     PatchSet.Id psId = PatchSet.id(changeId, p.key.patchSetId);
     if (p.side == 0) {
       if (idA == null && idB.equals(psId)) {
diff --git a/java/com/google/gerrit/common/data/ContributorAgreement.java b/java/com/google/gerrit/common/data/ContributorAgreement.java
deleted file mode 100644
index bc106f0..0000000
--- a/java/com/google/gerrit/common/data/ContributorAgreement.java
+++ /dev/null
@@ -1,111 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.entities.Project;
-import java.util.ArrayList;
-import java.util.List;
-
-/** Portion of a {@link Project} describing a single contributor agreement. */
-public class ContributorAgreement implements Comparable<ContributorAgreement> {
-  protected String name;
-  protected String description;
-  protected List<PermissionRule> accepted;
-  protected GroupReference autoVerify;
-  protected String agreementUrl;
-  protected List<String> excludeProjectsRegexes;
-  protected List<String> matchProjectsRegexes;
-
-  protected ContributorAgreement() {}
-
-  public ContributorAgreement(String name) {
-    setName(name);
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public void setName(String name) {
-    this.name = name;
-  }
-
-  public String getDescription() {
-    return description;
-  }
-
-  public void setDescription(String description) {
-    this.description = description;
-  }
-
-  public List<PermissionRule> getAccepted() {
-    if (accepted == null) {
-      accepted = new ArrayList<>();
-    }
-    return accepted;
-  }
-
-  public void setAccepted(List<PermissionRule> accepted) {
-    this.accepted = accepted;
-  }
-
-  public GroupReference getAutoVerify() {
-    return autoVerify;
-  }
-
-  public void setAutoVerify(GroupReference autoVerify) {
-    this.autoVerify = autoVerify;
-  }
-
-  public String getAgreementUrl() {
-    return agreementUrl;
-  }
-
-  public void setAgreementUrl(String agreementUrl) {
-    this.agreementUrl = agreementUrl;
-  }
-
-  public List<String> getExcludeProjectsRegexes() {
-    if (excludeProjectsRegexes == null) {
-      excludeProjectsRegexes = new ArrayList<>();
-    }
-    return excludeProjectsRegexes;
-  }
-
-  public void setExcludeProjectsRegexes(List<String> excludeProjectsRegexes) {
-    this.excludeProjectsRegexes = excludeProjectsRegexes;
-  }
-
-  public List<String> getMatchProjectsRegexes() {
-    if (matchProjectsRegexes == null) {
-      matchProjectsRegexes = new ArrayList<>();
-    }
-    return matchProjectsRegexes;
-  }
-
-  public void setMatchProjectsRegexes(List<String> matchProjectsRegexes) {
-    this.matchProjectsRegexes = matchProjectsRegexes;
-  }
-
-  @Override
-  public int compareTo(ContributorAgreement o) {
-    return getName().compareTo(o.getName());
-  }
-
-  @Override
-  public String toString() {
-    return "ContributorAgreement[" + getName() + "]";
-  }
-}
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index 10a66cc..51d9ecd 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
diff --git a/java/com/google/gerrit/common/data/GroupDescription.java b/java/com/google/gerrit/common/data/GroupDescription.java
deleted file mode 100644
index ed8b39d..0000000
--- a/java/com/google/gerrit/common/data/GroupDescription.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
-import java.util.Set;
-
-/** Group methods exposed by the GroupBackend. */
-public class GroupDescription {
-  /** The Basic information required to be exposed by any Group. */
-  public interface Basic {
-    /** @return the non-null UUID of the group. */
-    AccountGroup.UUID getGroupUUID();
-
-    /** @return the non-null name of the group. */
-    String getName();
-
-    /**
-     * @return optional email address to send to the group's members. If provided, Gerrit will use
-     *     this email address to send change notifications to the group.
-     */
-    @Nullable
-    String getEmailAddress();
-
-    /**
-     * @return optional URL to information about the group. Typically a URL to a web page that
-     *     permits users to apply to join the group, or manage their membership.
-     */
-    @Nullable
-    String getUrl();
-  }
-
-  /** The extended information exposed by internal groups. */
-  public interface Internal extends Basic {
-
-    AccountGroup.Id getId();
-
-    @Nullable
-    String getDescription();
-
-    AccountGroup.UUID getOwnerGroupUUID();
-
-    boolean isVisibleToAll();
-
-    Timestamp getCreatedOn();
-
-    Set<Account.Id> getMembers();
-
-    Set<AccountGroup.UUID> getSubgroups();
-  }
-
-  private GroupDescription() {}
-}
diff --git a/java/com/google/gerrit/common/data/GroupReference.java b/java/com/google/gerrit/common/data/GroupReference.java
deleted file mode 100644
index 0af088e..0000000
--- a/java/com/google/gerrit/common/data/GroupReference.java
+++ /dev/null
@@ -1,120 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.AccountGroup;
-
-/** Describes a group within a projects {@link AccessSection}s. */
-public class GroupReference implements Comparable<GroupReference> {
-
-  private static final String PREFIX = "group ";
-
-  public static GroupReference forGroup(GroupDescription.Basic group) {
-    return new GroupReference(group.getGroupUUID(), group.getName());
-  }
-
-  public static boolean isGroupReference(String configValue) {
-    return configValue != null && configValue.startsWith(PREFIX);
-  }
-
-  @Nullable
-  public static String extractGroupName(String configValue) {
-    if (!isGroupReference(configValue)) {
-      return null;
-    }
-    return configValue.substring(PREFIX.length()).trim();
-  }
-
-  protected String uuid;
-  protected String name;
-
-  protected GroupReference() {}
-
-  /**
-   * Create a group reference.
-   *
-   * @param uuid UUID of the group, must not be {@code null}
-   * @param name the group name, must not be {@code null}
-   */
-  public GroupReference(AccountGroup.UUID uuid, String name) {
-    setUUID(requireNonNull(uuid));
-    setName(name);
-  }
-
-  /**
-   * Create a group reference where the group's name couldn't be resolved.
-   *
-   * @param name the group name, must not be {@code null}
-   */
-  public GroupReference(String name) {
-    setUUID(null);
-    setName(name);
-  }
-
-  @Nullable
-  public AccountGroup.UUID getUUID() {
-    return uuid != null ? AccountGroup.uuid(uuid) : null;
-  }
-
-  public void setUUID(@Nullable AccountGroup.UUID newUUID) {
-    uuid = newUUID != null ? newUUID.get() : null;
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public void setName(String newName) {
-    if (newName == null) {
-      throw new NullPointerException();
-    }
-    this.name = newName;
-  }
-
-  @Override
-  public int compareTo(GroupReference o) {
-    return uuid(this).compareTo(uuid(o));
-  }
-
-  private static String uuid(GroupReference a) {
-    if (a.getUUID() != null && a.getUUID().get() != null) {
-      return a.getUUID().get();
-    }
-
-    return "?";
-  }
-
-  @Override
-  public int hashCode() {
-    return uuid(this).hashCode();
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    return o instanceof GroupReference && compareTo((GroupReference) o) == 0;
-  }
-
-  public String toConfigValue() {
-    return PREFIX + name;
-  }
-
-  @Override
-  public String toString() {
-    return "Group[" + getName() + " / " + getUUID() + "]";
-  }
-}
diff --git a/java/com/google/gerrit/common/data/LabelFunction.java b/java/com/google/gerrit/common/data/LabelFunction.java
deleted file mode 100644
index 6af675b..0000000
--- a/java/com/google/gerrit/common/data/LabelFunction.java
+++ /dev/null
@@ -1,123 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.PatchSetApproval;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.Optional;
-
-/**
- * Functions for determining submittability based on label votes.
- *
- * <p>Only describes built-in label functions. Admins can extend the logic arbitrarily using Prolog
- * rules, in which case the choice of function in the project config is ignored.
- *
- * <p>Function semantics are documented in {@code config-labels.txt}, and actual behavior is
- * implemented both in Prolog in {@code gerrit_common.pl} and in the {@link #check} method.
- */
-public enum LabelFunction {
-  ANY_WITH_BLOCK("AnyWithBlock", true, false, false),
-  MAX_WITH_BLOCK("MaxWithBlock", true, true, true),
-  MAX_NO_BLOCK("MaxNoBlock", false, true, true),
-  NO_BLOCK("NoBlock"),
-  NO_OP("NoOp"),
-  PATCH_SET_LOCK("PatchSetLock");
-
-  public static final Map<String, LabelFunction> ALL;
-
-  static {
-    Map<String, LabelFunction> all = new LinkedHashMap<>();
-    for (LabelFunction f : values()) {
-      all.put(f.getFunctionName(), f);
-    }
-    ALL = Collections.unmodifiableMap(all);
-  }
-
-  public static Optional<LabelFunction> parse(@Nullable String str) {
-    return Optional.ofNullable(ALL.get(str));
-  }
-
-  private final String name;
-  private final boolean isBlock;
-  private final boolean isRequired;
-  private final boolean requiresMaxValue;
-
-  LabelFunction(String name) {
-    this(name, false, false, false);
-  }
-
-  LabelFunction(String name, boolean isBlock, boolean isRequired, boolean requiresMaxValue) {
-    this.name = name;
-    this.isBlock = isBlock;
-    this.isRequired = isRequired;
-    this.requiresMaxValue = requiresMaxValue;
-  }
-
-  /** The function name as defined in documentation and {@code project.config}. */
-  public String getFunctionName() {
-    return name;
-  }
-
-  /** Whether the label is a "block" label, meaning a minimum vote will prevent submission. */
-  public boolean isBlock() {
-    return isBlock;
-  }
-
-  /** Whether the label is a mandatory label, meaning absence of votes will prevent submission. */
-  public boolean isRequired() {
-    return isRequired;
-  }
-
-  /** Whether the label requires a vote with the maximum value to allow submission. */
-  public boolean isMaxValueRequired() {
-    return requiresMaxValue;
-  }
-
-  public SubmitRecord.Label check(LabelType labelType, Iterable<PatchSetApproval> approvals) {
-    SubmitRecord.Label submitRecordLabel = new SubmitRecord.Label();
-    submitRecordLabel.label = labelType.getName();
-
-    submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
-    if (isRequired) {
-      submitRecordLabel.status = SubmitRecord.Label.Status.NEED;
-    }
-
-    for (PatchSetApproval a : approvals) {
-      if (a.value() == 0) {
-        continue;
-      }
-
-      if (isBlock && labelType.isMaxNegative(a)) {
-        submitRecordLabel.appliedBy = a.accountId();
-        submitRecordLabel.status = SubmitRecord.Label.Status.REJECT;
-        return submitRecordLabel;
-      }
-
-      if (labelType.isMaxPositive(a) || !requiresMaxValue) {
-        submitRecordLabel.appliedBy = a.accountId();
-
-        submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
-        if (isRequired) {
-          submitRecordLabel.status = SubmitRecord.Label.Status.OK;
-        }
-      }
-    }
-
-    return submitRecordLabel;
-  }
-}
diff --git a/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
deleted file mode 100644
index 3a68414..0000000
--- a/java/com/google/gerrit/common/data/LabelType.java
+++ /dev/null
@@ -1,353 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.collectingAndThen;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.LabelId;
-import com.google.gerrit.entities.PatchSetApproval;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-public class LabelType {
-  public static final boolean DEF_ALLOW_POST_SUBMIT = true;
-  public static final boolean DEF_CAN_OVERRIDE = true;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
-  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
-  public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
-  public static final boolean DEF_COPY_ANY_SCORE = false;
-  public static final boolean DEF_COPY_MAX_SCORE = false;
-  public static final boolean DEF_COPY_MIN_SCORE = false;
-  public static final ImmutableList<Short> DEF_COPY_VALUES = ImmutableList.of();
-  public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
-
-  public static LabelType withDefaultValues(String name) {
-    checkName(name);
-    List<LabelValue> values = new ArrayList<>(2);
-    values.add(new LabelValue((short) 0, "Rejected"));
-    values.add(new LabelValue((short) 1, "Approved"));
-    return new LabelType(name, values);
-  }
-
-  public static String checkName(String name) {
-    checkNameInternal(name);
-    if ("SUBM".equals(name)) {
-      throw new IllegalArgumentException("Reserved label name \"" + name + "\"");
-    }
-    return name;
-  }
-
-  public static String checkNameInternal(String name) {
-    if (name == null || name.isEmpty()) {
-      throw new IllegalArgumentException("Empty label name");
-    }
-    for (int i = 0; i < name.length(); i++) {
-      char c = name.charAt(i);
-      if ((i == 0 && c == '-')
-          || !((c >= 'a' && c <= 'z')
-              || (c >= 'A' && c <= 'Z')
-              || (c >= '0' && c <= '9')
-              || c == '-')) {
-        throw new IllegalArgumentException("Illegal label name \"" + name + "\"");
-      }
-    }
-    return name;
-  }
-
-  private static List<LabelValue> sortValues(List<LabelValue> values) {
-    values = new ArrayList<>(values);
-    if (values.isEmpty()) {
-      return Collections.emptyList();
-    }
-    values = values.stream().sorted(comparing(LabelValue::getValue)).collect(toList());
-    short v = values.get(0).getValue();
-    short i = 0;
-    ArrayList<LabelValue> result = new ArrayList<>();
-    // Fill in any missing values with empty text.
-    while (i < values.size()) {
-      while (v < values.get(i).getValue()) {
-        result.add(new LabelValue(v++, ""));
-      }
-      v++;
-      result.add(values.get(i++));
-    }
-    result.trimToSize();
-    return Collections.unmodifiableList(result);
-  }
-
-  protected String name;
-
-  protected LabelFunction function;
-
-  protected boolean copyAnyScore;
-  protected boolean copyMinScore;
-  protected boolean copyMaxScore;
-  protected boolean copyAllScoresOnMergeFirstParentUpdate;
-  protected boolean copyAllScoresOnTrivialRebase;
-  protected boolean copyAllScoresIfNoCodeChange;
-  protected boolean copyAllScoresIfNoChange;
-  protected ImmutableList<Short> copyValues;
-  protected boolean allowPostSubmit;
-  protected boolean ignoreSelfApproval;
-  protected short defaultValue;
-
-  protected List<LabelValue> values;
-  protected short maxNegative;
-  protected short maxPositive;
-
-  private transient boolean canOverride;
-  private transient List<String> refPatterns;
-  private transient Map<Short, LabelValue> byValue;
-
-  protected LabelType() {}
-
-  public LabelType(String name, List<LabelValue> valueList) {
-    this.name = checkName(name);
-    canOverride = true;
-    values = sortValues(valueList);
-    defaultValue = 0;
-
-    function = LabelFunction.MAX_WITH_BLOCK;
-
-    maxNegative = Short.MIN_VALUE;
-    maxPositive = Short.MAX_VALUE;
-    if (!values.isEmpty()) {
-      if (values.get(0).getValue() < 0) {
-        maxNegative = values.get(0).getValue();
-      }
-      if (values.get(values.size() - 1).getValue() > 0) {
-        maxPositive = values.get(values.size() - 1).getValue();
-      }
-    }
-    setCanOverride(DEF_CAN_OVERRIDE);
-    setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
-    setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
-    setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
-    setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
-    setCopyAnyScore(DEF_COPY_ANY_SCORE);
-    setCopyMaxScore(DEF_COPY_MAX_SCORE);
-    setCopyMinScore(DEF_COPY_MIN_SCORE);
-    setCopyValues(DEF_COPY_VALUES);
-    setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT);
-    setIgnoreSelfApproval(DEF_IGNORE_SELF_APPROVAL);
-
-    byValue = new HashMap<>();
-    for (LabelValue v : values) {
-      byValue.put(v.getValue(), v);
-    }
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public void setName(String name) {
-    this.name = checkName(name);
-  }
-
-  public boolean matches(PatchSetApproval psa) {
-    return psa.labelId().get().equalsIgnoreCase(name);
-  }
-
-  public LabelFunction getFunction() {
-    return function;
-  }
-
-  public void setFunction(@Nullable LabelFunction function) {
-    this.function = function;
-  }
-
-  public boolean canOverride() {
-    return canOverride;
-  }
-
-  @Nullable
-  public List<String> getRefPatterns() {
-    return refPatterns;
-  }
-
-  public void setCanOverride(boolean canOverride) {
-    this.canOverride = canOverride;
-  }
-
-  public boolean allowPostSubmit() {
-    return allowPostSubmit;
-  }
-
-  public void setAllowPostSubmit(boolean allowPostSubmit) {
-    this.allowPostSubmit = allowPostSubmit;
-  }
-
-  public boolean ignoreSelfApproval() {
-    return ignoreSelfApproval;
-  }
-
-  public void setIgnoreSelfApproval(boolean ignoreSelfApproval) {
-    this.ignoreSelfApproval = ignoreSelfApproval;
-  }
-
-  public void setRefPatterns(List<String> refPatterns) {
-    if (refPatterns != null && !refPatterns.isEmpty()) {
-      this.refPatterns =
-          refPatterns.stream().collect(collectingAndThen(toList(), Collections::unmodifiableList));
-    } else {
-      this.refPatterns = null;
-    }
-  }
-
-  public List<LabelValue> getValues() {
-    return values;
-  }
-
-  public void setValues(List<LabelValue> values) {
-    this.values = sortValues(values);
-  }
-
-  public LabelValue getMin() {
-    if (values.isEmpty()) {
-      return null;
-    }
-    return values.get(0);
-  }
-
-  public LabelValue getMax() {
-    if (values.isEmpty()) {
-      return null;
-    }
-    return values.get(values.size() - 1);
-  }
-
-  public short getDefaultValue() {
-    return defaultValue;
-  }
-
-  public void setDefaultValue(short defaultValue) {
-    this.defaultValue = defaultValue;
-  }
-
-  public boolean isCopyAnyScore() {
-    return copyAnyScore;
-  }
-
-  public void setCopyAnyScore(boolean copyAnyScore) {
-    this.copyAnyScore = copyAnyScore;
-  }
-
-  public boolean isCopyMinScore() {
-    return copyMinScore;
-  }
-
-  public void setCopyMinScore(boolean copyMinScore) {
-    this.copyMinScore = copyMinScore;
-  }
-
-  public boolean isCopyMaxScore() {
-    return copyMaxScore;
-  }
-
-  public void setCopyMaxScore(boolean copyMaxScore) {
-    this.copyMaxScore = copyMaxScore;
-  }
-
-  public boolean isCopyAllScoresOnMergeFirstParentUpdate() {
-    return copyAllScoresOnMergeFirstParentUpdate;
-  }
-
-  public void setCopyAllScoresOnMergeFirstParentUpdate(
-      boolean copyAllScoresOnMergeFirstParentUpdate) {
-    this.copyAllScoresOnMergeFirstParentUpdate = copyAllScoresOnMergeFirstParentUpdate;
-  }
-
-  public boolean isCopyAllScoresOnTrivialRebase() {
-    return copyAllScoresOnTrivialRebase;
-  }
-
-  public void setCopyAllScoresOnTrivialRebase(boolean copyAllScoresOnTrivialRebase) {
-    this.copyAllScoresOnTrivialRebase = copyAllScoresOnTrivialRebase;
-  }
-
-  public boolean isCopyAllScoresIfNoCodeChange() {
-    return copyAllScoresIfNoCodeChange;
-  }
-
-  public void setCopyAllScoresIfNoCodeChange(boolean copyAllScoresIfNoCodeChange) {
-    this.copyAllScoresIfNoCodeChange = copyAllScoresIfNoCodeChange;
-  }
-
-  public boolean isCopyAllScoresIfNoChange() {
-    return copyAllScoresIfNoChange;
-  }
-
-  public void setCopyAllScoresIfNoChange(boolean copyAllScoresIfNoChange) {
-    this.copyAllScoresIfNoChange = copyAllScoresIfNoChange;
-  }
-
-  public ImmutableList<Short> getCopyValues() {
-    return copyValues;
-  }
-
-  public void setCopyValues(Collection<Short> copyValues) {
-    this.copyValues = copyValues.stream().sorted().collect(toImmutableList());
-  }
-
-  public boolean isMaxNegative(PatchSetApproval ca) {
-    return maxNegative == ca.value();
-  }
-
-  public boolean isMaxPositive(PatchSetApproval ca) {
-    return maxPositive == ca.value();
-  }
-
-  public LabelValue getValue(short value) {
-    return byValue.get(value);
-  }
-
-  public LabelValue getValue(PatchSetApproval ca) {
-    return byValue.get(ca.value());
-  }
-
-  public LabelId getLabelId() {
-    return LabelId.create(name);
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder sb = new StringBuilder(name).append('[');
-    LabelValue min = getMin();
-    LabelValue max = getMax();
-    if (min != null && max != null) {
-      sb.append(
-          new PermissionRange(Permission.forLabel(name), min.getValue(), max.getValue())
-              .toString()
-              .trim());
-    } else if (min != null) {
-      sb.append(min.formatValue().trim());
-    } else if (max != null) {
-      sb.append(max.formatValue().trim());
-    }
-    sb.append(']');
-    return sb.toString();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/LabelTypes.java b/java/com/google/gerrit/common/data/LabelTypes.java
deleted file mode 100644
index 1647658..0000000
--- a/java/com/google/gerrit/common/data/LabelTypes.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.entities.LabelId;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-public class LabelTypes {
-  protected List<LabelType> labelTypes;
-  private transient volatile Map<String, LabelType> byLabel;
-  private transient volatile Map<String, Integer> positions;
-
-  protected LabelTypes() {}
-
-  public LabelTypes(List<? extends LabelType> approvals) {
-    labelTypes = Collections.unmodifiableList(new ArrayList<>(approvals));
-  }
-
-  public List<LabelType> getLabelTypes() {
-    return labelTypes;
-  }
-
-  public LabelType byLabel(LabelId labelId) {
-    return byLabel().get(labelId.get().toLowerCase());
-  }
-
-  public LabelType byLabel(String labelName) {
-    return byLabel().get(labelName.toLowerCase());
-  }
-
-  private Map<String, LabelType> byLabel() {
-    if (byLabel == null) {
-      synchronized (this) {
-        if (byLabel == null) {
-          Map<String, LabelType> l = new HashMap<>();
-          if (labelTypes != null) {
-            for (LabelType t : labelTypes) {
-              l.put(t.getName().toLowerCase(), t);
-            }
-          }
-          byLabel = l;
-        }
-      }
-    }
-    return byLabel;
-  }
-
-  @Override
-  public String toString() {
-    return labelTypes.toString();
-  }
-
-  public Comparator<String> nameComparator() {
-    final Map<String, Integer> positions = positions();
-    return new Comparator<String>() {
-      @Override
-      public int compare(String left, String right) {
-        int lp = position(left);
-        int rp = position(right);
-        int cmp = lp - rp;
-        if (cmp == 0) {
-          cmp = left.compareTo(right);
-        }
-        return cmp;
-      }
-
-      private int position(String name) {
-        Integer p = positions.get(name);
-        return p != null ? p : positions.size();
-      }
-    };
-  }
-
-  private Map<String, Integer> positions() {
-    if (positions == null) {
-      synchronized (this) {
-        if (positions == null) {
-          Map<String, Integer> p = new HashMap<>();
-          if (labelTypes != null) {
-            int i = 0;
-            for (LabelType t : labelTypes) {
-              p.put(t.getName(), i++);
-            }
-          }
-          positions = p;
-        }
-      }
-    }
-    return positions;
-  }
-}
diff --git a/java/com/google/gerrit/common/data/LabelValue.java b/java/com/google/gerrit/common/data/LabelValue.java
deleted file mode 100644
index c0ba781..0000000
--- a/java/com/google/gerrit/common/data/LabelValue.java
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import java.util.Objects;
-
-public class LabelValue {
-  public static String formatValue(short value) {
-    if (value < 0) {
-      return Short.toString(value);
-    } else if (value == 0) {
-      return " 0";
-    } else {
-      return "+" + Short.toString(value);
-    }
-  }
-
-  protected short value;
-  protected String text;
-
-  public LabelValue(short value, String text) {
-    this.value = value;
-    this.text = text;
-  }
-
-  protected LabelValue() {}
-
-  public short getValue() {
-    return value;
-  }
-
-  public String getText() {
-    return text;
-  }
-
-  public String formatValue() {
-    return formatValue(value);
-  }
-
-  public String format() {
-    StringBuilder sb = new StringBuilder(formatValue());
-    if (!text.isEmpty()) {
-      sb.append(' ').append(text);
-    }
-    return sb.toString();
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (!(o instanceof LabelValue)) {
-      return false;
-    }
-    LabelValue v = (LabelValue) o;
-    return value == v.value && Objects.equals(text, v.text);
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(value, text);
-  }
-
-  @Override
-  public String toString() {
-    return format();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/Permission.java b/java/com/google/gerrit/common/data/Permission.java
deleted file mode 100644
index 9b86b7e..0000000
--- a/java/com/google/gerrit/common/data/Permission.java
+++ /dev/null
@@ -1,302 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.common.collect.ImmutableList;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-
-/** A single permission within an {@link AccessSection} of a project. */
-public class Permission implements Comparable<Permission> {
-  public static final String ABANDON = "abandon";
-  public static final String ADD_PATCH_SET = "addPatchSet";
-  public static final String CREATE = "create";
-  public static final String CREATE_SIGNED_TAG = "createSignedTag";
-  public static final String CREATE_TAG = "createTag";
-  public static final String DELETE = "delete";
-  public static final String DELETE_CHANGES = "deleteChanges";
-  public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
-  public static final String EDIT_ASSIGNEE = "editAssignee";
-  public static final String EDIT_HASHTAGS = "editHashtags";
-  public static final String EDIT_TOPIC_NAME = "editTopicName";
-  public static final String FORGE_AUTHOR = "forgeAuthor";
-  public static final String FORGE_COMMITTER = "forgeCommitter";
-  public static final String FORGE_SERVER = "forgeServerAsCommitter";
-  public static final String LABEL = "label-";
-  public static final String LABEL_AS = "labelAs-";
-  public static final String OWNER = "owner";
-  public static final String PUSH = "push";
-  public static final String PUSH_MERGE = "pushMerge";
-  public static final String READ = "read";
-  public static final String REBASE = "rebase";
-  public static final String REMOVE_REVIEWER = "removeReviewer";
-  public static final String REVERT = "revert";
-  public static final String SUBMIT = "submit";
-  public static final String SUBMIT_AS = "submitAs";
-  public static final String TOGGLE_WORK_IN_PROGRESS_STATE = "toggleWipState";
-  public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges";
-
-  private static final List<String> NAMES_LC;
-  private static final int LABEL_INDEX;
-  private static final int LABEL_AS_INDEX;
-
-  static {
-    NAMES_LC = new ArrayList<>();
-    NAMES_LC.add(ABANDON.toLowerCase());
-    NAMES_LC.add(ADD_PATCH_SET.toLowerCase());
-    NAMES_LC.add(CREATE.toLowerCase());
-    NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase());
-    NAMES_LC.add(CREATE_TAG.toLowerCase());
-    NAMES_LC.add(DELETE.toLowerCase());
-    NAMES_LC.add(DELETE_CHANGES.toLowerCase());
-    NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase());
-    NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
-    NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
-    NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
-    NAMES_LC.add(FORGE_AUTHOR.toLowerCase());
-    NAMES_LC.add(FORGE_COMMITTER.toLowerCase());
-    NAMES_LC.add(FORGE_SERVER.toLowerCase());
-    NAMES_LC.add(LABEL.toLowerCase());
-    NAMES_LC.add(LABEL_AS.toLowerCase());
-    NAMES_LC.add(OWNER.toLowerCase());
-    NAMES_LC.add(PUSH.toLowerCase());
-    NAMES_LC.add(PUSH_MERGE.toLowerCase());
-    NAMES_LC.add(READ.toLowerCase());
-    NAMES_LC.add(REBASE.toLowerCase());
-    NAMES_LC.add(REMOVE_REVIEWER.toLowerCase());
-    NAMES_LC.add(REVERT.toLowerCase());
-    NAMES_LC.add(SUBMIT.toLowerCase());
-    NAMES_LC.add(SUBMIT_AS.toLowerCase());
-    NAMES_LC.add(TOGGLE_WORK_IN_PROGRESS_STATE.toLowerCase());
-    NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase());
-
-    LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
-    LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
-  }
-
-  /** @return true if the name is recognized as a permission name. */
-  public static boolean isPermission(String varName) {
-    return isLabel(varName) || isLabelAs(varName) || NAMES_LC.contains(varName.toLowerCase());
-  }
-
-  public static boolean hasRange(String varName) {
-    return isLabel(varName) || isLabelAs(varName);
-  }
-
-  /** @return true if the permission name is actually for a review label. */
-  public static boolean isLabel(String varName) {
-    return varName.startsWith(LABEL) && LABEL.length() < varName.length();
-  }
-
-  /** @return true if the permission is for impersonated review labels. */
-  public static boolean isLabelAs(String var) {
-    return var.startsWith(LABEL_AS) && LABEL_AS.length() < var.length();
-  }
-
-  /** @return permission name for the given review label. */
-  public static String forLabel(String labelName) {
-    return LABEL + labelName;
-  }
-
-  /** @return permission name to apply a label for another user. */
-  public static String forLabelAs(String labelName) {
-    return LABEL_AS + labelName;
-  }
-
-  public static String extractLabel(String varName) {
-    if (isLabel(varName)) {
-      return varName.substring(LABEL.length());
-    } else if (isLabelAs(varName)) {
-      return varName.substring(LABEL_AS.length());
-    }
-    return null;
-  }
-
-  public static boolean canBeOnAllProjects(String ref, String permissionName) {
-    if (AccessSection.ALL.equals(ref)) {
-      return !OWNER.equals(permissionName);
-    }
-    return true;
-  }
-
-  protected String name;
-  protected boolean exclusiveGroup;
-  protected List<PermissionRule> rules;
-
-  protected Permission() {}
-
-  public Permission(String name) {
-    this.name = name;
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public String getLabel() {
-    return extractLabel(getName());
-  }
-
-  public boolean getExclusiveGroup() {
-    // Only permit exclusive group behavior on non OWNER permissions,
-    // otherwise an owner might lose access to a delegated subspace.
-    //
-    return exclusiveGroup && !OWNER.equals(getName());
-  }
-
-  public void setExclusiveGroup(boolean newExclusiveGroup) {
-    exclusiveGroup = newExclusiveGroup;
-  }
-
-  public ImmutableList<PermissionRule> getRules() {
-    return rules == null ? ImmutableList.of() : ImmutableList.copyOf(rules);
-  }
-
-  public void setRules(List<PermissionRule> list) {
-    rules = new ArrayList<>(list);
-  }
-
-  public void add(PermissionRule rule) {
-    initRules();
-    rules.add(rule);
-  }
-
-  public void remove(PermissionRule rule) {
-    if (rule != null) {
-      removeRule(rule.getGroup());
-    }
-  }
-
-  public void removeRule(GroupReference group) {
-    if (rules != null) {
-      rules.removeIf(permissionRule -> sameGroup(permissionRule, group));
-    }
-  }
-
-  public void clearRules() {
-    if (rules != null) {
-      rules.clear();
-    }
-  }
-
-  public PermissionRule getRule(GroupReference group) {
-    return getRule(group, false);
-  }
-
-  public PermissionRule getRule(GroupReference group, boolean create) {
-    initRules();
-
-    for (PermissionRule r : rules) {
-      if (sameGroup(r, group)) {
-        return r;
-      }
-    }
-
-    if (create) {
-      PermissionRule r = new PermissionRule(group);
-      rules.add(r);
-      return r;
-    }
-    return null;
-  }
-
-  void mergeFrom(Permission src) {
-    for (PermissionRule srcRule : src.getRules()) {
-      PermissionRule dstRule = getRule(srcRule.getGroup());
-      if (dstRule != null) {
-        dstRule.mergeFrom(srcRule);
-      } else {
-        add(srcRule);
-      }
-    }
-  }
-
-  private static boolean sameGroup(PermissionRule rule, GroupReference group) {
-    if (group.getUUID() != null) {
-      return group.getUUID().equals(rule.getGroup().getUUID());
-
-    } else if (group.getName() != null) {
-      return group.getName().equals(rule.getGroup().getName());
-
-    } else {
-      return false;
-    }
-  }
-
-  private void initRules() {
-    if (rules == null) {
-      rules = new ArrayList<>(4);
-    }
-  }
-
-  @Override
-  public int compareTo(Permission b) {
-    int cmp = index(this) - index(b);
-    if (cmp == 0) {
-      cmp = getName().compareTo(b.getName());
-    }
-    return cmp;
-  }
-
-  private static int index(Permission a) {
-    if (isLabel(a.getName())) {
-      return LABEL_INDEX;
-    } else if (isLabelAs(a.getName())) {
-      return LABEL_AS_INDEX;
-    }
-
-    int index = NAMES_LC.indexOf(a.getName().toLowerCase());
-    return 0 <= index ? index : NAMES_LC.size();
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (!(obj instanceof Permission)) {
-      return false;
-    }
-
-    final Permission other = (Permission) obj;
-    if (!name.equals(other.name) || exclusiveGroup != other.exclusiveGroup) {
-      return false;
-    }
-    return new HashSet<>(getRules()).equals(new HashSet<>(other.getRules()));
-  }
-
-  @Override
-  public int hashCode() {
-    return name.hashCode();
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder bldr = new StringBuilder();
-    bldr.append(name).append(" ");
-    if (exclusiveGroup) {
-      bldr.append("[exclusive] ");
-    }
-    bldr.append("[");
-    Iterator<PermissionRule> it = getRules().iterator();
-    while (it.hasNext()) {
-      bldr.append(it.next());
-      if (it.hasNext()) {
-        bldr.append(", ");
-      }
-    }
-    bldr.append("]");
-    return bldr.toString();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/PermissionRange.java b/java/com/google/gerrit/common/data/PermissionRange.java
deleted file mode 100644
index 97c3731..0000000
--- a/java/com/google/gerrit/common/data/PermissionRange.java
+++ /dev/null
@@ -1,144 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Represents a closed interval [min, max] with a name. The special value [0, 0] is understood to be
- * the empty range.
- */
-public class PermissionRange implements Comparable<PermissionRange> {
-  public static class WithDefaults extends PermissionRange {
-    protected int defaultMin;
-    protected int defaultMax;
-
-    protected WithDefaults() {}
-
-    public WithDefaults(String name, int min, int max, int defMin, int defMax) {
-      super(name, min, max);
-      setDefaultRange(defMin, defMax);
-    }
-
-    public int getDefaultMin() {
-      return defaultMin;
-    }
-
-    public int getDefaultMax() {
-      return defaultMax;
-    }
-
-    public void setDefaultRange(int min, int max) {
-      defaultMin = min;
-      defaultMax = max;
-    }
-
-    /** @return all values between {@link #getMin()} and {@link #getMax()} */
-    public List<Integer> getValuesAsList() {
-      ArrayList<Integer> r = new ArrayList<>(getRangeSize());
-      for (int i = min; i <= max; i++) {
-        r.add(i);
-      }
-      return r;
-    }
-
-    /** @return number of values between {@link #getMin()} and {@link #getMax()} */
-    public int getRangeSize() {
-      return max - min;
-    }
-  }
-
-  protected String name;
-  protected int min;
-  protected int max;
-
-  protected PermissionRange() {}
-
-  public PermissionRange(String name, int min, int max) {
-    this.name = name;
-
-    if (min <= max) {
-      this.min = min;
-      this.max = max;
-    } else {
-      this.min = 0;
-      this.max = 0;
-    }
-  }
-
-  public String getName() {
-    return name;
-  }
-
-  public boolean isLabel() {
-    return Permission.isLabel(getName());
-  }
-
-  public String getLabel() {
-    return Permission.extractLabel(getName());
-  }
-
-  public int getMin() {
-    return min;
-  }
-
-  public int getMax() {
-    return max;
-  }
-
-  /** True if the value is within the range. */
-  public boolean contains(int value) {
-    return getMin() <= value && value <= getMax();
-  }
-
-  /** Normalize the value to fit within the bounds of the range. */
-  public int squash(int value) {
-    return Math.min(Math.max(getMin(), value), getMax());
-  }
-
-  /** True both {@link #getMin()} and {@link #getMax()} are 0. */
-  public boolean isEmpty() {
-    return getMin() == 0 && getMax() == 0;
-  }
-
-  @Override
-  public int compareTo(PermissionRange o) {
-    return getName().compareTo(o.getName());
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder r = new StringBuilder();
-    if (getMin() < 0 && getMax() == 0) {
-      r.append(getMin());
-      r.append(' ');
-    } else {
-      if (getMin() != getMax()) {
-        if (0 <= getMin()) {
-          r.append('+');
-        }
-        r.append(getMin());
-        r.append("..");
-      }
-      if (0 <= getMax()) {
-        r.append('+');
-      }
-      r.append(getMax());
-      r.append(' ');
-    }
-    return r.toString();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/PermissionRule.java b/java/com/google/gerrit/common/data/PermissionRule.java
deleted file mode 100644
index 8ab0a55..0000000
--- a/java/com/google/gerrit/common/data/PermissionRule.java
+++ /dev/null
@@ -1,296 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-public class PermissionRule implements Comparable<PermissionRule> {
-  public static final String FORCE_PUSH = "Force Push";
-  public static final String FORCE_EDIT = "Force Edit";
-
-  public enum Action {
-    ALLOW,
-    DENY,
-    BLOCK,
-
-    INTERACTIVE,
-    BATCH
-  }
-
-  protected Action action = Action.ALLOW;
-  protected boolean force;
-  protected int min;
-  protected int max;
-  protected GroupReference group;
-
-  public PermissionRule() {}
-
-  public PermissionRule(GroupReference group) {
-    this.group = group;
-  }
-
-  public Action getAction() {
-    return action;
-  }
-
-  public void setAction(Action action) {
-    if (action == null) {
-      throw new NullPointerException("action");
-    }
-    this.action = action;
-  }
-
-  public boolean isDeny() {
-    return action == Action.DENY;
-  }
-
-  public void setDeny() {
-    action = Action.DENY;
-  }
-
-  public boolean isBlock() {
-    return action == Action.BLOCK;
-  }
-
-  public void setBlock() {
-    action = Action.BLOCK;
-  }
-
-  public boolean getForce() {
-    return force;
-  }
-
-  public void setForce(boolean newForce) {
-    force = newForce;
-  }
-
-  public int getMin() {
-    return min;
-  }
-
-  public void setMin(int min) {
-    this.min = min;
-  }
-
-  public void setMax(int max) {
-    this.max = max;
-  }
-
-  public int getMax() {
-    return max;
-  }
-
-  public void setRange(int newMin, int newMax) {
-    if (newMax < newMin) {
-      min = newMax;
-      max = newMin;
-    } else {
-      min = newMin;
-      max = newMax;
-    }
-  }
-
-  public GroupReference getGroup() {
-    return group;
-  }
-
-  public void setGroup(GroupReference newGroup) {
-    group = newGroup;
-  }
-
-  void mergeFrom(PermissionRule src) {
-    if (getAction() != src.getAction()) {
-      if (getAction() == Action.BLOCK || src.getAction() == Action.BLOCK) {
-        setAction(Action.BLOCK);
-
-      } else if (getAction() == Action.DENY || src.getAction() == Action.DENY) {
-        setAction(Action.DENY);
-
-      } else if (getAction() == Action.BATCH || src.getAction() == Action.BATCH) {
-        setAction(Action.BATCH);
-      }
-    }
-
-    setForce(getForce() || src.getForce());
-    setRange(Math.min(getMin(), src.getMin()), Math.max(getMax(), src.getMax()));
-  }
-
-  @Override
-  public int compareTo(PermissionRule o) {
-    int cmp = action(this) - action(o);
-    if (cmp == 0) {
-      cmp = range(o) - range(this);
-    }
-    if (cmp == 0) {
-      cmp = group(this).compareTo(group(o));
-    }
-    return cmp;
-  }
-
-  private static int action(PermissionRule a) {
-    switch (a.getAction()) {
-      case DENY:
-        return 0;
-      case ALLOW:
-      case BATCH:
-      case BLOCK:
-      case INTERACTIVE:
-      default:
-        return 1 + a.getAction().ordinal();
-    }
-  }
-
-  private static int range(PermissionRule a) {
-    return Math.abs(a.getMin()) + Math.abs(a.getMax());
-  }
-
-  private static String group(PermissionRule a) {
-    return a.getGroup().getName() != null ? a.getGroup().getName() : "";
-  }
-
-  @Override
-  public String toString() {
-    return asString(true);
-  }
-
-  public String asString(boolean canUseRange) {
-    StringBuilder r = new StringBuilder();
-
-    switch (getAction()) {
-      case ALLOW:
-        break;
-
-      case DENY:
-        r.append("deny ");
-        break;
-
-      case BLOCK:
-        r.append("block ");
-        break;
-
-      case INTERACTIVE:
-        r.append("interactive ");
-        break;
-
-      case BATCH:
-        r.append("batch ");
-        break;
-    }
-
-    if (getForce()) {
-      r.append("+force ");
-    }
-
-    if (canUseRange && (getMin() != 0 || getMax() != 0)) {
-      if (0 <= getMin()) {
-        r.append('+');
-      }
-      r.append(getMin());
-      r.append("..");
-      if (0 <= getMax()) {
-        r.append('+');
-      }
-      r.append(getMax());
-      r.append(' ');
-    }
-
-    r.append(getGroup().toConfigValue());
-
-    return r.toString();
-  }
-
-  public static PermissionRule fromString(String src, boolean mightUseRange) {
-    final String orig = src;
-    final PermissionRule rule = new PermissionRule();
-
-    src = src.trim();
-
-    if (src.startsWith("deny ")) {
-      rule.setAction(Action.DENY);
-      src = src.substring("deny ".length()).trim();
-
-    } else if (src.startsWith("block ")) {
-      rule.setAction(Action.BLOCK);
-      src = src.substring("block ".length()).trim();
-
-    } else if (src.startsWith("interactive ")) {
-      rule.setAction(Action.INTERACTIVE);
-      src = src.substring("interactive ".length()).trim();
-
-    } else if (src.startsWith("batch ")) {
-      rule.setAction(Action.BATCH);
-      src = src.substring("batch ".length()).trim();
-    }
-
-    if (src.startsWith("+force ")) {
-      rule.setForce(true);
-      src = src.substring("+force ".length()).trim();
-    }
-
-    if (mightUseRange && !GroupReference.isGroupReference(src)) {
-      int sp = src.indexOf(' ');
-      String range = src.substring(0, sp);
-
-      if (range.matches("^([+-]?\\d+)\\.\\.([+-]?\\d+)$")) {
-        int dotdot = range.indexOf("..");
-        int min = parseInt(range.substring(0, dotdot));
-        int max = parseInt(range.substring(dotdot + 2));
-        rule.setRange(min, max);
-      } else {
-        throw new IllegalArgumentException("Invalid range in rule: " + orig);
-      }
-
-      src = src.substring(sp + 1).trim();
-    }
-
-    String groupName = GroupReference.extractGroupName(src);
-    if (groupName != null) {
-      GroupReference group = new GroupReference();
-      group.setName(groupName);
-      rule.setGroup(group);
-    } else {
-      throw new IllegalArgumentException("Rule must include group: " + orig);
-    }
-
-    return rule;
-  }
-
-  public boolean hasRange() {
-    return getMin() != 0 || getMax() != 0;
-  }
-
-  public static int parseInt(String value) {
-    if (value.startsWith("+")) {
-      value = value.substring(1);
-    }
-    return Integer.parseInt(value);
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (!(obj instanceof PermissionRule)) {
-      return false;
-    }
-    final PermissionRule other = (PermissionRule) obj;
-    return action.equals(other.action)
-        && force == other.force
-        && min == other.min
-        && max == other.max
-        && group.equals(other.group);
-  }
-
-  @Override
-  public int hashCode() {
-    return group.hashCode();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/SubmitRecord.java b/java/com/google/gerrit/common/data/SubmitRecord.java
deleted file mode 100644
index fe5843ad..0000000
--- a/java/com/google/gerrit/common/data/SubmitRecord.java
+++ /dev/null
@@ -1,185 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.entities.Account;
-import java.util.Collection;
-import java.util.List;
-import java.util.Objects;
-
-/** Describes the state and edits required to submit a change. */
-public class SubmitRecord {
-  public static boolean allRecordsOK(Collection<SubmitRecord> in) {
-    if (in == null || in.isEmpty()) {
-      // If the list is null or empty, it means that this Gerrit installation does not
-      // have any form of validation rules.
-      // Hence, the permission system should be used to determine if the change can be merged
-      // or not.
-      return true;
-    }
-
-    // The change can be submitted, unless at least one plugin prevents it.
-    return in.stream().map(SubmitRecord::status).allMatch(SubmitRecord.Status::allowsSubmission);
-  }
-
-  public enum Status {
-    // NOTE: These values are persisted in the index, so deleting or changing
-    // the name of any values requires a schema upgrade.
-
-    /** The change is ready for submission. */
-    OK,
-
-    /** Something is preventing this change from being submitted. */
-    NOT_READY,
-
-    /** The change has been closed. */
-    CLOSED,
-
-    /** The change was submitted bypassing submit rules. */
-    FORCED,
-
-    /**
-     * An internal server error occurred preventing computation.
-     *
-     * <p>Additional detail may be available in {@link SubmitRecord#errorMessage}.
-     */
-    RULE_ERROR;
-
-    private boolean allowsSubmission() {
-      return this == OK || this == FORCED;
-    }
-  }
-
-  public Status status;
-  public List<Label> labels;
-  public List<SubmitRequirement> requirements;
-  public String errorMessage;
-
-  public static class Label {
-    public enum Status {
-      // NOTE: These values are persisted in the index, so deleting or changing
-      // the name of any values requires a schema upgrade.
-
-      /**
-       * This label provides what is necessary for submission.
-       *
-       * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
-       * to the change.
-       */
-      OK,
-
-      /**
-       * This label prevents the change from being submitted.
-       *
-       * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
-       * to the change.
-       */
-      REJECT,
-
-      /** The label is required for submission, but has not been satisfied. */
-      NEED,
-
-      /**
-       * The label may be set, but it's neither necessary for submission nor does it block
-       * submission if set.
-       */
-      MAY,
-
-      /**
-       * The label is required for submission, but is impossible to complete. The likely cause is
-       * access has not been granted correctly by the project owner or site administrator.
-       */
-      IMPOSSIBLE
-    }
-
-    public String label;
-    public Status status;
-    public Account.Id appliedBy;
-
-    @Override
-    public String toString() {
-      StringBuilder sb = new StringBuilder();
-      sb.append(label).append(": ").append(status);
-      if (appliedBy != null) {
-        sb.append(" by ").append(appliedBy);
-      }
-      return sb.toString();
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      if (o instanceof Label) {
-        Label l = (Label) o;
-        return Objects.equals(label, l.label)
-            && Objects.equals(status, l.status)
-            && Objects.equals(appliedBy, l.appliedBy);
-      }
-      return false;
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hash(label, status, appliedBy);
-    }
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append(status);
-    if (status == Status.RULE_ERROR && errorMessage != null) {
-      sb.append('(').append(errorMessage).append(')');
-    }
-    sb.append('[');
-    if (labels != null) {
-      String delimiter = "";
-      for (Label label : labels) {
-        sb.append(delimiter).append(label);
-        delimiter = ", ";
-      }
-    }
-    sb.append("],[");
-    if (requirements != null) {
-      String delimiter = "";
-      for (SubmitRequirement requirement : requirements) {
-        sb.append(delimiter).append(requirement);
-        delimiter = ", ";
-      }
-    }
-    sb.append(']');
-    return sb.toString();
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (o instanceof SubmitRecord) {
-      SubmitRecord r = (SubmitRecord) o;
-      return Objects.equals(status, r.status)
-          && Objects.equals(labels, r.labels)
-          && Objects.equals(errorMessage, r.errorMessage)
-          && Objects.equals(requirements, r.requirements);
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(status, labels, errorMessage, requirements);
-  }
-
-  private Status status() {
-    return status;
-  }
-}
diff --git a/java/com/google/gerrit/common/data/SubmitRequirement.java b/java/com/google/gerrit/common/data/SubmitRequirement.java
deleted file mode 100644
index 2c341bf..0000000
--- a/java/com/google/gerrit/common/data/SubmitRequirement.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.CharMatcher;
-
-/** Describes a requirement to submit a change. */
-@AutoValue
-@AutoValue.CopyAnnotations
-public abstract class SubmitRequirement {
-  private static final CharMatcher TYPE_MATCHER =
-      CharMatcher.inRange('a', 'z')
-          .or(CharMatcher.inRange('A', 'Z'))
-          .or(CharMatcher.inRange('0', '9'))
-          .or(CharMatcher.anyOf("-_"));
-
-  @AutoValue.Builder
-  public abstract static class Builder {
-    public abstract Builder setType(String value);
-
-    public abstract Builder setFallbackText(String value);
-
-    public SubmitRequirement build() {
-      SubmitRequirement requirement = autoBuild();
-      checkState(
-          validateType(requirement.type()),
-          "SubmitRequirement's type contains non alphanumerical symbols.");
-      return requirement;
-    }
-
-    abstract SubmitRequirement autoBuild();
-  }
-
-  public abstract String fallbackText();
-
-  public abstract String type();
-
-  public static Builder builder() {
-    return new AutoValue_SubmitRequirement.Builder();
-  }
-
-  private static boolean validateType(String type) {
-    return TYPE_MATCHER.matchesAllOf(type);
-  }
-}
diff --git a/java/com/google/gerrit/common/data/SubmitTypeRecord.java b/java/com/google/gerrit/common/data/SubmitTypeRecord.java
deleted file mode 100644
index afb3bac..0000000
--- a/java/com/google/gerrit/common/data/SubmitTypeRecord.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.extensions.client.SubmitType;
-
-/** Describes the submit type for a change. */
-public class SubmitTypeRecord {
-  public enum Status {
-    /** The type was computed successfully */
-    OK,
-
-    /**
-     * An internal server error occurred preventing computation.
-     *
-     * <p>Additional detail may be available in {@link SubmitTypeRecord#errorMessage}
-     */
-    RULE_ERROR
-  }
-
-  public static SubmitTypeRecord OK(SubmitType type) {
-    return new SubmitTypeRecord(Status.OK, type, null);
-  }
-
-  public static SubmitTypeRecord error(String err) {
-    return new SubmitTypeRecord(SubmitTypeRecord.Status.RULE_ERROR, null, err);
-  }
-
-  /** Status enum value of the record. */
-  public final Status status;
-
-  /** Submit type of the record; never null if {@link #status} is {@code OK}. */
-  public final SubmitType type;
-
-  /** Submit type of the record; always null if {@link #status} is {@code OK}. */
-  public final String errorMessage;
-
-  private SubmitTypeRecord(Status status, SubmitType type, String errorMessage) {
-    if (type == SubmitType.INHERIT) {
-      throw new IllegalArgumentException("Cannot output submit type " + type);
-    }
-    this.status = status;
-    this.type = type;
-    this.errorMessage = errorMessage;
-  }
-
-  public boolean isOk() {
-    return status == Status.OK;
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder sb = new StringBuilder();
-    sb.append(status);
-    if (status == Status.RULE_ERROR && errorMessage != null) {
-      sb.append(" (").append(errorMessage).append(")");
-    }
-    if (type != null) {
-      sb.append('[');
-      sb.append(type.name());
-      sb.append(']');
-    }
-    return sb.toString();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/SubscribeSection.java b/java/com/google/gerrit/common/data/SubscribeSection.java
deleted file mode 100644
index 6ac4695..0000000
--- a/java/com/google/gerrit/common/data/SubscribeSection.java
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.Project;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import org.eclipse.jgit.transport.RefSpec;
-
-/** Portion of a {@link Project} describing superproject subscription rules. */
-public class SubscribeSection {
-
-  private final List<RefSpec> multiMatchRefSpecs;
-  private final List<RefSpec> matchingRefSpecs;
-  private final Project.NameKey project;
-
-  public SubscribeSection(Project.NameKey p) {
-    project = p;
-    matchingRefSpecs = new ArrayList<>();
-    multiMatchRefSpecs = new ArrayList<>();
-  }
-
-  public void addMatchingRefSpec(RefSpec spec) {
-    matchingRefSpecs.add(spec);
-  }
-
-  public void addMatchingRefSpec(String spec) {
-    RefSpec r = new RefSpec(spec);
-    matchingRefSpecs.add(r);
-  }
-
-  public void addMultiMatchRefSpec(String spec) {
-    RefSpec r = new RefSpec(spec, RefSpec.WildcardMode.ALLOW_MISMATCH);
-    multiMatchRefSpecs.add(r);
-  }
-
-  public Project.NameKey getProject() {
-    return project;
-  }
-
-  /**
-   * Determines if the <code>branch</code> could trigger a superproject update as allowed via this
-   * subscribe section.
-   *
-   * @param branch the branch to check
-   * @return if the branch could trigger a superproject update
-   */
-  public boolean appliesTo(BranchNameKey branch) {
-    for (RefSpec r : matchingRefSpecs) {
-      if (r.matchSource(branch.branch())) {
-        return true;
-      }
-    }
-    for (RefSpec r : multiMatchRefSpecs) {
-      if (r.matchSource(branch.branch())) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  public Collection<RefSpec> getMatchingRefSpecs() {
-    return Collections.unmodifiableCollection(matchingRefSpecs);
-  }
-
-  public Collection<RefSpec> getMultiMatchRefSpecs() {
-    return Collections.unmodifiableCollection(multiMatchRefSpecs);
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder ret = new StringBuilder();
-    ret.append("[SubscribeSection, project=");
-    ret.append(project);
-    if (!matchingRefSpecs.isEmpty()) {
-      ret.append(", matching=[");
-      for (RefSpec r : matchingRefSpecs) {
-        ret.append(r.toString());
-        ret.append(", ");
-      }
-    }
-    if (!multiMatchRefSpecs.isEmpty()) {
-      ret.append(", all=[");
-      for (RefSpec r : multiMatchRefSpecs) {
-        ret.append(r.toString());
-        ret.append(", ");
-      }
-    }
-    ret.append("]");
-    return ret.toString();
-  }
-}
diff --git a/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java b/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
index d841aa6..beb62b4 100644
--- a/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
+++ b/java/com/google/gerrit/common/data/testing/GroupReferenceSubject.java
@@ -20,8 +20,8 @@
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 
 public class GroupReferenceSubject extends Subject {
 
diff --git a/java/com/google/gerrit/entities/AccessSection.java b/java/com/google/gerrit/entities/AccessSection.java
new file mode 100644
index 0000000..d97bca8
--- /dev/null
+++ b/java/com/google/gerrit/entities/AccessSection.java
@@ -0,0 +1,165 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+/** Portion of a {@link Project} describing access rules. */
+@AutoValue
+public abstract class AccessSection implements Comparable<AccessSection> {
+  /** Special name given to the global capabilities; not a valid reference. */
+  public static final String GLOBAL_CAPABILITIES = "GLOBAL_CAPABILITIES";
+  /** Pattern that matches all references in a project. */
+  public static final String ALL = "refs/*";
+
+  /** Pattern that matches all branches in a project. */
+  public static final String HEADS = "refs/heads/*";
+
+  /** Prefix that triggers a regular expression pattern. */
+  public static final String REGEX_PREFIX = "^";
+
+  /** Name of the access section. It could be a ref pattern or something else. */
+  public abstract String getName();
+
+  public abstract ImmutableList<Permission> getPermissions();
+
+  public static AccessSection create(String name) {
+    return builder(name).build();
+  }
+
+  public static Builder builder(String name) {
+    return new AutoValue_AccessSection.Builder().setName(name).setPermissions(ImmutableList.of());
+  }
+
+  /** @return true if the name is likely to be a valid reference section name. */
+  public static boolean isValidRefSectionName(String name) {
+    return name.startsWith("refs/") || name.startsWith("^refs/");
+  }
+
+  @Nullable
+  public Permission getPermission(String name) {
+    requireNonNull(name);
+    for (Permission p : getPermissions()) {
+      if (p.getName().equalsIgnoreCase(name)) {
+        return p;
+      }
+    }
+    return null;
+  }
+
+  @Override
+  public final int compareTo(AccessSection o) {
+    return comparePattern().compareTo(o.comparePattern());
+  }
+
+  private String comparePattern() {
+    if (getName().startsWith(REGEX_PREFIX)) {
+      return getName().substring(REGEX_PREFIX.length());
+    }
+    return getName();
+  }
+
+  @Override
+  public final String toString() {
+    return "AccessSection[" + getName() + "]";
+  }
+
+  public Builder toBuilder() {
+    Builder b = autoToBuilder();
+    b.getPermissions().stream().map(Permission::toBuilder).forEach(p -> b.addPermission(p));
+    return b;
+  }
+
+  protected abstract Builder autoToBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    private final List<Permission.Builder> permissionBuilders;
+
+    protected Builder() {
+      permissionBuilders = new ArrayList<>();
+    }
+
+    public abstract Builder setName(String name);
+
+    public abstract String getName();
+
+    public Builder modifyPermissions(Consumer<List<Permission.Builder>> modification) {
+      modification.accept(permissionBuilders);
+      return this;
+    }
+
+    public Builder addPermission(Permission.Builder permission) {
+      requireNonNull(permission, "permission must be non-null");
+      return modifyPermissions(p -> p.add(permission));
+    }
+
+    public Builder remove(Permission.Builder permission) {
+      requireNonNull(permission, "permission must be non-null");
+      return removePermission(permission.getName());
+    }
+
+    public Builder removePermission(String name) {
+      requireNonNull(name, "name must be non-null");
+      return modifyPermissions(
+          p -> p.removeIf(permissionBuilder -> name.equalsIgnoreCase(permissionBuilder.getName())));
+    }
+
+    public Permission.Builder upsertPermission(String permissionName) {
+      requireNonNull(permissionName, "permissionName must be non-null");
+
+      Optional<Permission.Builder> maybePermission =
+          permissionBuilders.stream()
+              .filter(p -> p.getName().equalsIgnoreCase(permissionName))
+              .findAny();
+      if (maybePermission.isPresent()) {
+        return maybePermission.get();
+      }
+
+      Permission.Builder permission = Permission.builder(permissionName);
+      modifyPermissions(p -> p.add(permission));
+      return permission;
+    }
+
+    public AccessSection build() {
+      setPermissions(
+          permissionBuilders.stream().map(Permission.Builder::build).collect(toImmutableList()));
+      if (getPermissions().size()
+          > getPermissions().stream()
+              .map(Permission::getName)
+              .map(String::toLowerCase)
+              .distinct()
+              .count()) {
+        throw new IllegalArgumentException("duplicate permissions: " + getPermissions());
+      }
+      return autoBuild();
+    }
+
+    protected abstract AccessSection autoBuild();
+
+    protected abstract ImmutableList<Permission> getPermissions();
+
+    abstract Builder setPermissions(ImmutableList<Permission> permissions);
+  }
+}
diff --git a/java/com/google/gerrit/entities/AccountsSection.java b/java/com/google/gerrit/entities/AccountsSection.java
new file mode 100644
index 0000000..93083a2
--- /dev/null
+++ b/java/com/google/gerrit/entities/AccountsSection.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+
+@AutoValue
+public abstract class AccountsSection {
+  public abstract ImmutableList<PermissionRule> getSameGroupVisibility();
+
+  public static AccountsSection create(List<PermissionRule> sameGroupVisibility) {
+    return new AutoValue_AccountsSection(ImmutableList.copyOf(sameGroupVisibility));
+  }
+}
diff --git a/java/com/google/gerrit/entities/Address.java b/java/com/google/gerrit/entities/Address.java
new file mode 100644
index 0000000..2324330
--- /dev/null
+++ b/java/com/google/gerrit/entities/Address.java
@@ -0,0 +1,135 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+
+/** Represents an address (name + email) in an email message. */
+@AutoValue
+public abstract class Address {
+  public static Address parse(String in) {
+    final int lt = in.indexOf('<');
+    final int gt = in.indexOf('>');
+    final int at = in.indexOf("@");
+    if (0 <= lt && lt < gt && lt + 1 < at && at + 1 < gt) {
+      final String email = in.substring(lt + 1, gt).trim();
+      final String name = in.substring(0, lt).trim();
+      int nameStart = 0;
+      int nameEnd = name.length();
+      if (name.startsWith("\"")) {
+        nameStart++;
+      }
+      if (name.endsWith("\"")) {
+        nameEnd--;
+      }
+      return Address.create(name.length() > 0 ? name.substring(nameStart, nameEnd) : null, email);
+    }
+
+    if (lt < 0 && gt < 0 && 0 < at && at < in.length() - 1) {
+      return Address.create(in);
+    }
+
+    throw new IllegalArgumentException("Invalid email address: " + in);
+  }
+
+  public static Address tryParse(String in) {
+    try {
+      return parse(in);
+    } catch (IllegalArgumentException e) {
+      return null;
+    }
+  }
+
+  public static Address create(String email) {
+    return create(null, email);
+  }
+
+  public static Address create(String name, String email) {
+    return new AutoValue_Address(name, email);
+  }
+
+  @Nullable
+  public abstract String name();
+
+  public abstract String email();
+
+  @Override
+  public final int hashCode() {
+    return email().hashCode();
+  }
+
+  @Override
+  public final boolean equals(Object other) {
+    if (other instanceof Address) {
+      return email().equals(((Address) other).email());
+    }
+    return false;
+  }
+
+  @Override
+  public final String toString() {
+    return toHeaderString();
+  }
+
+  public String toHeaderString() {
+    if (name() != null) {
+      return quotedPhrase(name()) + " <" + email() + ">";
+    } else if (isSimple()) {
+      return email();
+    }
+    return "<" + email() + ">";
+  }
+
+  private static final String MUST_QUOTE_EMAIL = "()<>,;:\\\"[]";
+  private static final String MUST_QUOTE_NAME = MUST_QUOTE_EMAIL + "@.";
+
+  private boolean isSimple() {
+    for (int i = 0; i < email().length(); i++) {
+      final char c = email().charAt(i);
+      if (c <= ' ' || 0x7F <= c || MUST_QUOTE_EMAIL.indexOf(c) != -1) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  private static String quotedPhrase(String name) {
+    if (EmailHeader.needsQuotedPrintable(name)) {
+      return EmailHeader.quotedPrintable(name);
+    }
+    for (int i = 0; i < name.length(); i++) {
+      final char c = name.charAt(i);
+      if (MUST_QUOTE_NAME.indexOf(c) != -1) {
+        return wrapInQuotes(name);
+      }
+    }
+    return name;
+  }
+
+  private static String wrapInQuotes(String name) {
+    final StringBuilder r = new StringBuilder(2 + name.length());
+    r.append('"');
+    for (int i = 0; i < name.length(); i++) {
+      char c = name.charAt(i);
+      if (c == '"' || c == '\\') {
+        r.append('\\');
+      }
+      r.append(c);
+    }
+    r.append('"');
+    return r.toString();
+  }
+}
diff --git a/java/com/google/gerrit/entities/AttentionSetUpdate.java b/java/com/google/gerrit/entities/AttentionSetUpdate.java
index 45588722..2e58608 100644
--- a/java/com/google/gerrit/entities/AttentionSetUpdate.java
+++ b/java/com/google/gerrit/entities/AttentionSetUpdate.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import java.time.Instant;
 
 /**
@@ -23,9 +24,7 @@
  * in reverse chronological order. Since each update contains all required information and
  * invalidates all previous state, only the most recent record is relevant for each user.
  *
- * <p>See {@link com.google.gerrit.extensions.api.changes.AddToAttentionSetInput} and {@link
- * com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput} for the representation in
- * the API.
+ * <p>See {@link AttentionSetInput} for the representation in the API.
  */
 @AutoValue
 public abstract class AttentionSetUpdate {
diff --git a/java/com/google/gerrit/entities/BUILD b/java/com/google/gerrit/entities/BUILD
index 26265ae..66d1869 100644
--- a/java/com/google/gerrit/entities/BUILD
+++ b/java/com/google/gerrit/entities/BUILD
@@ -16,6 +16,7 @@
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/errorprone:annotations",
+        "//lib/flogger:api",
         "//proto:cache_java_proto",
         "//proto:entities_java_proto",
     ],
diff --git a/java/com/google/gerrit/entities/BranchOrderSection.java b/java/com/google/gerrit/entities/BranchOrderSection.java
new file mode 100644
index 0000000..f964e59
--- /dev/null
+++ b/java/com/google/gerrit/entities/BranchOrderSection.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.util.Collection;
+
+/**
+ * An ordering of branches by stability.
+ *
+ * <p>The REST API supports automatically checking if changes on development branches can be merged
+ * into stable branches. This is configured by the {@code branchOrder.branch} project setting. This
+ * class represents the ordered list of branches, by increasing stability.
+ */
+@AutoValue
+public abstract class BranchOrderSection {
+
+  /**
+   * Branch names ordered from least to the most stable.
+   *
+   * <p>Typically the order will be like: master, stable-M.N, stable-M.N-1, ...
+   *
+   * <p>Ref names in this list are exactly as they appear in {@code project.config}
+   */
+  public abstract ImmutableList<String> order();
+
+  public static BranchOrderSection create(Collection<String> order) {
+    // Do not mutate the given list as this will be written back to disk when ProjectConfig is
+    // stored.
+    return new AutoValue_BranchOrderSection(ImmutableList.copyOf(order));
+  }
+
+  /**
+   * Returns the tail list of branches that are more stable - so lower in the entire list ordered by
+   * priority compared to the provided branch. Always returns a fully qualified ref name (including
+   * the refs/heads/ prefix).
+   */
+  public ImmutableList<String> getMoreStable(String branch) {
+    ImmutableList<String> fullyQualifiedOrder =
+        order().stream().map(RefNames::fullName).collect(toImmutableList());
+    int i = fullyQualifiedOrder.indexOf(RefNames.fullName(branch));
+    if (0 <= i) {
+      return fullyQualifiedOrder.subList(i + 1, fullyQualifiedOrder.size());
+    }
+    return ImmutableList.of();
+  }
+}
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
new file mode 100644
index 0000000..eb9a3e2
--- /dev/null
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -0,0 +1,234 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Cached representation of values parsed from {@link
+ * com.google.gerrit.server.project.ProjectConfig}.
+ *
+ * <p>This class is immutable and thread-safe.
+ */
+@AutoValue
+public abstract class CachedProjectConfig {
+  public abstract Project getProject();
+
+  public abstract ImmutableMap<AccountGroup.UUID, GroupReference> getGroups();
+
+  /** Returns a set of all groups used by this configuration. */
+  public ImmutableSet<AccountGroup.UUID> getAllGroupUUIDs() {
+    return getGroups().keySet();
+  }
+
+  /**
+   * Returns the group reference for a {@link AccountGroup.UUID}, if the group is used by at least
+   * one rule.
+   */
+  public Optional<GroupReference> getGroup(AccountGroup.UUID uuid) {
+    return Optional.ofNullable(getGroups().get(uuid));
+  }
+
+  /**
+   * Returns the group reference for matching the given {@code name}, if the group is used by at
+   * least one rule.
+   */
+  public Optional<GroupReference> getGroupByName(@Nullable String name) {
+    if (name == null) {
+      return Optional.empty();
+    }
+    return getGroups().values().stream().filter(g -> name.equals(g.getName())).findAny();
+  }
+
+  /** Returns the account section containing visibility information about accounts. */
+  public abstract AccountsSection getAccountsSection();
+
+  /** Returns a map of {@link AccessSection}s keyed by their name. */
+  public abstract ImmutableMap<String, AccessSection> getAccessSections();
+
+  /** Returns the {@link AccessSection} with to the given name. */
+  public Optional<AccessSection> getAccessSection(String refName) {
+    return Optional.ofNullable(getAccessSections().get(refName));
+  }
+
+  /** Returns all {@link AccessSection} names. */
+  public ImmutableSet<String> getAccessSectionNames() {
+    return ImmutableSet.copyOf(getAccessSections().keySet());
+  }
+
+  /**
+   * Returns the {@link BranchOrderSection} containing the order in which branches should be shown.
+   */
+  public abstract Optional<BranchOrderSection> getBranchOrderSection();
+
+  /** Returns the {@link ContributorAgreement}s keyed by their name. */
+  public abstract ImmutableMap<String, ContributorAgreement> getContributorAgreements();
+
+  /** Returns the {@link NotifyConfig}s keyed by their name. */
+  public abstract ImmutableMap<String, NotifyConfig> getNotifySections();
+
+  /** Returns the {@link LabelType}s keyed by their name. */
+  public abstract ImmutableMap<String, LabelType> getLabelSections();
+
+  /** Returns configured {@link ConfiguredMimeTypes}s. */
+  public abstract ConfiguredMimeTypes getMimeTypes();
+
+  /** Returns {@link SubscribeSection} keyed by the {@link Project.NameKey} they reference. */
+  public abstract ImmutableMap<Project.NameKey, SubscribeSection> getSubscribeSections();
+
+  /** Returns {@link StoredCommentLinkInfo} keyed by their name. */
+  public abstract ImmutableMap<String, StoredCommentLinkInfo> getCommentLinkSections();
+
+  /** Returns the blob ID of the {@code rules.pl} file, if present. */
+  public abstract Optional<ObjectId> getRulesId();
+
+  // TODO(hiesel): This should not have to be an Optional.
+  /** Returns the SHA1 of the {@code refs/meta/config} branch. */
+  public abstract Optional<ObjectId> getRevision();
+
+  /** Returns the maximum allowed object size. */
+  public abstract long getMaxObjectSizeLimit();
+
+  /** Returns {@code true} if received objects should be checked for validity. */
+  public abstract boolean getCheckReceivedObjects();
+
+  /** Returns a list of panel sections keyed by title. */
+  public abstract ImmutableMap<String, ImmutableList<String>> getExtensionPanelSections();
+
+  public ImmutableList<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
+    return filterSubscribeSectionsByBranch(getSubscribeSections().values(), branch);
+  }
+
+  public abstract ImmutableMap<String, String> getPluginConfigs();
+
+  public static Builder builder() {
+    return new AutoValue_CachedProjectConfig.Builder();
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setProject(Project value);
+
+    public abstract Builder setAccountsSection(AccountsSection value);
+
+    public abstract Builder setBranchOrderSection(Optional<BranchOrderSection> value);
+
+    public Builder addGroup(GroupReference groupReference) {
+      groupsBuilder().put(groupReference.getUUID(), groupReference);
+      return this;
+    }
+
+    public Builder addAccessSection(AccessSection accessSection) {
+      accessSectionsBuilder().put(accessSection.getName(), accessSection);
+      return this;
+    }
+
+    public Builder addContributorAgreement(ContributorAgreement contributorAgreement) {
+      contributorAgreementsBuilder().put(contributorAgreement.getName(), contributorAgreement);
+      return this;
+    }
+
+    public Builder addNotifySection(NotifyConfig notifyConfig) {
+      notifySectionsBuilder().put(notifyConfig.getName(), notifyConfig);
+      return this;
+    }
+
+    public Builder addLabelSection(LabelType labelType) {
+      labelSectionsBuilder().put(labelType.getName(), labelType);
+      return this;
+    }
+
+    public abstract Builder setMimeTypes(ConfiguredMimeTypes value);
+
+    public Builder addSubscribeSection(SubscribeSection subscribeSection) {
+      subscribeSectionsBuilder().put(subscribeSection.project(), subscribeSection);
+      return this;
+    }
+
+    public Builder addCommentLinkSection(StoredCommentLinkInfo storedCommentLinkInfo) {
+      commentLinkSectionsBuilder().put(storedCommentLinkInfo.getName(), storedCommentLinkInfo);
+      return this;
+    }
+
+    public abstract Builder setRulesId(Optional<ObjectId> value);
+
+    public abstract Builder setRevision(Optional<ObjectId> value);
+
+    public abstract Builder setMaxObjectSizeLimit(long value);
+
+    public abstract Builder setCheckReceivedObjects(boolean value);
+
+    public abstract ImmutableMap.Builder<String, ImmutableList<String>>
+        extensionPanelSectionsBuilder();
+
+    public Builder setExtensionPanelSections(Map<String, List<String>> value) {
+      value
+          .entrySet()
+          .forEach(
+              e ->
+                  extensionPanelSectionsBuilder()
+                      .put(e.getKey(), ImmutableList.copyOf(e.getValue())));
+      return this;
+    }
+
+    abstract ImmutableMap.Builder<String, String> pluginConfigsBuilder();
+
+    public Builder addPluginConfig(String pluginName, String pluginConfig) {
+      pluginConfigsBuilder().put(pluginName, pluginConfig);
+      return this;
+    }
+
+    public abstract CachedProjectConfig build();
+
+    protected abstract ImmutableMap.Builder<AccountGroup.UUID, GroupReference> groupsBuilder();
+
+    protected abstract ImmutableMap.Builder<String, AccessSection> accessSectionsBuilder();
+
+    protected abstract ImmutableMap.Builder<String, ContributorAgreement>
+        contributorAgreementsBuilder();
+
+    protected abstract ImmutableMap.Builder<String, NotifyConfig> notifySectionsBuilder();
+
+    protected abstract ImmutableMap.Builder<String, LabelType> labelSectionsBuilder();
+
+    protected abstract ImmutableMap.Builder<Project.NameKey, SubscribeSection>
+        subscribeSectionsBuilder();
+
+    protected abstract ImmutableMap.Builder<String, StoredCommentLinkInfo>
+        commentLinkSectionsBuilder();
+  }
+
+  private static ImmutableList<SubscribeSection> filterSubscribeSectionsByBranch(
+      Collection<SubscribeSection> allSubscribeSections, BranchNameKey branch) {
+    ImmutableList.Builder<SubscribeSection> ret = ImmutableList.builder();
+    for (SubscribeSection s : allSubscribeSections) {
+      if (s.appliesTo(branch)) {
+        ret.add(s);
+      }
+    }
+    return ret.build();
+  }
+}
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index b36b5f9..845a9bb 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -39,7 +39,7 @@
  *          |
  *          +- {@link PatchSetApproval}: a +/- vote on the change's current state.
  *          |
- *          +- {@link Comment}: comment about a specific line
+ *          +- {@link HumanComment}: comment about a specific line
  * </pre>
  *
  * <p>
diff --git a/java/com/google/gerrit/entities/Comment.java b/java/com/google/gerrit/entities/Comment.java
index 9c58fef..2c10c87 100644
--- a/java/com/google/gerrit/entities/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -24,15 +24,15 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
- * This class represents inline comments in NoteDb. This means it determines the JSON format for
- * inline comments in the revision notes that NoteDb uses to persist inline comments.
+ * This class is a base class that can be extended by the different types of inline comment
+ * entities.
  *
  * <p>Changing fields in this class changes the storage format of inline comments in NoteDb and may
  * require a corresponding data migration (adding new optional fields is generally okay).
  *
- * <p>Consider updating {@link #getApproximateSize()} when adding/changing fields.
+ * <p>Consider updating {@link #getCommentFieldApproximateSize()} when adding/changing fields.
  */
-public class Comment {
+public abstract class Comment {
   public enum Status {
     DRAFT('d'),
 
@@ -301,11 +301,13 @@
    * Returns the comment's approximate size. This is used to enforce size limits and should
    * therefore include all unbounded fields (e.g. String-s).
    */
-  public int getApproximateSize() {
+  protected int getCommentFieldApproximateSize() {
     return nullableLength(message, parentUuid, tag, revId, serverId)
         + (key != null ? nullableLength(key.filename, key.uuid) : 0);
   }
 
+  public abstract int getApproximateSize();
+
   static int nullableLength(String... strings) {
     int length = 0;
     for (String s : strings) {
diff --git a/java/com/google/gerrit/entities/ConfiguredMimeTypes.java b/java/com/google/gerrit/entities/ConfiguredMimeTypes.java
new file mode 100644
index 0000000..6ba89c9
--- /dev/null
+++ b/java/com/google/gerrit/entities/ConfiguredMimeTypes.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.errors.InvalidPatternException;
+import org.eclipse.jgit.fnmatch.FileNameMatcher;
+import org.eclipse.jgit.lib.Config;
+
+@AutoValue
+public abstract class ConfiguredMimeTypes {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final String MIMETYPE = "mimetype";
+  private static final String KEY_PATH = "path";
+
+  public abstract ImmutableList<TypeMatcher> matchers();
+
+  public static ConfiguredMimeTypes create(String projectName, Config rc) {
+    Set<String> types = rc.getSubsections(MIMETYPE);
+    ImmutableList.Builder<TypeMatcher> matchers = ImmutableList.builder();
+    if (!types.isEmpty()) {
+      for (String typeName : types) {
+        for (String path : rc.getStringList(MIMETYPE, typeName, KEY_PATH)) {
+          try {
+            if (path.startsWith("^")) {
+              matchers.add(new ReType(typeName, path));
+            } else {
+              matchers.add(new FnType(typeName, path));
+            }
+          } catch (PatternSyntaxException | InvalidPatternException e) {
+            logger.atWarning().log(
+                "Ignoring invalid %s.%s.%s = %s in project %s: %s",
+                MIMETYPE, typeName, KEY_PATH, path, projectName, e.getMessage());
+          }
+        }
+      }
+    }
+    return new AutoValue_ConfiguredMimeTypes(matchers.build());
+  }
+
+  public static ConfiguredMimeTypes create(ImmutableList<TypeMatcher> matchers) {
+    return new AutoValue_ConfiguredMimeTypes(matchers);
+  }
+
+  @Nullable
+  public String getMimeType(String path) {
+    for (TypeMatcher m : matchers()) {
+      if (m.matches(path)) {
+        return m.type;
+      }
+    }
+    return null;
+  }
+
+  public abstract static class TypeMatcher {
+    private final String type;
+    private final String pattern;
+
+    private TypeMatcher(String type, String pattern) {
+      this.type = type;
+      this.pattern = pattern;
+    }
+
+    public String getPattern() {
+      return pattern;
+    }
+
+    public String getType() {
+      return type;
+    }
+
+    protected abstract boolean matches(String path);
+  }
+
+  public static class FnType extends TypeMatcher {
+    private final FileNameMatcher matcher;
+
+    public FnType(String type, String pattern) throws InvalidPatternException {
+      super(type, pattern);
+      this.matcher = new FileNameMatcher(pattern, null);
+    }
+
+    @Override
+    protected boolean matches(String input) {
+      FileNameMatcher m = new FileNameMatcher(matcher);
+      m.append(input);
+      return m.isMatch();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof FnType)) {
+        return false;
+      }
+      FnType other = (FnType) o;
+      return Objects.equals(other.getType(), getType())
+          && Objects.equals(other.getPattern(), getPattern());
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(getType(), getPattern());
+    }
+  }
+
+  public static class ReType extends TypeMatcher {
+    private final Pattern re;
+
+    public ReType(String type, String pattern) throws PatternSyntaxException {
+      super(type, pattern);
+      this.re = Pattern.compile(pattern);
+    }
+
+    @Override
+    protected boolean matches(String input) {
+      return re.matcher(input).matches();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof ReType)) {
+        return false;
+      }
+      ReType other = (ReType) o;
+      return Objects.equals(other.getType(), getType())
+          && Objects.equals(other.getPattern(), getPattern());
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(getType(), getPattern());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/entities/ContributorAgreement.java b/java/com/google/gerrit/entities/ContributorAgreement.java
new file mode 100644
index 0000000..1d933b5
--- /dev/null
+++ b/java/com/google/gerrit/entities/ContributorAgreement.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import java.util.List;
+
+/** Portion of a {@link Project} describing a single contributor agreement. */
+@AutoValue
+public abstract class ContributorAgreement implements Comparable<ContributorAgreement> {
+  public abstract String getName();
+
+  @Nullable
+  public abstract String getDescription();
+
+  public abstract ImmutableList<PermissionRule> getAccepted();
+
+  @Nullable
+  public abstract GroupReference getAutoVerify();
+
+  @Nullable
+  public abstract String getAgreementUrl();
+
+  public abstract ImmutableList<String> getExcludeProjectsRegexes();
+
+  public abstract ImmutableList<String> getMatchProjectsRegexes();
+
+  public static ContributorAgreement.Builder builder(String name) {
+    return new AutoValue_ContributorAgreement.Builder()
+        .setName(name)
+        .setAccepted(ImmutableList.of())
+        .setExcludeProjectsRegexes(ImmutableList.of())
+        .setMatchProjectsRegexes(ImmutableList.of());
+  }
+
+  @Override
+  public final int compareTo(ContributorAgreement o) {
+    return getName().compareTo(o.getName());
+  }
+
+  @Override
+  public final String toString() {
+    return "ContributorAgreement[" + getName() + "]";
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setName(String name);
+
+    public abstract Builder setDescription(@Nullable String description);
+
+    public abstract Builder setAccepted(ImmutableList<PermissionRule> accepted);
+
+    public abstract Builder setAutoVerify(@Nullable GroupReference autoVerify);
+
+    public abstract Builder setAgreementUrl(@Nullable String agreementUrl);
+
+    public abstract Builder setExcludeProjectsRegexes(List<String> excludeProjectsRegexes);
+
+    public abstract Builder setMatchProjectsRegexes(List<String> matchProjectsRegexes);
+
+    public abstract ContributorAgreement build();
+  }
+}
diff --git a/java/com/google/gerrit/entities/EmailHeader.java b/java/com/google/gerrit/entities/EmailHeader.java
new file mode 100644
index 0000000..71414c7
--- /dev/null
+++ b/java/com/google/gerrit/entities/EmailHeader.java
@@ -0,0 +1,233 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.MoreObjects;
+import java.io.IOException;
+import java.io.Writer;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+public abstract class EmailHeader {
+  public abstract boolean isEmpty();
+
+  public abstract void write(Writer w) throws IOException;
+
+  public static class String extends EmailHeader {
+    private final java.lang.String value;
+
+    public String(java.lang.String v) {
+      value = v;
+    }
+
+    public java.lang.String getString() {
+      return value;
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return value == null || value.length() == 0;
+    }
+
+    @Override
+    public void write(Writer w) throws IOException {
+      if (needsQuotedPrintable(value)) {
+        w.write(quotedPrintable(value));
+      } else {
+        w.write(value);
+      }
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(value);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return (o instanceof String) && Objects.equals(value, ((String) o).value);
+    }
+
+    @Override
+    public java.lang.String toString() {
+      return MoreObjects.toStringHelper(this).addValue(value).toString();
+    }
+  }
+
+  public static boolean needsQuotedPrintable(java.lang.String value) {
+    for (int i = 0; i < value.length(); i++) {
+      if (value.charAt(i) < ' ' || '~' < value.charAt(i)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  static boolean needsQuotedPrintableWithinPhrase(int cp) {
+    switch (cp) {
+      case '!':
+      case '*':
+      case '+':
+      case '-':
+      case '/':
+      case '=':
+      case '_':
+        return false;
+      default:
+        if (('a' <= cp && cp <= 'z') || ('A' <= cp && cp <= 'Z') || ('0' <= cp && cp <= '9')) {
+          return false;
+        }
+        return true;
+    }
+  }
+
+  public static java.lang.String quotedPrintable(java.lang.String value) {
+    final StringBuilder r = new StringBuilder();
+
+    r.append("=?UTF-8?Q?");
+    for (int i = 0; i < value.length(); i++) {
+      final int cp = value.codePointAt(i);
+      if (cp == ' ') {
+        r.append('_');
+
+      } else if (needsQuotedPrintableWithinPhrase(cp)) {
+        byte[] buf = new java.lang.String(Character.toChars(cp)).getBytes(UTF_8);
+        for (byte b : buf) {
+          r.append('=');
+          r.append(Integer.toHexString((b >>> 4) & 0x0f).toUpperCase());
+          r.append(Integer.toHexString(b & 0x0f).toUpperCase());
+        }
+
+      } else {
+        r.append(Character.toChars(cp));
+      }
+    }
+    r.append("?=");
+
+    return r.toString();
+  }
+
+  public static class Date extends EmailHeader {
+    private final java.util.Date value;
+
+    public Date(java.util.Date v) {
+      value = v;
+    }
+
+    public java.util.Date getDate() {
+      return value;
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return value == null;
+    }
+
+    @Override
+    public void write(Writer w) throws IOException {
+      final SimpleDateFormat fmt;
+      // Mon, 1 Jun 2009 10:49:44 -0700
+      fmt = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z", Locale.US);
+      w.write(fmt.format(value));
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(value);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return (o instanceof Date) && Objects.equals(value, ((Date) o).value);
+    }
+
+    @Override
+    public java.lang.String toString() {
+      return MoreObjects.toStringHelper(this).addValue(value).toString();
+    }
+  }
+
+  public static class AddressList extends EmailHeader {
+    private final List<Address> list = new ArrayList<>();
+
+    public AddressList() {}
+
+    public AddressList(Address addr) {
+      add(addr);
+    }
+
+    public List<Address> getAddressList() {
+      return Collections.unmodifiableList(list);
+    }
+
+    public void add(Address addr) {
+      list.add(addr);
+    }
+
+    public void remove(java.lang.String email) {
+      list.removeIf(address -> address.email().equals(email));
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return list.isEmpty();
+    }
+
+    @Override
+    public void write(Writer w) throws IOException {
+      int len = 8;
+      boolean firstAddress = true;
+      boolean needComma = false;
+      for (Address addr : list) {
+        java.lang.String s = addr.toHeaderString();
+        if (firstAddress) {
+          firstAddress = false;
+        } else if (72 < len + s.length()) {
+          w.write(",\r\n\t");
+          len = 8;
+          needComma = false;
+        }
+
+        if (needComma) {
+          w.write(", ");
+        }
+        w.write(s);
+        len += s.length();
+        needComma = true;
+      }
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(list);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return (o instanceof AddressList) && Objects.equals(list, ((AddressList) o).list);
+    }
+
+    @Override
+    public java.lang.String toString() {
+      return MoreObjects.toStringHelper(this).addValue(list).toString();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/entities/GroupDescription.java b/java/com/google/gerrit/entities/GroupDescription.java
new file mode 100644
index 0000000..e950257
--- /dev/null
+++ b/java/com/google/gerrit/entities/GroupDescription.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.gerrit.common.Nullable;
+import java.sql.Timestamp;
+import java.util.Set;
+
+/** Group methods exposed by the GroupBackend. */
+public class GroupDescription {
+  /** The Basic information required to be exposed by any Group. */
+  public interface Basic {
+    /** @return the non-null UUID of the group. */
+    AccountGroup.UUID getGroupUUID();
+
+    /** @return the non-null name of the group. */
+    String getName();
+
+    /**
+     * @return optional email address to send to the group's members. If provided, Gerrit will use
+     *     this email address to send change notifications to the group.
+     */
+    @Nullable
+    String getEmailAddress();
+
+    /**
+     * @return optional URL to information about the group. Typically a URL to a web page that
+     *     permits users to apply to join the group, or manage their membership.
+     */
+    @Nullable
+    String getUrl();
+  }
+
+  /** The extended information exposed by internal groups. */
+  public interface Internal extends Basic {
+
+    AccountGroup.Id getId();
+
+    @Nullable
+    String getDescription();
+
+    AccountGroup.UUID getOwnerGroupUUID();
+
+    boolean isVisibleToAll();
+
+    Timestamp getCreatedOn();
+
+    Set<Account.Id> getMembers();
+
+    Set<AccountGroup.UUID> getSubgroups();
+  }
+
+  private GroupDescription() {}
+}
diff --git a/java/com/google/gerrit/entities/GroupReference.java b/java/com/google/gerrit/entities/GroupReference.java
new file mode 100644
index 0000000..208ba0f
--- /dev/null
+++ b/java/com/google/gerrit/entities/GroupReference.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+
+/** Describes a group within a projects {@link AccessSection}s. */
+@AutoValue
+public abstract class GroupReference implements Comparable<GroupReference> {
+
+  private static final String PREFIX = "group ";
+
+  public static GroupReference forGroup(GroupDescription.Basic group) {
+    return GroupReference.create(group.getGroupUUID(), group.getName());
+  }
+
+  public static boolean isGroupReference(String configValue) {
+    return configValue != null && configValue.startsWith(PREFIX);
+  }
+
+  @Nullable
+  public static String extractGroupName(String configValue) {
+    if (!isGroupReference(configValue)) {
+      return null;
+    }
+    return configValue.substring(PREFIX.length()).trim();
+  }
+
+  @Nullable
+  public abstract AccountGroup.UUID getUUID();
+
+  public abstract String getName();
+
+  /**
+   * Create a group reference.
+   *
+   * @param uuid UUID of the group, must not be {@code null}
+   * @param name the group name, must not be {@code null}
+   */
+  public static GroupReference create(AccountGroup.UUID uuid, String name) {
+    return new AutoValue_GroupReference(requireNonNull(uuid), requireNonNull(name));
+  }
+
+  /**
+   * Create a group reference where the group's name couldn't be resolved.
+   *
+   * @param name the group name, must not be {@code null}
+   */
+  public static GroupReference create(String name) {
+    return new AutoValue_GroupReference(null, name);
+  }
+
+  @Override
+  public final int compareTo(GroupReference o) {
+    return uuid(this).compareTo(uuid(o));
+  }
+
+  private static String uuid(GroupReference a) {
+    if (a.getUUID() != null && a.getUUID().get() != null) {
+      return a.getUUID().get();
+    }
+
+    return "?";
+  }
+
+  @Override
+  public final int hashCode() {
+    return uuid(this).hashCode();
+  }
+
+  @Override
+  public final boolean equals(Object o) {
+    return o instanceof GroupReference && compareTo((GroupReference) o) == 0;
+  }
+
+  @Override
+  public final String toString() {
+    return "Group[" + getName() + " / " + getUUID() + "]";
+  }
+
+  public String toConfigValue() {
+    return PREFIX + getName();
+  }
+}
diff --git a/java/com/google/gerrit/entities/HumanComment.java b/java/com/google/gerrit/entities/HumanComment.java
new file mode 100644
index 0000000..8b687cc
--- /dev/null
+++ b/java/com/google/gerrit/entities/HumanComment.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import java.sql.Timestamp;
+
+/**
+ * This class represents inline human comments in NoteDb. This means it determines the JSON format
+ * for inline comments in the revision notes that NoteDb uses to persist inline comments.
+ *
+ * <p>Changing fields in this class changes the storage format of inline comments in NoteDb and may
+ * require a corresponding data migration (adding new optional fields is generally okay).
+ *
+ * <p>Consider updating {@link #getApproximateSize()} when adding/changing fields.
+ */
+public class HumanComment extends Comment {
+
+  public HumanComment(
+      Key key,
+      Account.Id author,
+      Timestamp writtenOn,
+      short side,
+      String message,
+      String serverId,
+      boolean unresolved) {
+    super(key, author, writtenOn, side, message, serverId, unresolved);
+  }
+
+  public HumanComment(HumanComment comment) {
+    super(comment);
+  }
+
+  @Override
+  public int getApproximateSize() {
+    return super.getCommentFieldApproximateSize();
+  }
+
+  @Override
+  public String toString() {
+    return toStringHelper().toString();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof HumanComment)) {
+      return false;
+    }
+    return super.equals(o);
+  }
+
+  @Override
+  public int hashCode() {
+    return super.hashCode();
+  }
+}
diff --git a/java/com/google/gerrit/entities/LabelFunction.java b/java/com/google/gerrit/entities/LabelFunction.java
new file mode 100644
index 0000000..f361741
--- /dev/null
+++ b/java/com/google/gerrit/entities/LabelFunction.java
@@ -0,0 +1,123 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.SubmitRecord.Label;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Functions for determining submittability based on label votes.
+ *
+ * <p>Only describes built-in label functions. Admins can extend the logic arbitrarily using Prolog
+ * rules, in which case the choice of function in the project config is ignored.
+ *
+ * <p>Function semantics are documented in {@code config-labels.txt}, and actual behavior is
+ * implemented both in Prolog in {@code gerrit_common.pl} and in the {@link #check} method.
+ */
+public enum LabelFunction {
+  ANY_WITH_BLOCK("AnyWithBlock", true, false, false),
+  MAX_WITH_BLOCK("MaxWithBlock", true, true, true),
+  MAX_NO_BLOCK("MaxNoBlock", false, true, true),
+  NO_BLOCK("NoBlock"),
+  NO_OP("NoOp"),
+  PATCH_SET_LOCK("PatchSetLock");
+
+  public static final Map<String, LabelFunction> ALL;
+
+  static {
+    Map<String, LabelFunction> all = new LinkedHashMap<>();
+    for (LabelFunction f : values()) {
+      all.put(f.getFunctionName(), f);
+    }
+    ALL = Collections.unmodifiableMap(all);
+  }
+
+  public static Optional<LabelFunction> parse(@Nullable String str) {
+    return Optional.ofNullable(ALL.get(str));
+  }
+
+  private final String name;
+  private final boolean isBlock;
+  private final boolean isRequired;
+  private final boolean requiresMaxValue;
+
+  LabelFunction(String name) {
+    this(name, false, false, false);
+  }
+
+  LabelFunction(String name, boolean isBlock, boolean isRequired, boolean requiresMaxValue) {
+    this.name = name;
+    this.isBlock = isBlock;
+    this.isRequired = isRequired;
+    this.requiresMaxValue = requiresMaxValue;
+  }
+
+  /** The function name as defined in documentation and {@code project.config}. */
+  public String getFunctionName() {
+    return name;
+  }
+
+  /** Whether the label is a "block" label, meaning a minimum vote will prevent submission. */
+  public boolean isBlock() {
+    return isBlock;
+  }
+
+  /** Whether the label is a mandatory label, meaning absence of votes will prevent submission. */
+  public boolean isRequired() {
+    return isRequired;
+  }
+
+  /** Whether the label requires a vote with the maximum value to allow submission. */
+  public boolean isMaxValueRequired() {
+    return requiresMaxValue;
+  }
+
+  public Label check(LabelType labelType, Iterable<PatchSetApproval> approvals) {
+    Label submitRecordLabel = new Label();
+    submitRecordLabel.label = labelType.getName();
+
+    submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
+    if (isRequired) {
+      submitRecordLabel.status = SubmitRecord.Label.Status.NEED;
+    }
+
+    for (PatchSetApproval a : approvals) {
+      if (a.value() == 0) {
+        continue;
+      }
+
+      if (isBlock && labelType.isMaxNegative(a)) {
+        submitRecordLabel.appliedBy = a.accountId();
+        submitRecordLabel.status = SubmitRecord.Label.Status.REJECT;
+        return submitRecordLabel;
+      }
+
+      if (labelType.isMaxPositive(a) || !requiresMaxValue) {
+        submitRecordLabel.appliedBy = a.accountId();
+
+        submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
+        if (isRequired) {
+          submitRecordLabel.status = SubmitRecord.Label.Status.OK;
+        }
+      }
+    }
+
+    return submitRecordLabel;
+  }
+}
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
new file mode 100644
index 0000000..a8d4da5
--- /dev/null
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -0,0 +1,298 @@
+// Copyright (C) 2008 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+@AutoValue
+public abstract class LabelType {
+  public static final boolean DEF_ALLOW_POST_SUBMIT = true;
+  public static final boolean DEF_CAN_OVERRIDE = true;
+  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
+  public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
+  public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
+  public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
+  public static final boolean DEF_COPY_ANY_SCORE = false;
+  public static final boolean DEF_COPY_MAX_SCORE = false;
+  public static final boolean DEF_COPY_MIN_SCORE = false;
+  public static final ImmutableList<Short> DEF_COPY_VALUES = ImmutableList.of();
+  public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
+
+  public static LabelType withDefaultValues(String name) {
+    checkName(name);
+    List<LabelValue> values = new ArrayList<>(2);
+    values.add(LabelValue.create((short) 0, "Rejected"));
+    values.add(LabelValue.create((short) 1, "Approved"));
+    return create(name, values);
+  }
+
+  public static String checkName(String name) throws IllegalArgumentException {
+    checkNameInternal(name);
+    if ("SUBM".equals(name)) {
+      throw new IllegalArgumentException("Reserved label name \"" + name + "\"");
+    }
+    return name;
+  }
+
+  public static String checkNameInternal(String name) throws IllegalArgumentException {
+    if (name == null || name.isEmpty()) {
+      throw new IllegalArgumentException("Empty label name");
+    }
+    for (int i = 0; i < name.length(); i++) {
+      char c = name.charAt(i);
+      if ((i == 0 && c == '-')
+          || !((c >= 'a' && c <= 'z')
+              || (c >= 'A' && c <= 'Z')
+              || (c >= '0' && c <= '9')
+              || c == '-')) {
+        throw new IllegalArgumentException("Illegal label name \"" + name + "\"");
+      }
+    }
+    return name;
+  }
+
+  private static ImmutableList<LabelValue> sortValues(List<LabelValue> values) {
+    if (values.isEmpty()) {
+      return ImmutableList.of();
+    }
+    values = values.stream().sorted(comparing(LabelValue::getValue)).collect(toList());
+    short v = values.get(0).getValue();
+    short i = 0;
+    ImmutableList.Builder<LabelValue> result = ImmutableList.builder();
+    // Fill in any missing values with empty text.
+    while (i < values.size()) {
+      while (v < values.get(i).getValue()) {
+        result.add(LabelValue.create(v++, ""));
+      }
+      v++;
+      result.add(values.get(i++));
+    }
+    return result.build();
+  }
+
+  public abstract String getName();
+
+  public abstract LabelFunction getFunction();
+
+  public abstract boolean isCopyAnyScore();
+
+  public abstract boolean isCopyMinScore();
+
+  public abstract boolean isCopyMaxScore();
+
+  public abstract boolean isCopyAllScoresOnMergeFirstParentUpdate();
+
+  public abstract boolean isCopyAllScoresOnTrivialRebase();
+
+  public abstract boolean isCopyAllScoresIfNoCodeChange();
+
+  public abstract boolean isCopyAllScoresIfNoChange();
+
+  public abstract ImmutableList<Short> getCopyValues();
+
+  public abstract boolean isAllowPostSubmit();
+
+  public abstract boolean isIgnoreSelfApproval();
+
+  public abstract short getDefaultValue();
+
+  public abstract ImmutableList<LabelValue> getValues();
+
+  public abstract short getMaxNegative();
+
+  public abstract short getMaxPositive();
+
+  public abstract boolean isCanOverride();
+
+  @Nullable
+  public abstract ImmutableList<String> getRefPatterns();
+
+  public abstract ImmutableMap<Short, LabelValue> getByValue();
+
+  public static LabelType create(String name, List<LabelValue> valueList) {
+    return LabelType.builder(name, valueList).build();
+  }
+
+  public static LabelType.Builder builder(String name, List<LabelValue> valueList) {
+    return (new AutoValue_LabelType.Builder())
+        .setName(name)
+        .setValues(valueList)
+        .setDefaultValue((short) 0)
+        .setFunction(LabelFunction.MAX_WITH_BLOCK)
+        .setMaxNegative(Short.MIN_VALUE)
+        .setMaxPositive(Short.MAX_VALUE)
+        .setCanOverride(DEF_CAN_OVERRIDE)
+        .setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
+        .setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
+        .setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
+        .setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
+        .setCopyAnyScore(DEF_COPY_ANY_SCORE)
+        .setCopyMaxScore(DEF_COPY_MAX_SCORE)
+        .setCopyMinScore(DEF_COPY_MIN_SCORE)
+        .setCopyValues(DEF_COPY_VALUES)
+        .setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT)
+        .setIgnoreSelfApproval(DEF_IGNORE_SELF_APPROVAL);
+  }
+
+  public boolean matches(PatchSetApproval psa) {
+    return psa.labelId().get().equalsIgnoreCase(getName());
+  }
+
+  public LabelValue getMin() {
+    if (getValues().isEmpty()) {
+      return null;
+    }
+    return getValues().get(0);
+  }
+
+  public LabelValue getMax() {
+    if (getValues().isEmpty()) {
+      return null;
+    }
+    return getValues().get(getValues().size() - 1);
+  }
+
+  public boolean isMaxNegative(PatchSetApproval ca) {
+    return getMaxNegative() == ca.value();
+  }
+
+  public boolean isMaxPositive(PatchSetApproval ca) {
+    return getMaxPositive() == ca.value();
+  }
+
+  public LabelValue getValue(short value) {
+    return getByValue().get(value);
+  }
+
+  public LabelValue getValue(PatchSetApproval ca) {
+    return getByValue().get(ca.value());
+  }
+
+  public LabelId getLabelId() {
+    return LabelId.create(getName());
+  }
+
+  @Override
+  public final String toString() {
+    StringBuilder sb = new StringBuilder(getName()).append('[');
+    LabelValue min = getMin();
+    LabelValue max = getMax();
+    if (min != null && max != null) {
+      sb.append(
+          new PermissionRange(Permission.forLabel(getName()), min.getValue(), max.getValue())
+              .toString()
+              .trim());
+    } else if (min != null) {
+      sb.append(min.formatValue().trim());
+    } else if (max != null) {
+      sb.append(max.formatValue().trim());
+    }
+    sb.append(']');
+    return sb.toString();
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setName(String name);
+
+    public abstract Builder setFunction(LabelFunction function);
+
+    public abstract Builder setCanOverride(boolean canOverride);
+
+    public abstract Builder setAllowPostSubmit(boolean allowPostSubmit);
+
+    public abstract Builder setIgnoreSelfApproval(boolean ignoreSelfApproval);
+
+    public abstract Builder setRefPatterns(@Nullable List<String> refPatterns);
+
+    public abstract Builder setValues(List<LabelValue> values);
+
+    public abstract Builder setDefaultValue(short defaultValue);
+
+    public abstract Builder setCopyAnyScore(boolean copyAnyScore);
+
+    public abstract Builder setCopyMinScore(boolean copyMinScore);
+
+    public abstract Builder setCopyMaxScore(boolean copyMaxScore);
+
+    public abstract Builder setCopyAllScoresOnMergeFirstParentUpdate(
+        boolean copyAllScoresOnMergeFirstParentUpdate);
+
+    public abstract Builder setCopyAllScoresOnTrivialRebase(boolean copyAllScoresOnTrivialRebase);
+
+    public abstract Builder setCopyAllScoresIfNoCodeChange(boolean copyAllScoresIfNoCodeChange);
+
+    public abstract Builder setCopyAllScoresIfNoChange(boolean copyAllScoresIfNoChange);
+
+    public abstract Builder setCopyValues(Collection<Short> copyValues);
+
+    public abstract Builder setMaxNegative(short maxNegative);
+
+    public abstract Builder setMaxPositive(short maxPositive);
+
+    public abstract ImmutableList<LabelValue> getValues();
+
+    protected abstract String getName();
+
+    protected abstract ImmutableList<Short> getCopyValues();
+
+    protected abstract Builder setByValue(ImmutableMap<Short, LabelValue> byValue);
+
+    @Nullable
+    protected abstract ImmutableList<String> getRefPatterns();
+
+    protected abstract LabelType autoBuild();
+
+    public LabelType build() throws IllegalArgumentException {
+      setName(checkName(getName()));
+      if (getRefPatterns() == null || getRefPatterns().isEmpty()) {
+        // Empty to null
+        setRefPatterns(null);
+      }
+
+      List<LabelValue> valueList = sortValues(getValues());
+      setValues(valueList);
+      if (!valueList.isEmpty()) {
+        if (valueList.get(0).getValue() < 0) {
+          setMaxNegative(valueList.get(0).getValue());
+        }
+        if (valueList.get(valueList.size() - 1).getValue() > 0) {
+          setMaxPositive(valueList.get(valueList.size() - 1).getValue());
+        }
+      }
+
+      ImmutableMap.Builder<Short, LabelValue> byValue = ImmutableMap.builder();
+      for (LabelValue v : valueList) {
+        byValue.put(v.getValue(), v);
+      }
+      setByValue(byValue.build());
+
+      setCopyValues(ImmutableList.sortedCopyOf(getCopyValues()));
+
+      return autoBuild();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/entities/LabelTypes.java b/java/com/google/gerrit/entities/LabelTypes.java
new file mode 100644
index 0000000..1c38c59
--- /dev/null
+++ b/java/com/google/gerrit/entities/LabelTypes.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class LabelTypes {
+  protected List<LabelType> labelTypes;
+  private transient volatile Map<String, LabelType> byLabel;
+  private transient volatile Map<String, Integer> positions;
+
+  protected LabelTypes() {}
+
+  public LabelTypes(List<? extends LabelType> approvals) {
+    labelTypes = Collections.unmodifiableList(new ArrayList<>(approvals));
+  }
+
+  public List<LabelType> getLabelTypes() {
+    return labelTypes;
+  }
+
+  public LabelType byLabel(LabelId labelId) {
+    return byLabel().get(labelId.get().toLowerCase());
+  }
+
+  public LabelType byLabel(String labelName) {
+    return byLabel().get(labelName.toLowerCase());
+  }
+
+  private Map<String, LabelType> byLabel() {
+    if (byLabel == null) {
+      synchronized (this) {
+        if (byLabel == null) {
+          Map<String, LabelType> l = new HashMap<>();
+          if (labelTypes != null) {
+            for (LabelType t : labelTypes) {
+              l.put(t.getName().toLowerCase(), t);
+            }
+          }
+          byLabel = l;
+        }
+      }
+    }
+    return byLabel;
+  }
+
+  @Override
+  public String toString() {
+    return labelTypes.toString();
+  }
+
+  public Comparator<String> nameComparator() {
+    final Map<String, Integer> positions = positions();
+    return new Comparator<String>() {
+      @Override
+      public int compare(String left, String right) {
+        int lp = position(left);
+        int rp = position(right);
+        int cmp = lp - rp;
+        if (cmp == 0) {
+          cmp = left.compareTo(right);
+        }
+        return cmp;
+      }
+
+      private int position(String name) {
+        Integer p = positions.get(name);
+        return p != null ? p : positions.size();
+      }
+    };
+  }
+
+  private Map<String, Integer> positions() {
+    if (positions == null) {
+      synchronized (this) {
+        if (positions == null) {
+          Map<String, Integer> p = new HashMap<>();
+          if (labelTypes != null) {
+            int i = 0;
+            for (LabelType t : labelTypes) {
+              p.put(t.getName(), i++);
+            }
+          }
+          positions = p;
+        }
+      }
+    }
+    return positions;
+  }
+}
diff --git a/java/com/google/gerrit/entities/LabelValue.java b/java/com/google/gerrit/entities/LabelValue.java
new file mode 100644
index 0000000..ec5a37e
--- /dev/null
+++ b/java/com/google/gerrit/entities/LabelValue.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+
+@AutoValue
+public abstract class LabelValue {
+  public static String formatValue(short value) {
+    if (value < 0) {
+      return Short.toString(value);
+    } else if (value == 0) {
+      return " 0";
+    } else {
+      return "+" + value;
+    }
+  }
+
+  public abstract short getValue();
+
+  public abstract String getText();
+
+  public static LabelValue create(short value, String text) {
+    return new AutoValue_LabelValue(value, text);
+  }
+
+  public String formatValue() {
+    return formatValue(getValue());
+  }
+
+  public String format() {
+    StringBuilder sb = new StringBuilder(formatValue());
+    if (!getText().isEmpty()) {
+      sb.append(' ').append(getText());
+    }
+    return sb.toString();
+  }
+
+  @Override
+  public final String toString() {
+    return format();
+  }
+}
diff --git a/java/com/google/gerrit/entities/NotifyConfig.java b/java/com/google/gerrit/entities/NotifyConfig.java
new file mode 100644
index 0000000..17da81f
--- /dev/null
+++ b/java/com/google/gerrit/entities/NotifyConfig.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import java.util.EnumSet;
+import java.util.Set;
+
+@AutoValue
+public abstract class NotifyConfig implements Comparable<NotifyConfig> {
+  public enum Header {
+    TO,
+    CC,
+    BCC
+  }
+
+  public enum NotifyType {
+    // sort by name, except 'ALL' which should stay last
+    ABANDONED_CHANGES,
+    ALL_COMMENTS,
+    NEW_CHANGES,
+    NEW_PATCHSETS,
+    SUBMITTED_CHANGES,
+
+    ALL
+  }
+
+  public abstract String getName();
+
+  public abstract ImmutableSet<NotifyType> getNotify();
+
+  @Nullable
+  public abstract String getFilter();
+
+  @Nullable
+  public abstract Header getHeader();
+
+  public abstract ImmutableSet<GroupReference> getGroups();
+
+  public abstract ImmutableSet<Address> getAddresses();
+
+  public boolean isNotify(NotifyType type) {
+    return getNotify().contains(type) || getNotify().contains(NotifyType.ALL);
+  }
+
+  public static Builder builder() {
+    return new AutoValue_NotifyConfig.Builder()
+        .setNotify(ImmutableSet.copyOf(EnumSet.of(NotifyType.ALL)));
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setName(String name);
+
+    public abstract Builder setNotify(Set<NotifyType> newTypes);
+
+    public abstract Builder setFilter(@Nullable String filter);
+
+    public abstract Builder setHeader(Header hdr);
+
+    public Builder addGroup(GroupReference group) {
+      groupsBuilder().add(group);
+      return this;
+    }
+
+    public Builder addAddress(Address address) {
+      addressesBuilder().add(address);
+      return this;
+    }
+
+    protected abstract ImmutableSet.Builder<GroupReference> groupsBuilder();
+
+    protected abstract ImmutableSet.Builder<Address> addressesBuilder();
+
+    protected abstract NotifyConfig autoBuild();
+
+    protected abstract String getFilter();
+
+    public NotifyConfig build() {
+      if ("*".equals(getFilter())) {
+        setFilter(null);
+      } else {
+        setFilter(Strings.emptyToNull(getFilter()));
+      }
+      return autoBuild();
+    }
+  }
+
+  @Override
+  public final int compareTo(NotifyConfig o) {
+    return getName().compareTo(o.getName());
+  }
+
+  @Override
+  public final int hashCode() {
+    return getName().hashCode();
+  }
+
+  @Override
+  public final boolean equals(Object obj) {
+    if (obj instanceof NotifyConfig) {
+      return compareTo((NotifyConfig) obj) == 0;
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index e47d197..e6b2167 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -36,6 +36,12 @@
   public static final String MERGE_LIST = "/MERGE_LIST";
 
   /**
+   * Magical file name which doesn't represent a file. Used specifically for patchset-level
+   * comments.
+   */
+  public static final String PATCHSET_LEVEL = "/PATCHSET_LEVEL";
+
+  /**
    * Checks if the given path represents a magic file. A magic file is a generated file that is
    * automatically included into changes. It does not exist in the commit of the patch set.
    *
@@ -43,7 +49,7 @@
    * @return {@code true} if the path represents a magic file, otherwise {@code false}.
    */
   public static boolean isMagic(String path) {
-    return COMMIT_MSG.equals(path) || MERGE_LIST.equals(path);
+    return COMMIT_MSG.equals(path) || MERGE_LIST.equals(path) || PATCHSET_LEVEL.equals(path);
   }
 
   public static Key key(PatchSet.Id patchSetId, String fileName) {
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index 4a33bd7..5c8f7eb 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -21,7 +21,6 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Streams;
 import com.google.common.primitives.Ints;
 import java.sql.Timestamp;
 import java.util.List;
@@ -55,7 +54,7 @@
   }
 
   public static ImmutableList<String> splitGroups(String joinedGroups) {
-    return Streams.stream(Splitter.on(',').split(joinedGroups)).collect(toImmutableList());
+    return Splitter.on(',').splitToStream(joinedGroups).collect(toImmutableList());
   }
 
   public static Id id(Change.Id changeId, int id) {
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
new file mode 100644
index 0000000..4f521a1
--- /dev/null
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -0,0 +1,291 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.function.Consumer;
+
+/** A single permission within an {@link AccessSection} of a project. */
+@AutoValue
+public abstract class Permission implements Comparable<Permission> {
+  public static final String ABANDON = "abandon";
+  public static final String ADD_PATCH_SET = "addPatchSet";
+  public static final String CREATE = "create";
+  public static final String CREATE_SIGNED_TAG = "createSignedTag";
+  public static final String CREATE_TAG = "createTag";
+  public static final String DELETE = "delete";
+  public static final String DELETE_CHANGES = "deleteChanges";
+  public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
+  public static final String EDIT_ASSIGNEE = "editAssignee";
+  public static final String EDIT_HASHTAGS = "editHashtags";
+  public static final String EDIT_TOPIC_NAME = "editTopicName";
+  public static final String FORGE_AUTHOR = "forgeAuthor";
+  public static final String FORGE_COMMITTER = "forgeCommitter";
+  public static final String FORGE_SERVER = "forgeServerAsCommitter";
+  public static final String LABEL = "label-";
+  public static final String LABEL_AS = "labelAs-";
+  public static final String OWNER = "owner";
+  public static final String PUSH = "push";
+  public static final String PUSH_MERGE = "pushMerge";
+  public static final String READ = "read";
+  public static final String REBASE = "rebase";
+  public static final String REMOVE_REVIEWER = "removeReviewer";
+  public static final String REVERT = "revert";
+  public static final String SUBMIT = "submit";
+  public static final String SUBMIT_AS = "submitAs";
+  public static final String TOGGLE_WORK_IN_PROGRESS_STATE = "toggleWipState";
+  public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges";
+
+  private static final List<String> NAMES_LC;
+  private static final int LABEL_INDEX;
+  private static final int LABEL_AS_INDEX;
+
+  static {
+    NAMES_LC = new ArrayList<>();
+    NAMES_LC.add(ABANDON.toLowerCase());
+    NAMES_LC.add(ADD_PATCH_SET.toLowerCase());
+    NAMES_LC.add(CREATE.toLowerCase());
+    NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase());
+    NAMES_LC.add(CREATE_TAG.toLowerCase());
+    NAMES_LC.add(DELETE.toLowerCase());
+    NAMES_LC.add(DELETE_CHANGES.toLowerCase());
+    NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase());
+    NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
+    NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
+    NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
+    NAMES_LC.add(FORGE_AUTHOR.toLowerCase());
+    NAMES_LC.add(FORGE_COMMITTER.toLowerCase());
+    NAMES_LC.add(FORGE_SERVER.toLowerCase());
+    NAMES_LC.add(LABEL.toLowerCase());
+    NAMES_LC.add(LABEL_AS.toLowerCase());
+    NAMES_LC.add(OWNER.toLowerCase());
+    NAMES_LC.add(PUSH.toLowerCase());
+    NAMES_LC.add(PUSH_MERGE.toLowerCase());
+    NAMES_LC.add(READ.toLowerCase());
+    NAMES_LC.add(REBASE.toLowerCase());
+    NAMES_LC.add(REMOVE_REVIEWER.toLowerCase());
+    NAMES_LC.add(REVERT.toLowerCase());
+    NAMES_LC.add(SUBMIT.toLowerCase());
+    NAMES_LC.add(SUBMIT_AS.toLowerCase());
+    NAMES_LC.add(TOGGLE_WORK_IN_PROGRESS_STATE.toLowerCase());
+    NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase());
+
+    LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
+    LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
+  }
+
+  /** @return true if the name is recognized as a permission name. */
+  public static boolean isPermission(String varName) {
+    return isLabel(varName) || isLabelAs(varName) || NAMES_LC.contains(varName.toLowerCase());
+  }
+
+  public static boolean hasRange(String varName) {
+    return isLabel(varName) || isLabelAs(varName);
+  }
+
+  /** @return true if the permission name is actually for a review label. */
+  public static boolean isLabel(String varName) {
+    return varName.startsWith(LABEL) && LABEL.length() < varName.length();
+  }
+
+  /** @return true if the permission is for impersonated review labels. */
+  public static boolean isLabelAs(String var) {
+    return var.startsWith(LABEL_AS) && LABEL_AS.length() < var.length();
+  }
+
+  /** @return permission name for the given review label. */
+  public static String forLabel(String labelName) {
+    return LABEL + labelName;
+  }
+
+  /** @return permission name to apply a label for another user. */
+  public static String forLabelAs(String labelName) {
+    return LABEL_AS + labelName;
+  }
+
+  public static String extractLabel(String varName) {
+    if (isLabel(varName)) {
+      return varName.substring(LABEL.length());
+    } else if (isLabelAs(varName)) {
+      return varName.substring(LABEL_AS.length());
+    }
+    return null;
+  }
+
+  public static boolean canBeOnAllProjects(String ref, String permissionName) {
+    if (AccessSection.ALL.equals(ref)) {
+      return !OWNER.equals(permissionName);
+    }
+    return true;
+  }
+
+  public abstract String getName();
+
+  protected abstract boolean isExclusiveGroup();
+
+  public abstract ImmutableList<PermissionRule> getRules();
+
+  public static Builder builder(String name) {
+    return new AutoValue_Permission.Builder()
+        .setName(name)
+        .setExclusiveGroup(false)
+        .setRules(ImmutableList.of());
+  }
+
+  public static Permission create(String name) {
+    return builder(name).build();
+  }
+
+  public String getLabel() {
+    return extractLabel(getName());
+  }
+
+  public boolean getExclusiveGroup() {
+    // Only permit exclusive group behavior on non OWNER permissions,
+    // otherwise an owner might lose access to a delegated subspace.
+    //
+    return isExclusiveGroup() && !OWNER.equals(getName());
+  }
+
+  @Nullable
+  public PermissionRule getRule(GroupReference group) {
+    for (PermissionRule r : getRules()) {
+      if (sameGroup(r, group)) {
+        return r;
+      }
+    }
+
+    return null;
+  }
+
+  private static boolean sameGroup(PermissionRule rule, GroupReference group) {
+    if (group.getUUID() != null && rule.getGroup().getUUID() != null) {
+      return group.getUUID().equals(rule.getGroup().getUUID());
+    } else if (group.getName() != null && rule.getGroup().getName() != null) {
+      return group.getName().equals(rule.getGroup().getName());
+    } else {
+      return false;
+    }
+  }
+
+  @Override
+  public final int compareTo(Permission b) {
+    int cmp = index(this) - index(b);
+    if (cmp == 0) {
+      cmp = getName().compareTo(b.getName());
+    }
+    return cmp;
+  }
+
+  private static int index(Permission a) {
+    if (isLabel(a.getName())) {
+      return LABEL_INDEX;
+    } else if (isLabelAs(a.getName())) {
+      return LABEL_AS_INDEX;
+    }
+
+    int index = NAMES_LC.indexOf(a.getName().toLowerCase());
+    return 0 <= index ? index : NAMES_LC.size();
+  }
+
+  @Override
+  public final String toString() {
+    StringBuilder bldr = new StringBuilder();
+    bldr.append(getName()).append(" ");
+    if (isExclusiveGroup()) {
+      bldr.append("[exclusive] ");
+    }
+    bldr.append("[");
+    Iterator<PermissionRule> it = getRules().iterator();
+    while (it.hasNext()) {
+      bldr.append(it.next());
+      if (it.hasNext()) {
+        bldr.append(", ");
+      }
+    }
+    bldr.append("]");
+    return bldr.toString();
+  }
+
+  protected abstract Builder autoToBuilder();
+
+  public Builder toBuilder() {
+    Builder b = autoToBuilder();
+    getRules().stream().map(PermissionRule::toBuilder).forEach(r -> b.add(r));
+    return b;
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    private final List<PermissionRule.Builder> rulesBuilders;
+
+    Builder() {
+      rulesBuilders = new ArrayList<>();
+    }
+
+    public abstract Builder setName(String value);
+
+    public abstract String getName();
+
+    public abstract Builder setExclusiveGroup(boolean value);
+
+    public Builder modifyRules(Consumer<List<PermissionRule.Builder>> modification) {
+      modification.accept(rulesBuilders);
+      return this;
+    }
+
+    public Builder add(PermissionRule.Builder rule) {
+      return modifyRules(r -> r.add(rule));
+    }
+
+    public Builder remove(PermissionRule rule) {
+      if (rule != null) {
+        return removeRule(rule.getGroup());
+      }
+      return this;
+    }
+
+    public Builder removeRule(GroupReference group) {
+      return modifyRules(rules -> rules.removeIf(rule -> sameGroup(rule.build(), group)));
+    }
+
+    public Builder clearRules() {
+      return modifyRules(r -> r.clear());
+    }
+
+    public Permission build() {
+      setRules(
+          rulesBuilders.stream().map(PermissionRule.Builder::build).collect(toImmutableList()));
+      return autoBuild();
+    }
+
+    public List<PermissionRule.Builder> getRulesBuilders() {
+      return rulesBuilders;
+    }
+
+    protected abstract ImmutableList<PermissionRule> getRules();
+
+    protected abstract Builder setRules(ImmutableList<PermissionRule> rules);
+
+    protected abstract Permission autoBuild();
+  }
+}
diff --git a/java/com/google/gerrit/entities/PermissionRange.java b/java/com/google/gerrit/entities/PermissionRange.java
new file mode 100644
index 0000000..fa9f4c2
--- /dev/null
+++ b/java/com/google/gerrit/entities/PermissionRange.java
@@ -0,0 +1,144 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents a closed interval [min, max] with a name. The special value [0, 0] is understood to be
+ * the empty range.
+ */
+public class PermissionRange implements Comparable<PermissionRange> {
+  public static class WithDefaults extends PermissionRange {
+    protected int defaultMin;
+    protected int defaultMax;
+
+    protected WithDefaults() {}
+
+    public WithDefaults(String name, int min, int max, int defMin, int defMax) {
+      super(name, min, max);
+      setDefaultRange(defMin, defMax);
+    }
+
+    public int getDefaultMin() {
+      return defaultMin;
+    }
+
+    public int getDefaultMax() {
+      return defaultMax;
+    }
+
+    public void setDefaultRange(int min, int max) {
+      defaultMin = min;
+      defaultMax = max;
+    }
+
+    /** @return all values between {@link #getMin()} and {@link #getMax()} */
+    public List<Integer> getValuesAsList() {
+      ArrayList<Integer> r = new ArrayList<>(getRangeSize());
+      for (int i = min; i <= max; i++) {
+        r.add(i);
+      }
+      return r;
+    }
+
+    /** @return number of values between {@link #getMin()} and {@link #getMax()} */
+    public int getRangeSize() {
+      return max - min;
+    }
+  }
+
+  protected String name;
+  protected int min;
+  protected int max;
+
+  protected PermissionRange() {}
+
+  public PermissionRange(String name, int min, int max) {
+    this.name = name;
+
+    if (min <= max) {
+      this.min = min;
+      this.max = max;
+    } else {
+      this.min = 0;
+      this.max = 0;
+    }
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public boolean isLabel() {
+    return Permission.isLabel(getName());
+  }
+
+  public String getLabel() {
+    return Permission.extractLabel(getName());
+  }
+
+  public int getMin() {
+    return min;
+  }
+
+  public int getMax() {
+    return max;
+  }
+
+  /** True if the value is within the range. */
+  public boolean contains(int value) {
+    return getMin() <= value && value <= getMax();
+  }
+
+  /** Normalize the value to fit within the bounds of the range. */
+  public int squash(int value) {
+    return Math.min(Math.max(getMin(), value), getMax());
+  }
+
+  /** True both {@link #getMin()} and {@link #getMax()} are 0. */
+  public boolean isEmpty() {
+    return getMin() == 0 && getMax() == 0;
+  }
+
+  @Override
+  public int compareTo(PermissionRange o) {
+    return getName().compareTo(o.getName());
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder r = new StringBuilder();
+    if (getMin() < 0 && getMax() == 0) {
+      r.append(getMin());
+      r.append(' ');
+    } else {
+      if (getMin() != getMax()) {
+        if (0 <= getMin()) {
+          r.append('+');
+        }
+        r.append(getMin());
+        r.append("..");
+      }
+      if (0 <= getMax()) {
+        r.append('+');
+      }
+      r.append(getMax());
+      r.append(' ');
+    }
+    return r.toString();
+  }
+}
diff --git a/java/com/google/gerrit/entities/PermissionRule.java b/java/com/google/gerrit/entities/PermissionRule.java
new file mode 100644
index 0000000..f67da5e
--- /dev/null
+++ b/java/com/google/gerrit/entities/PermissionRule.java
@@ -0,0 +1,270 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+
+@AutoValue
+public abstract class PermissionRule implements Comparable<PermissionRule> {
+  public enum Action {
+    ALLOW,
+    DENY,
+    BLOCK,
+
+    INTERACTIVE,
+    BATCH
+  }
+
+  public abstract Action getAction();
+
+  public abstract boolean getForce();
+
+  public abstract int getMin();
+
+  public abstract int getMax();
+
+  public abstract GroupReference getGroup();
+
+  public static PermissionRule.Builder builder(GroupReference group) {
+    return builder().setGroup(group);
+  }
+
+  public static PermissionRule create(GroupReference group) {
+    return builder().setGroup(group).build();
+  }
+
+  protected static Builder builder() {
+    return new AutoValue_PermissionRule.Builder()
+        .setMin(0)
+        .setMax(0)
+        .setAction(Action.ALLOW)
+        .setForce(false);
+  }
+
+  static PermissionRule merge(PermissionRule src, PermissionRule dest) {
+    PermissionRule.Builder result = dest.toBuilder();
+    if (dest.getAction() != src.getAction()) {
+      if (dest.getAction() == Action.BLOCK || src.getAction() == Action.BLOCK) {
+        result.setAction(Action.BLOCK);
+
+      } else if (dest.getAction() == Action.DENY || src.getAction() == Action.DENY) {
+        result.setAction(Action.DENY);
+
+      } else if (dest.getAction() == Action.BATCH || src.getAction() == Action.BATCH) {
+        result.setAction(Action.BATCH);
+      }
+    }
+
+    result.setForce(dest.getForce() || src.getForce());
+    result.setRange(Math.min(dest.getMin(), src.getMin()), Math.max(dest.getMax(), src.getMax()));
+    return result.build();
+  }
+
+  public boolean isDeny() {
+    return getAction() == Action.DENY;
+  }
+
+  public boolean isBlock() {
+    return getAction() == Action.BLOCK;
+  }
+
+  @Override
+  public int compareTo(PermissionRule o) {
+    int cmp = action(this) - action(o);
+    if (cmp == 0) {
+      cmp = range(o) - range(this);
+    }
+    if (cmp == 0) {
+      cmp = group(this).compareTo(group(o));
+    }
+    return cmp;
+  }
+
+  private static int action(PermissionRule a) {
+    switch (a.getAction()) {
+      case DENY:
+        return 0;
+      case ALLOW:
+      case BATCH:
+      case BLOCK:
+      case INTERACTIVE:
+      default:
+        return 1 + a.getAction().ordinal();
+    }
+  }
+
+  private static int range(PermissionRule a) {
+    return Math.abs(a.getMin()) + Math.abs(a.getMax());
+  }
+
+  private static String group(PermissionRule a) {
+    return a.getGroup().getName() != null ? a.getGroup().getName() : "";
+  }
+
+  @Override
+  public final String toString() {
+    return asString(true);
+  }
+
+  public String asString(boolean canUseRange) {
+    StringBuilder r = new StringBuilder();
+
+    switch (getAction()) {
+      case ALLOW:
+        break;
+
+      case DENY:
+        r.append("deny ");
+        break;
+
+      case BLOCK:
+        r.append("block ");
+        break;
+
+      case INTERACTIVE:
+        r.append("interactive ");
+        break;
+
+      case BATCH:
+        r.append("batch ");
+        break;
+    }
+
+    if (getForce()) {
+      r.append("+force ");
+    }
+
+    if (canUseRange && (getMin() != 0 || getMax() != 0)) {
+      if (0 <= getMin()) {
+        r.append('+');
+      }
+      r.append(getMin());
+      r.append("..");
+      if (0 <= getMax()) {
+        r.append('+');
+      }
+      r.append(getMax());
+      r.append(' ');
+    }
+
+    r.append(getGroup().toConfigValue());
+
+    return r.toString();
+  }
+
+  public static PermissionRule fromString(String src, boolean mightUseRange) {
+    final String orig = src;
+    final PermissionRule.Builder rule = PermissionRule.builder();
+
+    src = src.trim();
+
+    if (src.startsWith("deny ")) {
+      rule.setAction(Action.DENY);
+      src = src.substring("deny ".length()).trim();
+
+    } else if (src.startsWith("block ")) {
+      rule.setAction(Action.BLOCK);
+      src = src.substring("block ".length()).trim();
+
+    } else if (src.startsWith("interactive ")) {
+      rule.setAction(Action.INTERACTIVE);
+      src = src.substring("interactive ".length()).trim();
+
+    } else if (src.startsWith("batch ")) {
+      rule.setAction(Action.BATCH);
+      src = src.substring("batch ".length()).trim();
+    }
+
+    if (src.startsWith("+force ")) {
+      rule.setForce(true);
+      src = src.substring("+force ".length()).trim();
+    }
+
+    if (mightUseRange && !GroupReference.isGroupReference(src)) {
+      int sp = src.indexOf(' ');
+      String range = src.substring(0, sp);
+
+      if (range.matches("^([+-]?\\d+)\\.\\.([+-]?\\d+)$")) {
+        int dotdot = range.indexOf("..");
+        int min = parseInt(range.substring(0, dotdot));
+        int max = parseInt(range.substring(dotdot + 2));
+        rule.setRange(min, max);
+      } else {
+        throw new IllegalArgumentException("Invalid range in rule: " + orig);
+      }
+
+      src = src.substring(sp + 1).trim();
+    }
+
+    String groupName = GroupReference.extractGroupName(src);
+    if (groupName != null) {
+      GroupReference group = GroupReference.create(groupName);
+      rule.setGroup(group);
+    } else {
+      throw new IllegalArgumentException("Rule must include group: " + orig);
+    }
+
+    return rule.build();
+  }
+
+  public boolean hasRange() {
+    return getMin() != 0 || getMax() != 0;
+  }
+
+  public static int parseInt(String value) {
+    if (value.startsWith("+")) {
+      value = value.substring(1);
+    }
+    return Integer.parseInt(value);
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public Builder setDeny() {
+      return setAction(Action.DENY);
+    }
+
+    public Builder setBlock() {
+      return setAction(Action.BLOCK);
+    }
+
+    public Builder setRange(int newMin, int newMax) {
+      if (newMax < newMin) {
+        setMin(newMax);
+        setMax(newMin);
+      } else {
+        setMin(newMin);
+        setMax(newMax);
+      }
+      return this;
+    }
+
+    public abstract Builder setAction(Action action);
+
+    public abstract Builder setGroup(GroupReference groupReference);
+
+    public abstract Builder setForce(boolean newForce);
+
+    public abstract Builder setMin(int min);
+
+    public abstract Builder setMax(int max);
+
+    public abstract GroupReference getGroup();
+
+    public abstract PermissionRule build();
+  }
+}
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index 867b14d..ef3cbeb 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -16,6 +16,10 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -26,7 +30,8 @@
 import java.util.Optional;
 
 /** Projects match a source code repository managed by Gerrit */
-public final class Project {
+@AutoValue
+public abstract class Project {
   /** Default submit type for new projects. */
   public static final SubmitType DEFAULT_SUBMIT_TYPE = SubmitType.MERGE_IF_NECESSARY;
 
@@ -47,7 +52,10 @@
    * <p>Because of this unusual subclassing behavior, this class is not an {@code @AutoValue},
    * unlike other key types in this package. However, this is strictly an implementation detail; its
    * interface and semantics are otherwise analogous to the {@code @AutoValue} types.
+   *
+   * <p>This class is immutable and thread safe.
    */
+  @Immutable
   public static class NameKey implements Serializable, Comparable<NameKey> {
     private static final long serialVersionUID = 1L;
 
@@ -56,10 +64,6 @@
       return nameKey(KeyUtil.decode(str));
     }
 
-    public static String asStringOrNull(NameKey key) {
-      return key == null ? null : key.get();
-    }
-
     private final String name;
 
     protected NameKey(String name) {
@@ -72,140 +76,86 @@
 
     @Override
     public final int hashCode() {
-      return get().hashCode();
+      return name.hashCode();
     }
 
     @Override
     public final boolean equals(Object b) {
       if (b instanceof NameKey) {
-        return get().equals(((NameKey) b).get());
+        return name.equals(((NameKey) b).get());
       }
       return false;
     }
 
     @Override
     public final int compareTo(NameKey o) {
-      return get().compareTo(o.get());
+      return name.compareTo(o.get());
     }
 
     @Override
     public final String toString() {
-      return KeyUtil.encode(get());
+      return KeyUtil.encode(name);
     }
   }
 
-  protected NameKey name;
+  public abstract NameKey getNameKey();
 
-  protected String description;
+  @Nullable
+  public abstract String getDescription();
 
-  protected Map<BooleanProjectConfig, InheritableBoolean> booleanConfigs;
-
-  protected SubmitType submitType;
-
-  protected ProjectState state;
-
-  protected NameKey parent;
-
-  protected String maxObjectSizeLimit;
-
-  protected String defaultDashboardId;
-
-  protected String localDefaultDashboardId;
-
-  protected String configRefState;
-
-  protected Project() {}
-
-  public Project(Project.NameKey nameKey) {
-    name = nameKey;
-    submitType = SubmitType.MERGE_IF_NECESSARY;
-    state = ProjectState.ACTIVE;
-
-    booleanConfigs = new HashMap<>();
-    Arrays.stream(BooleanProjectConfig.values())
-        .forEach(c -> booleanConfigs.put(c, InheritableBoolean.INHERIT));
-  }
-
-  public Project.NameKey getNameKey() {
-    return name;
-  }
-
-  public String getName() {
-    return name != null ? name.get() : null;
-  }
-
-  public String getDescription() {
-    return description;
-  }
-
-  public void setDescription(String d) {
-    description = d;
-  }
-
-  public String getMaxObjectSizeLimit() {
-    return maxObjectSizeLimit;
-  }
-
-  public InheritableBoolean getBooleanConfig(BooleanProjectConfig config) {
-    return booleanConfigs.get(config);
-  }
-
-  public void setBooleanConfig(BooleanProjectConfig config, InheritableBoolean val) {
-    booleanConfigs.replace(config, val);
-  }
-
-  public void setMaxObjectSizeLimit(String limit) {
-    maxObjectSizeLimit = limit;
-  }
+  public abstract ImmutableMap<BooleanProjectConfig, InheritableBoolean> getBooleanConfigs();
 
   /**
    * Submit type as configured in {@code project.config}.
    *
    * <p>Does not take inheritance into account, i.e. may return {@link SubmitType#INHERIT}.
-   *
-   * @return submit type.
    */
-  public SubmitType getConfiguredSubmitType() {
-    return submitType;
-  }
+  public abstract SubmitType getSubmitType();
 
-  public void setSubmitType(SubmitType type) {
-    submitType = type;
-  }
-
-  public ProjectState getState() {
-    return state;
-  }
-
-  public void setState(ProjectState newState) {
-    state = newState;
-  }
-
-  public String getDefaultDashboard() {
-    return defaultDashboardId;
-  }
-
-  public void setDefaultDashboard(String defaultDashboardId) {
-    this.defaultDashboardId = defaultDashboardId;
-  }
-
-  public String getLocalDefaultDashboard() {
-    return localDefaultDashboardId;
-  }
-
-  public void setLocalDefaultDashboard(String localDefaultDashboardId) {
-    this.localDefaultDashboardId = localDefaultDashboardId;
-  }
+  public abstract ProjectState getState();
 
   /**
-   * Returns the name key of the parent project.
+   * Name key of the parent project.
    *
-   * @return name key of the parent project, {@code null} if this project is the wild project,
-   *     {@code null} or the name key of the wild project if this project is a direct child of the
-   *     wild project
+   * <p>{@code null} if this project is the wild project, {@code null} or the name key of the wild
+   * project if this project is a direct child of the wild project.
    */
-  public Project.NameKey getParent() {
-    return parent;
+  @Nullable
+  public abstract NameKey getParent();
+
+  @Nullable
+  public abstract String getMaxObjectSizeLimit();
+
+  @Nullable
+  public abstract String getDefaultDashboard();
+
+  @Nullable
+  public abstract String getLocalDefaultDashboard();
+
+  /** The {@code ObjectId} as 40 digit hex of {@code refs/meta/config}'s HEAD. */
+  @Nullable
+  public abstract String getConfigRefState();
+
+  public static Builder builder(Project.NameKey nameKey) {
+    Builder builder =
+        new AutoValue_Project.Builder()
+            .setNameKey(nameKey)
+            .setSubmitType(SubmitType.MERGE_IF_NECESSARY)
+            .setState(ProjectState.ACTIVE);
+    ImmutableMap.Builder<BooleanProjectConfig, InheritableBoolean> booleans =
+        ImmutableMap.builder();
+    Arrays.stream(BooleanProjectConfig.values())
+        .forEach(b -> booleans.put(b, InheritableBoolean.INHERIT));
+    builder.setBooleanConfigs(booleans.build());
+    return builder;
+  }
+
+  public String getName() {
+    return getNameKey() != null ? getNameKey().get() : null;
+  }
+
+  public InheritableBoolean getBooleanConfig(BooleanProjectConfig config) {
+    return getBooleanConfigs().get(config);
   }
 
   /**
@@ -216,11 +166,11 @@
    *     project
    */
   public Project.NameKey getParent(Project.NameKey allProjectsName) {
-    if (parent != null) {
-      return parent;
+    if (getParent() != null) {
+      return getParent();
     }
 
-    if (name.equals(allProjectsName)) {
+    if (getNameKey().equals(allProjectsName)) {
       return null;
     }
 
@@ -228,29 +178,53 @@
   }
 
   public String getParentName() {
-    return parent != null ? parent.get() : null;
-  }
-
-  public void setParentName(String n) {
-    parent = n != null ? nameKey(n) : null;
-  }
-
-  public void setParentName(NameKey n) {
-    parent = n;
-  }
-
-  /** Returns the {@code ObjectId} as 40 digit hex of {@code refs/meta/config}'s HEAD. */
-  public String getConfigRefState() {
-    return configRefState;
-  }
-
-  /** Sets the {@code ObjectId} as 40 digit hex of {@code refs/meta/config}'s HEAD. */
-  public void setConfigRefState(String state) {
-    configRefState = state;
+    return getParent() != null ? getParent().get() : null;
   }
 
   @Override
-  public String toString() {
+  public final String toString() {
     return Optional.of(getName()).orElse("<null>");
   }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setDescription(String description);
+
+    public Builder setBooleanConfig(BooleanProjectConfig config, InheritableBoolean val) {
+      Map<BooleanProjectConfig, InheritableBoolean> map = new HashMap<>(getBooleanConfigs());
+      map.replace(config, val);
+      setBooleanConfigs(ImmutableMap.copyOf(map));
+      return this;
+    }
+
+    public abstract Builder setMaxObjectSizeLimit(String limit);
+
+    public abstract Builder setSubmitType(SubmitType type);
+
+    public abstract Builder setState(ProjectState newState);
+
+    public abstract Builder setDefaultDashboard(String defaultDashboardId);
+
+    public abstract Builder setLocalDefaultDashboard(String localDefaultDashboard);
+
+    public abstract Builder setParent(NameKey n);
+
+    public Builder setParent(String n) {
+      return setParent(n != null ? nameKey(n) : null);
+    }
+
+    /** Sets the {@code ObjectId} as 40 digit hex of {@code refs/meta/config}'s HEAD. */
+    public abstract Builder setConfigRefState(String state);
+
+    public abstract Project build();
+
+    protected abstract Builder setNameKey(Project.NameKey nameKey);
+
+    protected abstract ImmutableMap<BooleanProjectConfig, InheritableBoolean> getBooleanConfigs();
+
+    protected abstract Builder setBooleanConfigs(
+        ImmutableMap<BooleanProjectConfig, InheritableBoolean> booleanConfigs);
+  }
 }
diff --git a/java/com/google/gerrit/entities/RobotComment.java b/java/com/google/gerrit/entities/RobotComment.java
index 9256e79..03ddad5 100644
--- a/java/com/google/gerrit/entities/RobotComment.java
+++ b/java/com/google/gerrit/entities/RobotComment.java
@@ -42,7 +42,8 @@
 
   @Override
   public int getApproximateSize() {
-    int approximateSize = super.getApproximateSize() + nullableLength(robotId, robotRunId, url);
+    int approximateSize =
+        super.getCommentFieldApproximateSize() + nullableLength(robotId, robotRunId, url);
     approximateSize +=
         properties != null
             ? properties.entrySet().stream()
@@ -66,4 +67,23 @@
         .add("fixSuggestions", Objects.toString(fixSuggestions, ""))
         .toString();
   }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof RobotComment)) {
+      return false;
+    }
+    RobotComment c = (RobotComment) o;
+    return super.equals(o)
+        && Objects.equals(robotId, c.robotId)
+        && Objects.equals(robotRunId, c.robotRunId)
+        && Objects.equals(url, c.url)
+        && Objects.equals(properties, c.properties)
+        && Objects.equals(fixSuggestions, c.fixSuggestions);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(super.hashCode(), robotId, robotRunId, url, properties, fixSuggestions);
+  }
 }
diff --git a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
new file mode 100644
index 0000000..ce24d31
--- /dev/null
+++ b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+
+/** Info about a single commentlink section in a config. */
+@AutoValue
+public abstract class StoredCommentLinkInfo {
+  public abstract String getName();
+
+  /** A regular expression to match for the commentlink to apply. */
+  @Nullable
+  public abstract String getMatch();
+
+  /** The link to replace the match with. This can only be set if html is {@code null}. */
+  @Nullable
+  public abstract String getLink();
+
+  /** The html to replace the match with. This can only be set if link is {@code null}. */
+  @Nullable
+  public abstract String getHtml();
+
+  /** Weather this comment link is active. {@code null} means true. */
+  @Nullable
+  public abstract Boolean getEnabled();
+
+  /** If set, {@link StoredCommentLinkInfo} has to be overriden to take any effect. */
+  public abstract boolean getOverrideOnly();
+
+  /**
+   * Creates an enabled {@link StoredCommentLinkInfo} that can be overriden but doesn't do anything
+   * on its own.
+   */
+  public static StoredCommentLinkInfo enabled(String name) {
+    return builder(name).setOverrideOnly(true).build();
+  }
+
+  /**
+   * Creates a disabled {@link StoredCommentLinkInfo} that can be overriden but doesn't do anything
+   * on it's own.
+   */
+  public static StoredCommentLinkInfo disabled(String name) {
+    return builder(name).setOverrideOnly(true).build();
+  }
+
+  /** Creates and returns a new {@link StoredCommentLinkInfo.Builder} instance. */
+  public static Builder builder(String name) {
+    checkArgument(name != null, "invalid commentlink.name");
+    return new AutoValue_StoredCommentLinkInfo.Builder().setName(name).setOverrideOnly(false);
+  }
+
+  /** Creates and returns a new {@link StoredCommentLinkInfo} instance with the same values. */
+  public static StoredCommentLinkInfo fromInfo(CommentLinkInfo src, Boolean enabled) {
+    return builder(src.name)
+        .setMatch(src.match)
+        .setLink(src.link)
+        .setHtml(src.html)
+        .setEnabled(enabled)
+        .setOverrideOnly(false)
+        .build();
+  }
+
+  /** Returns an {@link CommentLinkInfo} instance with the same values. */
+  public CommentLinkInfo toInfo() {
+    CommentLinkInfo info = new CommentLinkInfo();
+    info.name = getName();
+    info.match = getMatch();
+    info.link = getLink();
+    info.html = getHtml();
+    info.enabled = getEnabled();
+    return info;
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setName(String value);
+
+    public abstract Builder setMatch(@Nullable String value);
+
+    public abstract Builder setLink(@Nullable String value);
+
+    public abstract Builder setHtml(@Nullable String value);
+
+    public abstract Builder setEnabled(@Nullable Boolean value);
+
+    public abstract Builder setOverrideOnly(boolean value);
+
+    public StoredCommentLinkInfo build() {
+      checkArgument(getName() != null, "invalid commentlink.name");
+      setLink(Strings.emptyToNull(getLink()));
+      setHtml(Strings.emptyToNull(getHtml()));
+      if (!getOverrideOnly()) {
+        checkArgument(
+            !Strings.isNullOrEmpty(getMatch()), "invalid commentlink.%s.match", getName());
+        checkArgument(
+            (getLink() != null && getHtml() == null) || (getLink() == null && getHtml() != null),
+            "commentlink.%s must have either link or html",
+            getName());
+      }
+      return autoBuild();
+    }
+
+    protected abstract StoredCommentLinkInfo autoBuild();
+
+    protected abstract String getName();
+
+    protected abstract String getMatch();
+
+    protected abstract String getLink();
+
+    protected abstract String getHtml();
+
+    protected abstract boolean getOverrideOnly();
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRecord.java b/java/com/google/gerrit/entities/SubmitRecord.java
new file mode 100644
index 0000000..67c6007
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRecord.java
@@ -0,0 +1,184 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+
+/** Describes the state and edits required to submit a change. */
+public class SubmitRecord {
+  public static boolean allRecordsOK(Collection<SubmitRecord> in) {
+    if (in == null || in.isEmpty()) {
+      // If the list is null or empty, it means that this Gerrit installation does not
+      // have any form of validation rules.
+      // Hence, the permission system should be used to determine if the change can be merged
+      // or not.
+      return true;
+    }
+
+    // The change can be submitted, unless at least one plugin prevents it.
+    return in.stream().map(SubmitRecord::status).allMatch(SubmitRecord.Status::allowsSubmission);
+  }
+
+  public enum Status {
+    // NOTE: These values are persisted in the index, so deleting or changing
+    // the name of any values requires a schema upgrade.
+
+    /** The change is ready for submission. */
+    OK,
+
+    /** Something is preventing this change from being submitted. */
+    NOT_READY,
+
+    /** The change has been closed. */
+    CLOSED,
+
+    /** The change was submitted bypassing submit rules. */
+    FORCED,
+
+    /**
+     * An internal server error occurred preventing computation.
+     *
+     * <p>Additional detail may be available in {@link SubmitRecord#errorMessage}.
+     */
+    RULE_ERROR;
+
+    private boolean allowsSubmission() {
+      return this == OK || this == FORCED;
+    }
+  }
+
+  public Status status;
+  public List<Label> labels;
+  public List<SubmitRequirement> requirements;
+  public String errorMessage;
+
+  public static class Label {
+    public enum Status {
+      // NOTE: These values are persisted in the index, so deleting or changing
+      // the name of any values requires a schema upgrade.
+
+      /**
+       * This label provides what is necessary for submission.
+       *
+       * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
+       * to the change.
+       */
+      OK,
+
+      /**
+       * This label prevents the change from being submitted.
+       *
+       * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
+       * to the change.
+       */
+      REJECT,
+
+      /** The label is required for submission, but has not been satisfied. */
+      NEED,
+
+      /**
+       * The label may be set, but it's neither necessary for submission nor does it block
+       * submission if set.
+       */
+      MAY,
+
+      /**
+       * The label is required for submission, but is impossible to complete. The likely cause is
+       * access has not been granted correctly by the project owner or site administrator.
+       */
+      IMPOSSIBLE
+    }
+
+    public String label;
+    public Status status;
+    public Account.Id appliedBy;
+
+    @Override
+    public String toString() {
+      StringBuilder sb = new StringBuilder();
+      sb.append(label).append(": ").append(status);
+      if (appliedBy != null) {
+        sb.append(" by ").append(appliedBy);
+      }
+      return sb.toString();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Label) {
+        Label l = (Label) o;
+        return Objects.equals(label, l.label)
+            && Objects.equals(status, l.status)
+            && Objects.equals(appliedBy, l.appliedBy);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(label, status, appliedBy);
+    }
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append(status);
+    if (status == Status.RULE_ERROR && errorMessage != null) {
+      sb.append('(').append(errorMessage).append(')');
+    }
+    sb.append('[');
+    if (labels != null) {
+      String delimiter = "";
+      for (Label label : labels) {
+        sb.append(delimiter).append(label);
+        delimiter = ", ";
+      }
+    }
+    sb.append("],[");
+    if (requirements != null) {
+      String delimiter = "";
+      for (SubmitRequirement requirement : requirements) {
+        sb.append(delimiter).append(requirement);
+        delimiter = ", ";
+      }
+    }
+    sb.append(']');
+    return sb.toString();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof SubmitRecord) {
+      SubmitRecord r = (SubmitRecord) o;
+      return Objects.equals(status, r.status)
+          && Objects.equals(labels, r.labels)
+          && Objects.equals(errorMessage, r.errorMessage)
+          && Objects.equals(requirements, r.requirements);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(status, labels, errorMessage, requirements);
+  }
+
+  private Status status() {
+    return status;
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
new file mode 100644
index 0000000..f9301a4
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+
+/** Describes a requirement to submit a change. */
+@AutoValue
+@AutoValue.CopyAnnotations
+public abstract class SubmitRequirement {
+  private static final CharMatcher TYPE_MATCHER =
+      CharMatcher.inRange('a', 'z')
+          .or(CharMatcher.inRange('A', 'Z'))
+          .or(CharMatcher.inRange('0', '9'))
+          .or(CharMatcher.anyOf("-_"));
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setType(String value);
+
+    public abstract Builder setFallbackText(String value);
+
+    public SubmitRequirement build() {
+      SubmitRequirement requirement = autoBuild();
+      checkState(
+          validateType(requirement.type()),
+          "SubmitRequirement's type contains non alphanumerical symbols.");
+      return requirement;
+    }
+
+    abstract SubmitRequirement autoBuild();
+  }
+
+  public abstract String fallbackText();
+
+  public abstract String type();
+
+  public static Builder builder() {
+    return new AutoValue_SubmitRequirement.Builder();
+  }
+
+  private static boolean validateType(String type) {
+    return TYPE_MATCHER.matchesAllOf(type);
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitTypeRecord.java b/java/com/google/gerrit/entities/SubmitTypeRecord.java
new file mode 100644
index 0000000..492d637
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitTypeRecord.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.gerrit.extensions.client.SubmitType;
+
+/** Describes the submit type for a change. */
+public class SubmitTypeRecord {
+  public enum Status {
+    /** The type was computed successfully */
+    OK,
+
+    /**
+     * An internal server error occurred preventing computation.
+     *
+     * <p>Additional detail may be available in {@link SubmitTypeRecord#errorMessage}
+     */
+    RULE_ERROR
+  }
+
+  public static SubmitTypeRecord OK(SubmitType type) {
+    return new SubmitTypeRecord(Status.OK, type, null);
+  }
+
+  public static SubmitTypeRecord error(String err) {
+    return new SubmitTypeRecord(SubmitTypeRecord.Status.RULE_ERROR, null, err);
+  }
+
+  /** Status enum value of the record. */
+  public final Status status;
+
+  /** Submit type of the record; never null if {@link #status} is {@code OK}. */
+  public final SubmitType type;
+
+  /** Submit type of the record; always null if {@link #status} is {@code OK}. */
+  public final String errorMessage;
+
+  private SubmitTypeRecord(Status status, SubmitType type, String errorMessage) {
+    if (type == SubmitType.INHERIT) {
+      throw new IllegalArgumentException("Cannot output submit type " + type);
+    }
+    this.status = status;
+    this.type = type;
+    this.errorMessage = errorMessage;
+  }
+
+  public boolean isOk() {
+    return status == Status.OK;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder sb = new StringBuilder();
+    sb.append(status);
+    if (status == Status.RULE_ERROR && errorMessage != null) {
+      sb.append(" (").append(errorMessage).append(")");
+    }
+    if (type != null) {
+      sb.append('[');
+      sb.append(type.name());
+      sb.append(']');
+    }
+    return sb.toString();
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubscribeSection.java b/java/com/google/gerrit/entities/SubscribeSection.java
new file mode 100644
index 0000000..b95517c
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubscribeSection.java
@@ -0,0 +1,162 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.transport.RefSpec;
+
+/** Portion of a {@link Project} describing superproject subscription rules. */
+@AutoValue
+public abstract class SubscribeSection {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public abstract Project.NameKey project();
+
+  protected abstract ImmutableList<RefSpec> matchingRefSpecs();
+
+  protected abstract ImmutableList<RefSpec> multiMatchRefSpecs();
+
+  public static Builder builder(Project.NameKey project) {
+    return new AutoValue_SubscribeSection.Builder().project(project);
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder project(Project.NameKey project);
+
+    abstract ImmutableList.Builder<RefSpec> matchingRefSpecsBuilder();
+
+    abstract ImmutableList.Builder<RefSpec> multiMatchRefSpecsBuilder();
+
+    public Builder addMatchingRefSpec(String matchingRefSpec) {
+      matchingRefSpecsBuilder()
+          .add(new RefSpec(matchingRefSpec, RefSpec.WildcardMode.REQUIRE_MATCH));
+      return this;
+    }
+
+    public Builder addMultiMatchRefSpec(String multiMatchRefSpec) {
+      multiMatchRefSpecsBuilder()
+          .add(new RefSpec(multiMatchRefSpec, RefSpec.WildcardMode.ALLOW_MISMATCH));
+      return this;
+    }
+
+    public abstract SubscribeSection build();
+  }
+
+  /**
+   * Determines if the <code>branch</code> could trigger a superproject update as allowed via this
+   * subscribe section.
+   *
+   * @param branch the branch to check
+   * @return if the branch could trigger a superproject update
+   */
+  public boolean appliesTo(BranchNameKey branch) {
+    for (RefSpec r : matchingRefSpecs()) {
+      if (r.matchSource(branch.branch())) {
+        return true;
+      }
+    }
+    for (RefSpec r : multiMatchRefSpecs()) {
+      if (r.matchSource(branch.branch())) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public Collection<String> matchingRefSpecsAsString() {
+    return matchingRefSpecs().stream().map(RefSpec::toString).collect(toImmutableList());
+  }
+
+  public Collection<String> multiMatchRefSpecsAsString() {
+    return multiMatchRefSpecs().stream().map(RefSpec::toString).collect(toImmutableList());
+  }
+
+  /** Evaluates what the destination branches for the subscription are. */
+  public ImmutableSet<BranchNameKey> getDestinationBranches(
+      BranchNameKey src, Collection<Ref> allRefsInRefsHeads) {
+    Set<BranchNameKey> ret = new HashSet<>();
+    logger.atFine().log("Inspecting SubscribeSection %s", this);
+    for (RefSpec r : matchingRefSpecs()) {
+      logger.atFine().log("Inspecting [matching] ref %s", r);
+      if (!r.matchSource(src.branch())) {
+        continue;
+      }
+      if (r.isWildcard()) {
+        // refs/heads/*[:refs/somewhere/*]
+        ret.add(BranchNameKey.create(project(), r.expandFromSource(src.branch()).getDestination()));
+      } else {
+        // e.g. refs/heads/master[:refs/heads/stable]
+        String dest = r.getDestination();
+        if (dest == null) {
+          dest = r.getSource();
+        }
+        ret.add(BranchNameKey.create(project(), dest));
+      }
+    }
+
+    for (RefSpec r : multiMatchRefSpecs()) {
+      logger.atFine().log("Inspecting [all] ref %s", r);
+      if (!r.matchSource(src.branch())) {
+        continue;
+      }
+      for (Ref ref : allRefsInRefsHeads) {
+        if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
+          continue;
+        }
+        BranchNameKey b = BranchNameKey.create(project(), ref.getName());
+        if (!ret.contains(b)) {
+          ret.add(b);
+        }
+      }
+    }
+    logger.atFine().log("Returning possible branches: %s for project %s", ret, project());
+    return ImmutableSet.copyOf(ret);
+  }
+
+  @Override
+  public final String toString() {
+    StringBuilder ret = new StringBuilder();
+    ret.append("[SubscribeSection, project=");
+    ret.append(project());
+    if (!matchingRefSpecs().isEmpty()) {
+      ret.append(", matching=[");
+      for (RefSpec r : matchingRefSpecs()) {
+        ret.append(r.toString());
+        ret.append(", ");
+      }
+    }
+    if (!multiMatchRefSpecs().isEmpty()) {
+      ret.append(", all=[");
+      for (RefSpec r : multiMatchRefSpecs()) {
+        ret.append(r.toString());
+        ret.append(", ");
+      }
+    }
+    ret.append("]");
+    return ret.toString();
+  }
+}
diff --git a/java/com/google/gerrit/extensions/annotations/RemoveAfter.java b/java/com/google/gerrit/extensions/annotations/RemoveAfter.java
index aa31dd0..02f70e9 100644
--- a/java/com/google/gerrit/extensions/annotations/RemoveAfter.java
+++ b/java/com/google/gerrit/extensions/annotations/RemoveAfter.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.extensions.annotations;
 
-import static java.lang.annotation.RetentionPolicy.SOURCE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.ElementType;
@@ -26,7 +26,7 @@
  * period we promised to users.
  */
 @Target({ElementType.PARAMETER, ElementType.FIELD, ElementType.METHOD, ElementType.TYPE})
-@Retention(SOURCE)
+@Retention(RUNTIME)
 @BindingAnnotation
 public @interface RemoveAfter {
   /**
diff --git a/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java b/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java
deleted file mode 100644
index 39efc64..0000000
--- a/java/com/google/gerrit/extensions/api/changes/AddToAttentionSetInput.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.api.changes;
-
-/**
- * Input at API level to add a user to the attention set.
- *
- * @see RemoveFromAttentionSetInput
- * @see com.google.gerrit.extensions.common.AttentionSetEntry
- */
-public class AddToAttentionSetInput {
-  public String user;
-  public String reason;
-
-  public AddToAttentionSetInput(String user, String reason) {
-    this.user = user;
-    this.reason = reason;
-  }
-
-  public AddToAttentionSetInput() {}
-}
diff --git a/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java b/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java
index 5086cd8..da9a8c7 100644
--- a/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java
@@ -20,7 +20,7 @@
 /** API for managing the attention set of a change. */
 public interface AttentionSetApi {
 
-  void remove(RemoveFromAttentionSetInput input) throws RestApiException;
+  void remove(AttentionSetInput input) throws RestApiException;
 
   /**
    * A default implementation which allows source compatibility when adding new methods to the
@@ -28,7 +28,7 @@
    */
   class NotImplemented implements AttentionSetApi {
     @Override
-    public void remove(RemoveFromAttentionSetInput input) throws RestApiException {
+    public void remove(AttentionSetInput input) throws RestApiException {
       throw new NotImplementedException();
     }
   }
diff --git a/java/com/google/gerrit/extensions/api/changes/AttentionSetInput.java b/java/com/google/gerrit/extensions/api/changes/AttentionSetInput.java
new file mode 100644
index 0000000..4665b11
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/AttentionSetInput.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.extensions.common.AttentionSetInfo;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
+
+/**
+ * Input at API level to add a user to the attention set.
+ *
+ * @see AttentionSetInfo
+ */
+public class AttentionSetInput {
+  public String user;
+  @DefaultInput public String reason;
+  public NotifyHandling notify;
+  public Map<RecipientType, NotifyInfo> notifyDetails;
+
+  public AttentionSetInput(String user, String reason) {
+    this.user = user;
+    this.reason = reason;
+  }
+
+  public AttentionSetInput(String reason) {
+    this.reason = reason;
+  }
+
+  public AttentionSetInput() {}
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 8df5343..e8b58f9 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -302,7 +302,7 @@
   AttentionSetApi attention(String id) throws RestApiException;
 
   /** Adds a user to the attention set. */
-  AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException;
+  AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException;
 
   /** Set the assignee of a change. */
   AccountInfo setAssignee(AssigneeInput input) throws RestApiException;
@@ -578,7 +578,7 @@
     }
 
     @Override
-    public AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException {
+    public AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java b/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java
deleted file mode 100644
index 9212788..0000000
--- a/java/com/google/gerrit/extensions/api/changes/RemoveFromAttentionSetInput.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.api.changes;
-
-import com.google.gerrit.extensions.restapi.DefaultInput;
-
-/**
- * Input at API level to remove a user from the attention set.
- *
- * @see AddToAttentionSetInput
- * @see com.google.gerrit.extensions.common.AttentionSetEntry
- */
-public class RemoveFromAttentionSetInput {
-  @DefaultInput public String reason;
-
-  public RemoveFromAttentionSetInput(String reason) {
-    this.reason = reason;
-  }
-
-  public RemoveFromAttentionSetInput() {}
-}
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index b140064..a213ed6 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -75,6 +75,20 @@
    */
   public boolean ready;
 
+  /** Users that should be added to the attention set of this change. */
+  public List<AttentionSetInput> addToAttentionSet;
+
+  /** Users that should be removed from the attention set of this change. */
+  public List<AttentionSetInput> removeFromAttentionSet;
+
+  /**
+   * Users in the attention set will only be added and removed based on {@link #addToAttentionSet}
+   * and {@link #removeFromAttentionSet}. Normally, they are also added and removed when some events
+   * occur. E.g, adding/removing reviewers, marking a change ready for review or work in progress,
+   * and replying on changes.
+   */
+  public boolean ignoreAutomaticAttentionSetRules;
+
   public enum DraftHandling {
     /** Leave pending drafts alone. */
     KEEP,
@@ -139,6 +153,33 @@
     return this;
   }
 
+  public ReviewInput addUserToAttentionSet(String user, String reason) {
+    AttentionSetInput input = new AttentionSetInput();
+    input.user = user;
+    input.reason = reason;
+    if (addToAttentionSet == null) {
+      addToAttentionSet = new ArrayList<>();
+    }
+    addToAttentionSet.add(input);
+    return this;
+  }
+
+  public ReviewInput removeUserFromAttentionSet(String user, String reason) {
+    AttentionSetInput input = new AttentionSetInput();
+    input.user = user;
+    input.reason = reason;
+    if (removeFromAttentionSet == null) {
+      removeFromAttentionSet = new ArrayList<>();
+    }
+    removeFromAttentionSet.add(input);
+    return this;
+  }
+
+  public ReviewInput blockAutomaticAttentionSetRules() {
+    ignoreAutomaticAttentionSetRules = true;
+    return this;
+  }
+
   public ReviewInput setWorkInProgress(boolean workInProgress) {
     this.workInProgress = workInProgress;
     ready = !workInProgress;
@@ -170,4 +211,8 @@
   public static ReviewInput reject() {
     return new ReviewInput().label("Code-Review", -2);
   }
+
+  public static ReviewInput create() {
+    return new ReviewInput();
+  }
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index fb2a0fe..3ba1277 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
@@ -49,7 +51,7 @@
 
   public Map<String, CommentLinkInfo> commentlinks;
 
-  public Map<String, List<String>> extensionPanelNames;
+  public ImmutableMap<String, ImmutableList<String>> extensionPanelNames;
 
   public static class InheritedBooleanInfo {
     public Boolean value;
diff --git a/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
index d5fbf89..faa9f69 100644
--- a/java/com/google/gerrit/extensions/client/Comment.java
+++ b/java/com/google/gerrit/extensions/client/Comment.java
@@ -39,6 +39,12 @@
   public String message;
   public Boolean unresolved;
 
+  /**
+   * Hex commit SHA1 (as 40 characters hex string) of the commit of the patchset to which this
+   * comment applies.
+   */
+  public String commitId;
+
   public static class Range implements Comparable<Range> {
     private static final Comparator<Range> RANGE_COMPARATOR =
         Comparator.<Range>comparingInt(range -> range.startLine)
@@ -122,7 +128,8 @@
           && Objects.equals(inReplyTo, c.inReplyTo)
           && Objects.equals(updated, c.updated)
           && Objects.equals(message, c.message)
-          && Objects.equals(unresolved, c.unresolved);
+          && Objects.equals(unresolved, c.unresolved)
+          && Objects.equals(commitId, c.commitId);
     }
     return false;
   }
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 212f6da..c6555b9 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -102,6 +102,11 @@
     }
   }
 
+  public enum Theme {
+    DARK,
+    LIGHT
+  }
+
   public enum TimeFormat {
     /** 12-hour clock: 1:15 am, 2:13 pm */
     HHMM_12("h:mm a"),
@@ -125,6 +130,7 @@
   /** Type of download URL the user prefers to use. */
   public String downloadScheme;
 
+  public Theme theme;
   public DateFormat dateFormat;
   public TimeFormat timeFormat;
   public Boolean expandInlineDiffs;
@@ -182,6 +188,7 @@
     GeneralPreferencesInfo p = new GeneralPreferencesInfo();
     p.changesPerPage = DEFAULT_PAGESIZE;
     p.downloadScheme = null;
+    p.theme = Theme.LIGHT;
     p.dateFormat = DateFormat.STD;
     p.timeFormat = TimeFormat.HHMM_12;
     p.expandInlineDiffs = false;
diff --git a/java/com/google/gerrit/extensions/client/ListOption.java b/java/com/google/gerrit/extensions/client/ListOption.java
index 4dea42f..dba2eee 100644
--- a/java/com/google/gerrit/extensions/client/ListOption.java
+++ b/java/com/google/gerrit/extensions/client/ListOption.java
@@ -48,9 +48,9 @@
     return r;
   }
 
-  static String toHex(Set<ListChangesOption> options) {
+  static <T extends Enum<T> & ListOption> String toHex(Set<T> options) {
     int v = 0;
-    for (ListChangesOption option : options) {
+    for (T option : options) {
       v |= 1 << option.getValue();
     }
 
diff --git a/java/com/google/gerrit/extensions/common/AttentionSetEntry.java b/java/com/google/gerrit/extensions/common/AttentionSetEntry.java
deleted file mode 100644
index 356b38a..0000000
--- a/java/com/google/gerrit/extensions/common/AttentionSetEntry.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.common;
-
-import java.sql.Timestamp;
-
-/**
- * Represents a single user included in the attention set. Used in the API. See {@link
- * com.google.gerrit.entities.AttentionSetUpdate} for the internal representation.
- *
- * <p>See <a href="https://www.gerritcodereview.com/design-docs/attention-set.html">here</a> for
- * background.
- */
-public class AttentionSetEntry {
-  /** The user included in the attention set. */
-  public AccountInfo accountInfo;
-  /** The timestamp of the last update. */
-  public Timestamp lastUpdate;
-  /** The human readable reason why the user was added. */
-  public String reason;
-
-  public AttentionSetEntry(AccountInfo accountInfo, Timestamp lastUpdate, String reason) {
-    this.accountInfo = accountInfo;
-    this.lastUpdate = lastUpdate;
-    this.reason = reason;
-  }
-}
diff --git a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
new file mode 100644
index 0000000..f29d32b
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.sql.Timestamp;
+
+/**
+ * Represents a single user included in the attention set. Used in the API. See {@link
+ * com.google.gerrit.entities.AttentionSetUpdate} for the internal representation.
+ *
+ * <p>See <a href="https://www.gerritcodereview.com/design-docs/attention-set.html">here</a> for
+ * background.
+ */
+public class AttentionSetInfo {
+  /** The user included in the attention set. */
+  public AccountInfo account;
+  /** The timestamp of the last update. */
+  public Timestamp lastUpdate;
+  /** The human readable reason why the user was added. */
+  public String reason;
+
+  public AttentionSetInfo(AccountInfo account, Timestamp lastUpdate, String reason) {
+    this.account = account;
+    this.lastUpdate = lastUpdate;
+    this.reason = reason;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index dce6fd1..190a97e 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -42,7 +42,7 @@
    * for this change. Keyed by account ID. We don't use {@link
    * com.google.gerrit.entities.Account.Id} to avoid a circular dependency.
    */
-  public Map<Integer, AttentionSetEntry> attentionSet;
+  public Map<Integer, AttentionSetInfo> attentionSet;
 
   public AccountInfo assignee;
   public Collection<String> hashtags;
diff --git a/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java b/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
index 4170797..deb03b0 100644
--- a/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
+++ b/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
@@ -19,7 +19,7 @@
 import java.util.Objects;
 
 public class TestSubmitRuleInfo {
-  /** @see com.google.gerrit.common.data.SubmitRecord.Status */
+  /** @see com.google.gerrit.entities.SubmitRecord.Status */
   public String status;
 
   public String errorMessage;
diff --git a/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
index 0698735..dd226ed 100644
--- a/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.truth.ListSubject.elements;
 
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
@@ -53,6 +54,11 @@
         .thatCustom(robotCommentInfo.fixSuggestions, FixSuggestionInfoSubject.fixSuggestions());
   }
 
+  public StringSubject path() {
+    isNotNull();
+    return check("path").that(robotCommentInfo.path);
+  }
+
   public FixSuggestionInfoSubject onlyFixSuggestion() {
     return fixSuggestions().onlyElement();
   }
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 05992d4..c5f97a3 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -96,6 +96,7 @@
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
+import com.google.gerrit.server.submit.SubscriptionGraph;
 import com.google.gerrit.sshd.SshHostKeyModule;
 import com.google.gerrit.sshd.SshKeyCacheImpl;
 import com.google.gerrit.sshd.SshModule;
@@ -246,7 +247,7 @@
   }
 
   private boolean sshdOff() {
-    return new SshAddressesModule().getListenAddresses(config).isEmpty();
+    return new SshAddressesModule().provideListenAddresses(config).isEmpty();
   }
 
   private Injector createCfgInjector() {
@@ -322,6 +323,7 @@
     }
 
     modules.add(new RestApiModule());
+    modules.add(new SubscriptionGraph.Module());
     modules.add(new WorkQueue.Module());
     modules.add(new GerritInstanceNameModule());
     modules.add(
diff --git a/java/com/google/gerrit/mail/Address.java b/java/com/google/gerrit/mail/Address.java
deleted file mode 100644
index 520a4c8..0000000
--- a/java/com/google/gerrit/mail/Address.java
+++ /dev/null
@@ -1,135 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.mail;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.common.Nullable;
-
-/** Represents an address (name + email) in an email message. */
-@AutoValue
-public abstract class Address {
-  public static Address parse(String in) {
-    final int lt = in.indexOf('<');
-    final int gt = in.indexOf('>');
-    final int at = in.indexOf("@");
-    if (0 <= lt && lt < gt && lt + 1 < at && at + 1 < gt) {
-      final String email = in.substring(lt + 1, gt).trim();
-      final String name = in.substring(0, lt).trim();
-      int nameStart = 0;
-      int nameEnd = name.length();
-      if (name.startsWith("\"")) {
-        nameStart++;
-      }
-      if (name.endsWith("\"")) {
-        nameEnd--;
-      }
-      return Address.create(name.length() > 0 ? name.substring(nameStart, nameEnd) : null, email);
-    }
-
-    if (lt < 0 && gt < 0 && 0 < at && at < in.length() - 1) {
-      return Address.create(in);
-    }
-
-    throw new IllegalArgumentException("Invalid email address: " + in);
-  }
-
-  public static Address tryParse(String in) {
-    try {
-      return parse(in);
-    } catch (IllegalArgumentException e) {
-      return null;
-    }
-  }
-
-  public static Address create(String email) {
-    return create(null, email);
-  }
-
-  public static Address create(String name, String email) {
-    return new AutoValue_Address(name, email);
-  }
-
-  @Nullable
-  public abstract String name();
-
-  public abstract String email();
-
-  @Override
-  public final int hashCode() {
-    return email().hashCode();
-  }
-
-  @Override
-  public final boolean equals(Object other) {
-    if (other instanceof Address) {
-      return email().equals(((Address) other).email());
-    }
-    return false;
-  }
-
-  @Override
-  public final String toString() {
-    return toHeaderString();
-  }
-
-  public String toHeaderString() {
-    if (name() != null) {
-      return quotedPhrase(name()) + " <" + email() + ">";
-    } else if (isSimple()) {
-      return email();
-    }
-    return "<" + email() + ">";
-  }
-
-  private static final String MUST_QUOTE_EMAIL = "()<>,;:\\\"[]";
-  private static final String MUST_QUOTE_NAME = MUST_QUOTE_EMAIL + "@.";
-
-  private boolean isSimple() {
-    for (int i = 0; i < email().length(); i++) {
-      final char c = email().charAt(i);
-      if (c <= ' ' || 0x7F <= c || MUST_QUOTE_EMAIL.indexOf(c) != -1) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  private static String quotedPhrase(String name) {
-    if (EmailHeader.needsQuotedPrintable(name)) {
-      return EmailHeader.quotedPrintable(name);
-    }
-    for (int i = 0; i < name.length(); i++) {
-      final char c = name.charAt(i);
-      if (MUST_QUOTE_NAME.indexOf(c) != -1) {
-        return wrapInQuotes(name);
-      }
-    }
-    return name;
-  }
-
-  private static String wrapInQuotes(String name) {
-    final StringBuilder r = new StringBuilder(2 + name.length());
-    r.append('"');
-    for (int i = 0; i < name.length(); i++) {
-      char c = name.charAt(i);
-      if (c == '"' || c == '\\') {
-        r.append('\\');
-      }
-      r.append(c);
-    }
-    r.append('"');
-    return r.toString();
-  }
-}
diff --git a/java/com/google/gerrit/mail/EmailHeader.java b/java/com/google/gerrit/mail/EmailHeader.java
deleted file mode 100644
index 9b11101..0000000
--- a/java/com/google/gerrit/mail/EmailHeader.java
+++ /dev/null
@@ -1,233 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.mail;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.MoreObjects;
-import java.io.IOException;
-import java.io.Writer;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Locale;
-import java.util.Objects;
-
-public abstract class EmailHeader {
-  public abstract boolean isEmpty();
-
-  public abstract void write(Writer w) throws IOException;
-
-  public static class String extends EmailHeader {
-    private final java.lang.String value;
-
-    public String(java.lang.String v) {
-      value = v;
-    }
-
-    public java.lang.String getString() {
-      return value;
-    }
-
-    @Override
-    public boolean isEmpty() {
-      return value == null || value.length() == 0;
-    }
-
-    @Override
-    public void write(Writer w) throws IOException {
-      if (needsQuotedPrintable(value)) {
-        w.write(quotedPrintable(value));
-      } else {
-        w.write(value);
-      }
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(value);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      return (o instanceof String) && Objects.equals(value, ((String) o).value);
-    }
-
-    @Override
-    public java.lang.String toString() {
-      return MoreObjects.toStringHelper(this).addValue(value).toString();
-    }
-  }
-
-  public static boolean needsQuotedPrintable(java.lang.String value) {
-    for (int i = 0; i < value.length(); i++) {
-      if (value.charAt(i) < ' ' || '~' < value.charAt(i)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  static boolean needsQuotedPrintableWithinPhrase(int cp) {
-    switch (cp) {
-      case '!':
-      case '*':
-      case '+':
-      case '-':
-      case '/':
-      case '=':
-      case '_':
-        return false;
-      default:
-        if (('a' <= cp && cp <= 'z') || ('A' <= cp && cp <= 'Z') || ('0' <= cp && cp <= '9')) {
-          return false;
-        }
-        return true;
-    }
-  }
-
-  public static java.lang.String quotedPrintable(java.lang.String value) {
-    final StringBuilder r = new StringBuilder();
-
-    r.append("=?UTF-8?Q?");
-    for (int i = 0; i < value.length(); i++) {
-      final int cp = value.codePointAt(i);
-      if (cp == ' ') {
-        r.append('_');
-
-      } else if (needsQuotedPrintableWithinPhrase(cp)) {
-        byte[] buf = new java.lang.String(Character.toChars(cp)).getBytes(UTF_8);
-        for (byte b : buf) {
-          r.append('=');
-          r.append(Integer.toHexString((b >>> 4) & 0x0f).toUpperCase());
-          r.append(Integer.toHexString(b & 0x0f).toUpperCase());
-        }
-
-      } else {
-        r.append(Character.toChars(cp));
-      }
-    }
-    r.append("?=");
-
-    return r.toString();
-  }
-
-  public static class Date extends EmailHeader {
-    private final java.util.Date value;
-
-    public Date(java.util.Date v) {
-      value = v;
-    }
-
-    public java.util.Date getDate() {
-      return value;
-    }
-
-    @Override
-    public boolean isEmpty() {
-      return value == null;
-    }
-
-    @Override
-    public void write(Writer w) throws IOException {
-      final SimpleDateFormat fmt;
-      // Mon, 1 Jun 2009 10:49:44 -0700
-      fmt = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z", Locale.US);
-      w.write(fmt.format(value));
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(value);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      return (o instanceof Date) && Objects.equals(value, ((Date) o).value);
-    }
-
-    @Override
-    public java.lang.String toString() {
-      return MoreObjects.toStringHelper(this).addValue(value).toString();
-    }
-  }
-
-  public static class AddressList extends EmailHeader {
-    private final List<Address> list = new ArrayList<>();
-
-    public AddressList() {}
-
-    public AddressList(Address addr) {
-      add(addr);
-    }
-
-    public List<Address> getAddressList() {
-      return Collections.unmodifiableList(list);
-    }
-
-    public void add(Address addr) {
-      list.add(addr);
-    }
-
-    public void remove(java.lang.String email) {
-      list.removeIf(address -> address.email().equals(email));
-    }
-
-    @Override
-    public boolean isEmpty() {
-      return list.isEmpty();
-    }
-
-    @Override
-    public void write(Writer w) throws IOException {
-      int len = 8;
-      boolean firstAddress = true;
-      boolean needComma = false;
-      for (Address addr : list) {
-        java.lang.String s = addr.toHeaderString();
-        if (firstAddress) {
-          firstAddress = false;
-        } else if (72 < len + s.length()) {
-          w.write(",\r\n\t");
-          len = 8;
-          needComma = false;
-        }
-
-        if (needComma) {
-          w.write(", ");
-        }
-        w.write(s);
-        len += s.length();
-        needComma = true;
-      }
-    }
-
-    @Override
-    public int hashCode() {
-      return Objects.hashCode(list);
-    }
-
-    @Override
-    public boolean equals(Object o) {
-      return (o instanceof AddressList) && Objects.equals(list, ((AddressList) o).list);
-    }
-
-    @Override
-    public java.lang.String toString() {
-      return MoreObjects.toStringHelper(this).addValue(list).toString();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/mail/HtmlParser.java b/java/com/google/gerrit/mail/HtmlParser.java
index 7905a0a..2fc659d 100644
--- a/java/com/google/gerrit/mail/HtmlParser.java
+++ b/java/com/google/gerrit/mail/HtmlParser.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterators;
 import com.google.common.collect.PeekingIterator;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -60,7 +60,7 @@
    * @return list of MailComments parsed from the html part of the email
    */
   public static List<MailComment> parse(
-      MailMessage email, Collection<Comment> comments, String changeUrl) {
+      MailMessage email, Collection<HumanComment> comments, String changeUrl) {
     // TODO(hiesel) Add support for Gmail Mobile
     // TODO(hiesel) Add tests for other popular email clients
 
@@ -71,10 +71,10 @@
     // Gerrit as these are generally more reliable then the text captions.
     List<MailComment> parsedComments = new ArrayList<>();
     Document d = Jsoup.parse(email.htmlContent());
-    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
+    PeekingIterator<HumanComment> iter = Iterators.peekingIterator(comments.iterator());
 
     String lastEncounteredFileName = null;
-    Comment lastEncounteredComment = null;
+    HumanComment lastEncounteredComment = null;
     for (Element e : d.body().getAllElements()) {
       String elementName = e.tagName();
       boolean isInBlockQuote =
@@ -91,7 +91,7 @@
         if (!iter.hasNext()) {
           continue;
         }
-        Comment perspectiveComment = iter.peek();
+        HumanComment perspectiveComment = iter.peek();
         if (href.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
           if (lastEncounteredFileName == null
               || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
diff --git a/java/com/google/gerrit/mail/MailComment.java b/java/com/google/gerrit/mail/MailComment.java
index f024f17..3e7da10 100644
--- a/java/com/google/gerrit/mail/MailComment.java
+++ b/java/com/google/gerrit/mail/MailComment.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.Objects;
 
 /** A comment parsed from inbound email */
@@ -26,7 +26,7 @@
   }
 
   CommentType type;
-  Comment inReplyTo;
+  HumanComment inReplyTo;
   String fileName;
   String message;
   boolean isLink;
@@ -34,7 +34,7 @@
   public MailComment() {}
 
   public MailComment(
-      String message, String fileName, Comment inReplyTo, CommentType type, boolean isLink) {
+      String message, String fileName, HumanComment inReplyTo, CommentType type, boolean isLink) {
     this.message = message;
     this.fileName = fileName;
     this.inReplyTo = inReplyTo;
@@ -46,7 +46,7 @@
     return type;
   }
 
-  public Comment getInReplyTo() {
+  public HumanComment getInReplyTo() {
     return inReplyTo;
   }
 
diff --git a/java/com/google/gerrit/mail/MailMessage.java b/java/com/google/gerrit/mail/MailMessage.java
index bb83dfd..2ce6cbb 100644
--- a/java/com/google/gerrit/mail/MailMessage.java
+++ b/java/com/google/gerrit/mail/MailMessage.java
@@ -17,6 +17,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Address;
 import java.time.Instant;
 
 /**
diff --git a/java/com/google/gerrit/mail/RawMailParser.java b/java/com/google/gerrit/mail/RawMailParser.java
index 4e005a5..213cc3f 100644
--- a/java/com/google/gerrit/mail/RawMailParser.java
+++ b/java/com/google/gerrit/mail/RawMailParser.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.io.CharStreams;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.Address;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStreamReader;
diff --git a/java/com/google/gerrit/mail/TextParser.java b/java/com/google/gerrit/mail/TextParser.java
index dac3deb..a33c66f 100644
--- a/java/com/google/gerrit/mail/TextParser.java
+++ b/java/com/google/gerrit/mail/TextParser.java
@@ -18,7 +18,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterators;
 import com.google.common.collect.PeekingIterator;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -31,15 +31,15 @@
    * Parses comments from plaintext email.
    *
    * @param email @param email the message as received from the email service
-   * @param comments list of {@link Comment}s previously persisted on the change that caused the
-   *     original notification email to be sent out. Ordering must be the same as in the outbound
-   *     email
+   * @param comments list of {@link HumanComment}s previously persisted on the change that caused
+   *     the original notification email to be sent out. Ordering must be the same as in the
+   *     outbound email
    * @param changeUrl canonical change url that points to the change on this Gerrit instance.
    *     Example: https://go-review.googlesource.com/#/c/91570
    * @return list of MailComments parsed from the plaintext part of the email
    */
   public static List<MailComment> parse(
-      MailMessage email, Collection<Comment> comments, String changeUrl) {
+      MailMessage email, Collection<HumanComment> comments, String changeUrl) {
     String body = email.textContent();
     // Replace CR-LF by \n
     body = body.replace("\r\n", "\n");
@@ -62,11 +62,11 @@
       body = body.replace(doubleQuotePattern, singleQuotePattern);
     }
 
-    PeekingIterator<Comment> iter = Iterators.peekingIterator(comments.iterator());
+    PeekingIterator<HumanComment> iter = Iterators.peekingIterator(comments.iterator());
 
     MailComment currentComment = null;
     String lastEncounteredFileName = null;
-    Comment lastEncounteredComment = null;
+    HumanComment lastEncounteredComment = null;
     for (String line : Splitter.on('\n').split(body)) {
       if (line.equals(">")) {
         // Skip empty lines
@@ -89,7 +89,7 @@
         if (!iter.hasNext()) {
           continue;
         }
-        Comment perspectiveComment = iter.peek();
+        HumanComment perspectiveComment = iter.peek();
         if (line.equals(ParserUtil.filePath(changeUrl, perspectiveComment))) {
           if (lastEncounteredFileName == null
               || !lastEncounteredFileName.equals(perspectiveComment.key.filename)) {
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 034e042e..63278c1 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -107,6 +107,7 @@
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
+import com.google.gerrit.server.submit.SubscriptionGraph;
 import com.google.gerrit.sshd.SshHostKeyModule;
 import com.google.gerrit.sshd.SshKeyCacheImpl;
 import com.google.gerrit.sshd.SshModule;
@@ -380,7 +381,7 @@
   }
 
   private boolean sshdOff() {
-    return new SshAddressesModule().getListenAddresses(config).isEmpty();
+    return new SshAddressesModule().provideListenAddresses(config).isEmpty();
   }
 
   private String myVersion() {
@@ -411,6 +412,7 @@
     // work queue can get stuck waiting on index futures that will never return.
     modules.add(createIndexModule());
 
+    modules.add(new SubscriptionGraph.Module());
     modules.add(new WorkQueue.Module());
     modules.add(new StreamEventsApiListener.Module());
     modules.add(new EventBroker.Module());
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 0333942..ca28255 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -19,9 +19,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index cf208ae..effb4c6 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -17,8 +17,8 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
diff --git a/java/com/google/gerrit/pgm/init/InitLabels.java b/java/com/google/gerrit/pgm/init/InitLabels.java
index 0797cf9..3edc732 100644
--- a/java/com/google/gerrit/pgm/init/InitLabels.java
+++ b/java/com/google/gerrit/pgm/init/InitLabels.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.pgm.init;
 
-import static com.google.gerrit.common.data.LabelFunction.MAX_WITH_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.MAX_WITH_BLOCK;
 
 import com.google.gerrit.pgm.init.api.AllProjectsConfig;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index 846bb82..8a71c1c 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -108,6 +108,8 @@
     extractMailExample("AbandonedHtml.soy");
     extractMailExample("AddKey.soy");
     extractMailExample("AddKeyHtml.soy");
+    extractMailExample("AddToAttentionSet.soy");
+    extractMailExample("AddToAttentionSetHtml.soy");
     extractMailExample("ChangeFooter.soy");
     extractMailExample("ChangeFooterHtml.soy");
     extractMailExample("ChangeSubject.soy");
@@ -133,6 +135,9 @@
     extractMailExample("NewChange.soy");
     extractMailExample("NewChangeHtml.soy");
     extractMailExample("RegisterNewEmail.soy");
+    extractMailExample("RegisterNewEmailHtml.soy");
+    extractMailExample("RemoveFromAttentionSet.soy");
+    extractMailExample("RemoveFromAttentionSetHtml.soy");
     extractMailExample("ReplacePatchSet.soy");
     extractMailExample("ReplacePatchSetHtml.soy");
     extractMailExample("Restored.soy");
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index a831b8e..b8bba1c 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -18,8 +18,8 @@
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.account.FakeRealm;
 import com.google.gerrit.server.account.GroupCacheImpl;
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
+import com.google.gerrit.server.account.NonInteractiveUserGroupRobotClassifier;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
@@ -164,6 +165,7 @@
     install(SectionSortCache.module());
     install(ChangeKindCacheImpl.module());
     install(MergeabilityCacheImpl.module());
+    install(NonInteractiveUserGroupRobotClassifier.module());
     install(TagCache.module());
     install(PureRevertCache.module());
     factory(CapabilityCollection.Factory.class);
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
index 417a4ef..aa3ef89 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -23,8 +23,8 @@
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ChangeKind;
diff --git a/java/com/google/gerrit/server/ApprovalsUtil.java b/java/com/google/gerrit/server/ApprovalsUtil.java
index 0280aee..a92ce3d 100644
--- a/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -27,11 +27,11 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetInfo;
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 1c46ed6..291ba6d 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -54,6 +54,7 @@
         "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/data",
         "//java/com/google/gerrit/server/git/receive:ref_cache",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
diff --git a/java/com/google/gerrit/server/CacheRefreshExecutor.java b/java/com/google/gerrit/server/CacheRefreshExecutor.java
new file mode 100644
index 0000000..1a377c3
--- /dev/null
+++ b/java/com/google/gerrit/server/CacheRefreshExecutor.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * Marker on the global {@link java.util.concurrent.ThreadPoolExecutor} used to refresh outdated
+ * values in caches.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface CacheRefreshExecutor {}
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index dd48b93..32edadb 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -35,34 +35,38 @@
 @Singleton
 public class ChangeMessagesUtil {
   public static final String AUTOGENERATED_TAG_PREFIX = "autogenerated:";
+  public static final String AUTOGENERATED_BY_GERRIT_TAG_PREFIX =
+      AUTOGENERATED_TAG_PREFIX + "gerrit:";
 
-  public static final String TAG_ABANDON = AUTOGENERATED_TAG_PREFIX + "gerrit:abandon";
+  public static final String TAG_ABANDON = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "abandon";
   public static final String TAG_CHERRY_PICK_CHANGE =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:cherryPickChange";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "cherryPickChange";
   public static final String TAG_DELETE_ASSIGNEE =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:deleteAssignee";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteAssignee";
   public static final String TAG_DELETE_REVIEWER =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:deleteReviewer";
-  public static final String TAG_DELETE_VOTE = AUTOGENERATED_TAG_PREFIX + "gerrit:deleteVote";
-  public static final String TAG_MERGED = AUTOGENERATED_TAG_PREFIX + "gerrit:merged";
-  public static final String TAG_MOVE = AUTOGENERATED_TAG_PREFIX + "gerrit:move";
-  public static final String TAG_RESTORE = AUTOGENERATED_TAG_PREFIX + "gerrit:restore";
-  public static final String TAG_REVERT = AUTOGENERATED_TAG_PREFIX + "gerrit:revert";
-  public static final String TAG_SET_ASSIGNEE = AUTOGENERATED_TAG_PREFIX + "gerrit:setAssignee";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteReviewer";
+  public static final String TAG_DELETE_VOTE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteVote";
+  public static final String TAG_MERGED = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "merged";
+  public static final String TAG_MOVE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "move";
+  public static final String TAG_RESTORE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "restore";
+  public static final String TAG_REVERT = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "revert";
+  public static final String TAG_SET_ASSIGNEE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setAssignee";
   public static final String TAG_UPDATE_ATTENTION_SET =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:updateAttentionSet";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "updateAttentionSet";
   public static final String TAG_SET_DESCRIPTION =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:setPsDescription";
-  public static final String TAG_SET_HASHTAGS = AUTOGENERATED_TAG_PREFIX + "gerrit:setHashtag";
-  public static final String TAG_SET_PRIVATE = AUTOGENERATED_TAG_PREFIX + "gerrit:setPrivate";
-  public static final String TAG_SET_READY = AUTOGENERATED_TAG_PREFIX + "gerrit:setReadyForReview";
-  public static final String TAG_SET_TOPIC = AUTOGENERATED_TAG_PREFIX + "gerrit:setTopic";
-  public static final String TAG_SET_WIP = AUTOGENERATED_TAG_PREFIX + "gerrit:setWorkInProgress";
-  public static final String TAG_UNSET_PRIVATE = AUTOGENERATED_TAG_PREFIX + "gerrit:unsetPrivate";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setPsDescription";
+  public static final String TAG_SET_HASHTAGS = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setHashtag";
+  public static final String TAG_SET_PRIVATE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setPrivate";
+  public static final String TAG_SET_READY =
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setReadyForReview";
+  public static final String TAG_SET_TOPIC = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setTopic";
+  public static final String TAG_SET_WIP = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setWorkInProgress";
+  public static final String TAG_UNSET_PRIVATE =
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "unsetPrivate";
   public static final String TAG_UPLOADED_PATCH_SET =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:newPatchSet";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "newPatchSet";
   public static final String TAG_UPLOADED_WIP_PATCH_SET =
-      AUTOGENERATED_TAG_PREFIX + "gerrit:newWipPatchSet";
+      AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "newWipPatchSet";
 
   public static ChangeMessage newMessage(ChangeContext ctx, String body, @Nullable String tag) {
     return newMessage(ctx.getChange().currentPatchSetId(), ctx.getUser(), ctx.getWhen(), body, tag);
@@ -122,6 +126,10 @@
     return tag != null && tag.startsWith(AUTOGENERATED_TAG_PREFIX);
   }
 
+  public static boolean isAutogeneratedByGerrit(@Nullable String tag) {
+    return tag != null && tag.startsWith(AUTOGENERATED_BY_GERRIT_TAG_PREFIX);
+  }
+
   public static ChangeMessageInfo createChangeMessageInfo(
       ChangeMessage message, AccountLoader accountLoader) {
     PatchSet.Id patchNum = message.getPatchSetId();
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index e9ba72d..0b3d1cb 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
@@ -116,7 +117,7 @@
     this.serverId = serverId;
   }
 
-  public Comment newComment(
+  public HumanComment newHumanComment(
       ChangeContext ctx,
       String path,
       PatchSet.Id psId,
@@ -132,15 +133,15 @@
       } else {
         // Inherit unresolved value from inReplyTo comment if not specified.
         Comment.Key key = new Comment.Key(parentUuid, path, psId.get());
-        Optional<Comment> parent = getPublished(ctx.getNotes(), key);
+        Optional<HumanComment> parent = getPublishedHumanComment(ctx.getNotes(), key);
         if (!parent.isPresent()) {
           throw new UnprocessableEntityException("Invalid parentUuid supplied for comment");
         }
         unresolved = parent.get().unresolved;
       }
     }
-    Comment c =
-        new Comment(
+    HumanComment c =
+        new HumanComment(
             new Comment.Key(ChangeUtil.messageUuid(), path, psId.get()),
             ctx.getUser().getAccountId(),
             ctx.getWhen(),
@@ -175,19 +176,21 @@
     return c;
   }
 
-  public Optional<Comment> getPublished(ChangeNotes notes, Comment.Key key) {
-    return publishedByChange(notes).stream().filter(c -> key.equals(c.key)).findFirst();
+  public Optional<HumanComment> getPublishedHumanComment(ChangeNotes notes, Comment.Key key) {
+    return publishedHumanCommentsByChange(notes).stream()
+        .filter(c -> key.equals(c.key))
+        .findFirst();
   }
 
-  public Optional<Comment> getDraft(ChangeNotes notes, IdentifiedUser user, Comment.Key key) {
+  public Optional<HumanComment> getDraft(ChangeNotes notes, IdentifiedUser user, Comment.Key key) {
     return draftByChangeAuthor(notes, user.getAccountId()).stream()
         .filter(c -> key.equals(c.key))
         .findFirst();
   }
 
-  public List<Comment> publishedByChange(ChangeNotes notes) {
+  public List<HumanComment> publishedHumanCommentsByChange(ChangeNotes notes) {
     notes.load();
-    return sort(Lists.newArrayList(notes.getComments().values()));
+    return sort(Lists.newArrayList(notes.getHumanComments().values()));
   }
 
   public List<RobotComment> robotCommentsByChange(ChangeNotes notes) {
@@ -195,8 +198,8 @@
     return sort(Lists.newArrayList(notes.getRobotComments().values()));
   }
 
-  public List<Comment> draftByChange(ChangeNotes notes) {
-    List<Comment> comments = new ArrayList<>();
+  public List<HumanComment> draftByChange(ChangeNotes notes) {
+    List<HumanComment> comments = new ArrayList<>();
     for (Ref ref : getDraftRefs(notes.getChangeId())) {
       Account.Id account = Account.Id.fromRefSuffix(ref.getName());
       if (account != null) {
@@ -206,8 +209,8 @@
     return sort(comments);
   }
 
-  public List<Comment> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
-    List<Comment> comments = new ArrayList<>();
+  public List<HumanComment> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
+    List<HumanComment> comments = new ArrayList<>();
     comments.addAll(publishedByPatchSet(notes, psId));
 
     for (Ref ref : getDraftRefs(notes.getChangeId())) {
@@ -219,13 +222,13 @@
     return sort(comments);
   }
 
-  public List<Comment> publishedByChangeFile(ChangeNotes notes, String file) {
-    return commentsOnFile(notes.load().getComments().values(), file);
+  public List<HumanComment> publishedByChangeFile(ChangeNotes notes, String file) {
+    return commentsOnFile(notes.load().getHumanComments().values(), file);
   }
 
-  public List<Comment> publishedByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
+  public List<HumanComment> publishedByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
     return removeCommentsOnAncestorOfCommitMessage(
-        commentsOnPatchSet(notes.load().getComments().values(), psId));
+        commentsOnPatchSet(notes.load().getHumanComments().values(), psId));
   }
 
   public List<RobotComment> robotCommentsByPatchSet(ChangeNotes notes, PatchSet.Id psId) {
@@ -242,7 +245,9 @@
    * @param changeMessages list of change messages
    */
   public static void linkCommentsToChangeMessages(
-      List<? extends CommentInfo> comments, List<ChangeMessage> changeMessages) {
+      List<? extends CommentInfo> comments,
+      List<ChangeMessage> changeMessages,
+      boolean skipAutoGeneratedMessages) {
     ArrayList<ChangeMessage> sortedChangeMessages =
         changeMessages.stream()
             .sorted(comparing(ChangeMessage::getWrittenOn))
@@ -257,7 +262,7 @@
       // message in timestamp
       while (cmItr < sortedChangeMessages.size()) {
         ChangeMessage cm = sortedChangeMessages.get(cmItr);
-        if (isAfter(comment, cm) || skipChangeMessage(cm)) {
+        if (isAfter(comment, cm) || (skipAutoGeneratedMessages && isAutoGenerated(cm))) {
           cmItr += 1;
         } else {
           break;
@@ -269,8 +274,10 @@
     }
   }
 
-  private static boolean skipChangeMessage(ChangeMessage cm) {
-    return ChangeMessagesUtil.isAutogenerated(cm.getTag());
+  private static boolean isAutoGenerated(ChangeMessage cm) {
+    // Ignore Gerrit auto-generated messages, allowing to link against human change messages that
+    // have an auto-generated tag
+    return ChangeMessagesUtil.isAutogeneratedByGerrit(cm.getTag());
   }
 
   private static boolean isAfter(CommentInfo c, ChangeMessage cm) {
@@ -284,29 +291,31 @@
    * auto-merge was done. From that time there may still be comments on the auto-merge commit
    * message and those we want to filter out.
    */
-  private List<Comment> removeCommentsOnAncestorOfCommitMessage(List<Comment> list) {
+  private List<HumanComment> removeCommentsOnAncestorOfCommitMessage(List<HumanComment> list) {
     return list.stream()
         .filter(c -> c.side != 0 || !Patch.COMMIT_MSG.equals(c.key.filename))
         .collect(toList());
   }
 
-  public List<Comment> draftByPatchSetAuthor(
+  public List<HumanComment> draftByPatchSetAuthor(
       PatchSet.Id psId, Account.Id author, ChangeNotes notes) {
     return commentsOnPatchSet(notes.load().getDraftComments(author).values(), psId);
   }
 
-  public List<Comment> draftByChangeFileAuthor(ChangeNotes notes, String file, Account.Id author) {
+  public List<HumanComment> draftByChangeFileAuthor(
+      ChangeNotes notes, String file, Account.Id author) {
     return commentsOnFile(notes.load().getDraftComments(author).values(), file);
   }
 
-  public List<Comment> draftByChangeAuthor(ChangeNotes notes, Account.Id author) {
-    List<Comment> comments = new ArrayList<>();
+  public List<HumanComment> draftByChangeAuthor(ChangeNotes notes, Account.Id author) {
+    List<HumanComment> comments = new ArrayList<>();
     comments.addAll(notes.getDraftComments(author).values());
     return sort(comments);
   }
 
-  public void putComments(ChangeUpdate update, Comment.Status status, Iterable<Comment> comments) {
-    for (Comment c : comments) {
+  public void putHumanComments(
+      ChangeUpdate update, HumanComment.Status status, Iterable<HumanComment> comments) {
+    for (HumanComment c : comments) {
       update.putComment(status, c);
     }
   }
@@ -317,8 +326,8 @@
     }
   }
 
-  public void deleteComments(ChangeUpdate update, Iterable<Comment> comments) {
-    for (Comment c : comments) {
+  public void deleteHumanComments(ChangeUpdate update, Iterable<HumanComment> comments) {
+    for (HumanComment c : comments) {
       update.deleteComment(c);
     }
   }
@@ -328,9 +337,10 @@
     update.deleteCommentByRewritingHistory(commentKey.uuid, newMessage);
   }
 
-  private static List<Comment> commentsOnFile(Collection<Comment> allComments, String file) {
-    List<Comment> result = new ArrayList<>(allComments.size());
-    for (Comment c : allComments) {
+  private static List<HumanComment> commentsOnFile(
+      Collection<HumanComment> allComments, String file) {
+    List<HumanComment> result = new ArrayList<>(allComments.size());
+    for (HumanComment c : allComments) {
       String currentFilename = c.key.filename;
       if (currentFilename.equals(file)) {
         result.add(c);
diff --git a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
index 17313e4..6c76de7 100644
--- a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
+++ b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
@@ -14,13 +14,11 @@
 
 package com.google.gerrit.server;
 
-import static java.util.stream.Collectors.toList;
-
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -34,6 +32,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.HashSet;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -85,10 +84,10 @@
         new HashSet<>(allProjectsState.getCapabilityCollection().createGroup);
     Set<PermissionRule> createGroupsRef = new HashSet<>();
 
-    AccessSection allUsersCreateGroupAccessSection =
+    Optional<AccessSection> allUsersCreateGroupAccessSection =
         allUsersState.getConfig().getAccessSection(RefNames.REFS_GROUPS + "*");
-    if (allUsersCreateGroupAccessSection != null) {
-      Permission create = allUsersCreateGroupAccessSection.getPermission(Permission.CREATE);
+    if (allUsersCreateGroupAccessSection.isPresent()) {
+      Permission create = allUsersCreateGroupAccessSection.get().getPermission(Permission.CREATE);
       if (create != null && create.getRules() != null) {
         createGroupsRef.addAll(create.getRules());
       }
@@ -101,23 +100,25 @@
 
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsers)) {
       ProjectConfig config = projectConfigFactory.read(md);
-      AccessSection createGroupAccessSection =
-          config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
-      if (createGroupsGlobal.isEmpty()) {
-        createGroupAccessSection.setPermissions(
-            createGroupAccessSection.getPermissions().stream()
-                .filter(p -> !Permission.CREATE.equals(p.getName()))
-                .collect(toList()));
-        config.replace(createGroupAccessSection);
-      } else {
-        // The create permission is managed by Gerrit at this point only so there is no concern of
-        // overwriting user-defined permissions here.
-        Permission createGroupPermission = new Permission(Permission.CREATE);
-        createGroupAccessSection.remove(createGroupPermission);
-        createGroupAccessSection.addPermission(createGroupPermission);
-        createGroupsGlobal.forEach(createGroupPermission::add);
-        config.replace(createGroupAccessSection);
-      }
+      config.upsertAccessSection(
+          RefNames.REFS_GROUPS + "*",
+          refsGroupsAccessSectionBuilder -> {
+            if (createGroupsGlobal.isEmpty()) {
+              refsGroupsAccessSectionBuilder.modifyPermissions(
+                  permissions -> {
+                    permissions.removeIf(p -> Permission.CREATE.equals(p.getName()));
+                  });
+            } else {
+              // The create permission is managed by Gerrit at this point only so there is no
+              // concern of overwriting user-defined permissions here.
+              Permission.Builder createGroupPermission = Permission.builder(Permission.CREATE);
+              refsGroupsAccessSectionBuilder.remove(createGroupPermission);
+              refsGroupsAccessSectionBuilder.addPermission(createGroupPermission);
+              createGroupsGlobal.stream()
+                  .map(p -> p.toBuilder())
+                  .forEach(createGroupPermission::add);
+            }
+          });
 
       config.commit(md);
       projectCache.evict(config.getProject());
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index aeef2b6..005ae3b 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -22,9 +22,9 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index 3d34d6b..658af15 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -20,8 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.Comment.Status;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
@@ -60,7 +59,7 @@
   public void publish(
       ChangeContext ctx,
       ChangeUpdate changeUpdate,
-      Collection<Comment> draftComments,
+      Collection<HumanComment> draftComments,
       @Nullable String tag) {
     ChangeNotes notes = ctx.getNotes();
     checkArgument(notes != null);
@@ -70,8 +69,8 @@
 
     Map<PatchSet.Id, PatchSet> patchSets =
         psUtil.getAsMap(notes, draftComments.stream().map(d -> psId(notes, d)).collect(toSet()));
-    Set<Comment> commentsToPublish = new HashSet<>();
-    for (Comment draftComment : draftComments) {
+    Set<HumanComment> commentsToPublish = new HashSet<>();
+    for (HumanComment draftComment : draftComments) {
       PatchSet.Id psIdOfDraftComment = psId(notes, draftComment);
       PatchSet ps = patchSets.get(psIdOfDraftComment);
       if (ps == null) {
@@ -109,10 +108,10 @@
       }
       commentsToPublish.add(draftComment);
     }
-    commentsUtil.putComments(changeUpdate, Status.PUBLISHED, commentsToPublish);
+    commentsUtil.putHumanComments(changeUpdate, HumanComment.Status.PUBLISHED, commentsToPublish);
   }
 
-  private static PatchSet.Id psId(ChangeNotes notes, Comment c) {
+  private static PatchSet.Id psId(ChangeNotes notes, HumanComment c) {
     return PatchSet.id(notes.getChangeId(), c.key.patchSetId);
   }
 
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index df57629..358ce92 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -16,9 +16,10 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.change.EmailReviewComments;
@@ -31,6 +32,7 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -56,7 +58,7 @@
   private final PatchSet.Id psId;
   private final PublishCommentUtil publishCommentUtil;
 
-  private List<Comment> comments = new ArrayList<>();
+  private List<HumanComment> comments = new ArrayList<>();
   private ChangeMessage message;
   private IdentifiedUser user;
 
@@ -114,7 +116,16 @@
     PatchSet ps = psUtil.get(changeNotes, psId);
     NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
     if (notify.shouldNotify()) {
-      email.create(notify, changeNotes, ps, user, message, comments, null, labelDelta).sendAsync();
+      RepoView repoView;
+      try {
+        repoView = ctx.getRepoView();
+      } catch (IOException ex) {
+        throw new StorageException(
+            String.format("Repository %s not found", ctx.getProject().get()), ex);
+      }
+      email
+          .create(notify, changeNotes, ps, user, message, comments, null, labelDelta, repoView)
+          .sendAsync();
     }
     commentAdded.fire(
         changeNotes.getChange(),
diff --git a/java/com/google/gerrit/server/ReviewerByEmailSet.java b/java/com/google/gerrit/server/ReviewerByEmailSet.java
index caae45e..4a317c3 100644
--- a/java/com/google/gerrit/server/ReviewerByEmailSet.java
+++ b/java/com/google/gerrit/server/ReviewerByEmailSet.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Table;
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import java.sql.Timestamp;
 
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 76d9471..e95bc1c 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -22,9 +22,9 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllUsersName;
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index a6143f4..6a48cce 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -17,9 +17,10 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountsSection;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -29,7 +30,6 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.AccountsSection;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/java/com/google/gerrit/server/account/AccountLimits.java b/java/com/google/gerrit/server/account/AccountLimits.java
index 4d1d1b8..1845f5b 100644
--- a/java/com/google/gerrit/server/account/AccountLimits.java
+++ b/java/com/google/gerrit/server/account/AccountLimits.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.index.query.QueryProcessor;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.QueueProvider;
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 7a5b1aa..47c6efb 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -24,11 +24,11 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AccountFieldName;
@@ -328,6 +328,7 @@
               .getAllProjects()
               .getConfig()
               .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+              .orElseThrow(() -> new IllegalStateException("access section does not exist"))
               .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
 
       AccountGroup.UUID adminGroupUuid = admin.getRules().get(0).getGroup().getUUID();
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index 1e9914d..b7a54f4 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -20,10 +20,10 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
diff --git a/java/com/google/gerrit/server/account/CachedAccountDetails.java b/java/com/google/gerrit/server/account/CachedAccountDetails.java
index 2eb5770..f23a766 100644
--- a/java/com/google/gerrit/server/account/CachedAccountDetails.java
+++ b/java/com/google/gerrit/server/account/CachedAccountDetails.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache;
@@ -80,7 +81,7 @@
   abstract Account account();
 
   /** Projects that the user has configured to watch. */
-  abstract ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<ProjectWatches.NotifyType>>
+  abstract ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
       projectWatches();
 
   /** Preferences that this user has. Serialized as Git-config style string. */
@@ -88,7 +89,7 @@
 
   static CachedAccountDetails create(
       Account account,
-      ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<ProjectWatches.NotifyType>>
+      ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
           projectWatches,
       CachedPreferences preferences) {
     return new AutoValue_CachedAccountDetails(account, projectWatches, preferences);
@@ -115,8 +116,8 @@
               .setMetaId(Strings.nullToEmpty(account.metaId()));
       serialized.setAccount(accountProto);
 
-      for (Map.Entry<ProjectWatches.ProjectWatchKey, ImmutableSet<ProjectWatches.NotifyType>>
-          watch : cachedAccountDetails.projectWatches().entrySet()) {
+      for (Map.Entry<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> watch :
+          cachedAccountDetails.projectWatches().entrySet()) {
         Cache.ProjectWatchProto.Builder proto =
             Cache.ProjectWatchProto.newBuilder().setProject(watch.getKey().project().get());
         if (watch.getKey().filter() != null) {
@@ -127,9 +128,7 @@
             .forEach(
                 n ->
                     proto.addNotifyType(
-                        Enums.stringConverter(ProjectWatches.NotifyType.class)
-                            .reverse()
-                            .convert(n)));
+                        Enums.stringConverter(NotifyConfig.NotifyType.class).reverse().convert(n)));
         serialized.addProjectWatchProto(proto);
       }
 
@@ -153,7 +152,7 @@
               .setMetaId(Strings.emptyToNull(proto.getAccount().getMetaId()))
               .build();
 
-      ImmutableMap.Builder<ProjectWatches.ProjectWatchKey, ImmutableSet<ProjectWatches.NotifyType>>
+      ImmutableMap.Builder<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
           projectWatches = ImmutableMap.builder();
       proto.getProjectWatchProtoList().stream()
           .forEach(
@@ -162,9 +161,7 @@
                       ProjectWatches.ProjectWatchKey.create(
                           Project.nameKey(p.getProject()), p.getFilter()),
                       p.getNotifyTypeList().stream()
-                          .map(
-                              e ->
-                                  Enums.stringConverter(ProjectWatches.NotifyType.class).convert(e))
+                          .map(e -> Enums.stringConverter(NotifyConfig.NotifyType.class).convert(e))
                           .collect(toImmutableSet())));
 
       return CachedAccountDetails.create(
diff --git a/java/com/google/gerrit/server/account/CapabilityCollection.java b/java/com/google/gerrit/server/account/CapabilityCollection.java
index b52d616..7621929 100644
--- a/java/com/google/gerrit/server/account/CapabilityCollection.java
+++ b/java/com/google/gerrit/server/account/CapabilityCollection.java
@@ -18,12 +18,12 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.server.config.AdministrateServerGroups;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
@@ -60,9 +60,8 @@
     this.systemGroupBackend = systemGroupBackend;
 
     if (section == null) {
-      section = new AccessSection(AccessSection.GLOBAL_CAPABILITIES);
+      section = AccessSection.create(AccessSection.GLOBAL_CAPABILITIES);
     }
-
     Map<String, List<PermissionRule>> tmp = new HashMap<>();
     for (Permission permission : section.getPermissions()) {
       for (PermissionRule rule : permission.getRules()) {
@@ -111,7 +110,7 @@
 
     List<PermissionRule> r = new ArrayList<>(admins.size() + rules.size());
     for (GroupReference g : admins) {
-      r.add(new PermissionRule(g));
+      r.add(PermissionRule.create(g));
     }
     for (PermissionRule rule : rules) {
       if (!admins.contains(rule.getGroup())) {
@@ -142,9 +141,9 @@
     if (doesNotDeclare(section, capName)) {
       PermissionRange.WithDefaults range = GlobalCapability.getRange(capName);
       if (range != null) {
-        PermissionRule rule = new PermissionRule(group);
+        PermissionRule.Builder rule = PermissionRule.builder(group);
         rule.setRange(range.getDefaultMin(), range.getDefaultMax());
-        out.put(capName, Collections.singletonList(rule));
+        out.put(capName, Collections.singletonList(rule.build()));
       }
     }
   }
diff --git a/java/com/google/gerrit/server/account/GroupBackend.java b/java/com/google/gerrit/server/account/GroupBackend.java
index 3a874bb..545da6e 100644
--- a/java/com/google/gerrit/server/account/GroupBackend.java
+++ b/java/com/google/gerrit/server/account/GroupBackend.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.project.ProjectState;
diff --git a/java/com/google/gerrit/server/account/GroupBackends.java b/java/com/google/gerrit/server/account/GroupBackends.java
index 1b15512..26b3a82 100644
--- a/java/com/google/gerrit/server/account/GroupBackends.java
+++ b/java/com/google/gerrit/server/account/GroupBackends.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.project.ProjectState;
 import java.util.Collection;
 import java.util.Comparator;
diff --git a/java/com/google/gerrit/server/account/GroupControl.java b/java/com/google/gerrit/server/account/GroupControl.java
index 64fd7c6..d42db60 100644
--- a/java/com/google/gerrit/server/account/GroupControl.java
+++ b/java/com/google/gerrit/server/account/GroupControl.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
diff --git a/java/com/google/gerrit/server/account/InternalAccountUpdate.java b/java/com/google/gerrit/server/account/InternalAccountUpdate.java
index bfbe917..4f9202f 100644
--- a/java/com/google/gerrit/server/account/InternalAccountUpdate.java
+++ b/java/com/google/gerrit/server/account/InternalAccountUpdate.java
@@ -20,10 +20,10 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
diff --git a/java/com/google/gerrit/server/account/InternalGroupBackend.java b/java/com/google/gerrit/server/account/InternalGroupBackend.java
index ddd3da2..c520c96 100644
--- a/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -17,9 +17,9 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
diff --git a/java/com/google/gerrit/server/account/NonInteractiveUserGroupRobotClassifier.java b/java/com/google/gerrit/server/account/NonInteractiveUserGroupRobotClassifier.java
new file mode 100644
index 0000000..03df963
--- /dev/null
+++ b/java/com/google/gerrit/server/account/NonInteractiveUserGroupRobotClassifier.java
@@ -0,0 +1,63 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import com.google.inject.Scopes;
+import com.google.inject.Singleton;
+import java.util.Optional;
+import javax.inject.Inject;
+
+/**
+ * An implementation of {@link RobotClassifier} that will consider a user to be a robot if they are
+ * a member in the {@code Non-Interactive Users} group.
+ */
+@Singleton
+public class NonInteractiveUserGroupRobotClassifier implements RobotClassifier {
+  public static Module module() {
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        bind(RobotClassifier.class)
+            .to(NonInteractiveUserGroupRobotClassifier.class)
+            .in(Scopes.SINGLETON);
+      }
+    };
+  }
+
+  private final GroupCache groupCache;
+
+  @Inject
+  NonInteractiveUserGroupRobotClassifier(GroupCache groupCache) {
+    this.groupCache = groupCache;
+  }
+
+  @Override
+  public boolean isRobot(Account.Id user) {
+    // TODO(hiesel, brohlfs, paiking): This is just an interim solution until we have figured out a
+    // long-term solution.
+    // Discussion is at: https://gerrit-review.googlesource.com/c/gerrit/+/274854
+    Optional<InternalGroup> maybeGroup =
+        groupCache.get(AccountGroup.nameKey("Non-Interactive Users"));
+    if (maybeGroup.isPresent()) {
+      return maybeGroup.get().getMembers().stream().anyMatch(member -> user.equals(member));
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/account/ProjectWatches.java b/java/com/google/gerrit/server/account/ProjectWatches.java
index cf63346..42137c1 100644
--- a/java/com/google/gerrit/server/account/ProjectWatches.java
+++ b/java/com/google/gerrit/server/account/ProjectWatches.java
@@ -31,6 +31,7 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
 import java.util.ArrayList;
@@ -89,17 +90,6 @@
     public abstract @Nullable String filter();
   }
 
-  public enum NotifyType {
-    // sort by name, except 'ALL' which should stay last
-    ABANDONED_CHANGES,
-    ALL_COMMENTS,
-    NEW_CHANGES,
-    NEW_PATCHSETS,
-    SUBMITTED_CHANGES,
-
-    ALL
-  }
-
   public static final String FILTER_ALL = "*";
 
   public static final String WATCH_CONFIG = "watch.config";
@@ -110,7 +100,7 @@
   private final Config cfg;
   private final ValidationError.Sink validationErrorSink;
 
-  private ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches;
+  private ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> projectWatches;
 
   ProjectWatches(Account.Id accountId, Config cfg, ValidationError.Sink validationErrorSink) {
     this.accountId = requireNonNull(accountId, "accountId");
@@ -118,7 +108,7 @@
     this.validationErrorSink = requireNonNull(validationErrorSink, "validationErrorSink");
   }
 
-  public ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> getProjectWatches() {
+  public ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> getProjectWatches() {
     if (projectWatches == null) {
       parse();
     }
@@ -152,9 +142,9 @@
    * @return the parsed project watches
    */
   @VisibleForTesting
-  public static ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> parse(
+  public static ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> parse(
       Account.Id accountId, Config cfg, ValidationError.Sink validationErrorSink) {
-    Map<ProjectWatchKey, Set<NotifyType>> projectWatches = new HashMap<>();
+    Map<ProjectWatchKey, Set<NotifyConfig.NotifyType>> projectWatches = new HashMap<>();
     for (String projectName : cfg.getSubsections(PROJECT)) {
       String[] notifyValues = cfg.getStringList(PROJECT, projectName, KEY_NOTIFY);
       for (String nv : notifyValues) {
@@ -171,7 +161,7 @@
         ProjectWatchKey key =
             ProjectWatchKey.create(Project.nameKey(projectName), notifyValue.filter());
         if (!projectWatches.containsKey(key)) {
-          projectWatches.put(key, EnumSet.noneOf(NotifyType.class));
+          projectWatches.put(key, EnumSet.noneOf(NotifyConfig.NotifyType.class));
         }
         projectWatches.get(key).addAll(notifyValue.notifyTypes());
       }
@@ -179,7 +169,7 @@
     return immutableCopyOf(projectWatches);
   }
 
-  public Config save(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+  public Config save(Map<ProjectWatchKey, Set<NotifyConfig.NotifyType>> projectWatches) {
     this.projectWatches = immutableCopyOf(projectWatches);
 
     for (String projectName : cfg.getSubsections(PROJECT)) {
@@ -188,7 +178,7 @@
 
     ListMultimap<String, String> notifyValuesByProject =
         MultimapBuilder.hashKeys().arrayListValues().build();
-    for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e : projectWatches.entrySet()) {
+    for (Map.Entry<ProjectWatchKey, Set<NotifyConfig.NotifyType>> e : projectWatches.entrySet()) {
       NotifyValue notifyValue = NotifyValue.create(e.getKey().filter(), e.getValue());
       notifyValuesByProject.put(e.getKey().project().get(), notifyValue.toString());
     }
@@ -200,9 +190,10 @@
     return cfg;
   }
 
-  private static ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> immutableCopyOf(
-      Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
-    ImmutableMap.Builder<ProjectWatchKey, ImmutableSet<NotifyType>> b = ImmutableMap.builder();
+  private static ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
+      immutableCopyOf(Map<ProjectWatchKey, Set<NotifyConfig.NotifyType>> projectWatches) {
+    ImmutableMap.Builder<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> b =
+        ImmutableMap.builder();
     projectWatches.entrySet().stream()
         .forEach(e -> b.put(e.getKey(), ImmutableSet.copyOf(e.getValue())));
     return b.build();
@@ -219,7 +210,7 @@
       int i = notifyValue.lastIndexOf('[');
       if (i < 0 || notifyValue.charAt(notifyValue.length() - 1) != ']') {
         validationErrorSink.error(
-            new ValidationError(
+            ValidationError.create(
                 WATCH_CONFIG,
                 String.format(
                     "Invalid project watch of account %d for project %s: %s",
@@ -231,16 +222,17 @@
         filter = null;
       }
 
-      Set<NotifyType> notifyTypes = EnumSet.noneOf(NotifyType.class);
+      Set<NotifyConfig.NotifyType> notifyTypes = EnumSet.noneOf(NotifyConfig.NotifyType.class);
       if (i + 1 < notifyValue.length() - 2) {
         for (String nt :
             Splitter.on(',')
                 .trimResults()
                 .splitToList(notifyValue.substring(i + 1, notifyValue.length() - 1))) {
-          NotifyType notifyType = Enums.getIfPresent(NotifyType.class, nt).orNull();
+          NotifyConfig.NotifyType notifyType =
+              Enums.getIfPresent(NotifyConfig.NotifyType.class, nt).orNull();
           if (notifyType == null) {
             validationErrorSink.error(
-                new ValidationError(
+                ValidationError.create(
                     WATCH_CONFIG,
                     String.format(
                         "Invalid notify type %s in project watch "
@@ -254,18 +246,19 @@
       return create(filter, notifyTypes);
     }
 
-    public static NotifyValue create(@Nullable String filter, Collection<NotifyType> notifyTypes) {
+    public static NotifyValue create(
+        @Nullable String filter, Collection<NotifyConfig.NotifyType> notifyTypes) {
       return new AutoValue_ProjectWatches_NotifyValue(
           Strings.emptyToNull(filter), Sets.immutableEnumSet(notifyTypes));
     }
 
     public abstract @Nullable String filter();
 
-    public abstract ImmutableSet<NotifyType> notifyTypes();
+    public abstract ImmutableSet<NotifyConfig.NotifyType> notifyTypes();
 
     @Override
     public final String toString() {
-      List<NotifyType> notifyTypes = new ArrayList<>(notifyTypes());
+      List<NotifyConfig.NotifyType> notifyTypes = new ArrayList<>(notifyTypes());
       StringBuilder notifyValue = new StringBuilder();
       notifyValue.append(firstNonNull(filter(), FILTER_ALL)).append(" [");
       Joiner.on(", ").appendTo(notifyValue, notifyTypes);
diff --git a/java/com/google/gerrit/server/account/RobotClassifier.java b/java/com/google/gerrit/server/account/RobotClassifier.java
new file mode 100644
index 0000000..5a51bf9
--- /dev/null
+++ b/java/com/google/gerrit/server/account/RobotClassifier.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.entities.Account;
+
+public interface RobotClassifier {
+  /** Returns {@code true} if the given user is considered a {@code robot} user. */
+  boolean isRobot(Account.Id user);
+
+  /** An instance that can be used for testing and will consider no user to be a robot. */
+  class NoOp implements RobotClassifier {
+    @Override
+    public boolean isRobot(Account.Id user) {
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/StoredPreferences.java b/java/com/google/gerrit/server/account/StoredPreferences.java
index 1b3ff40..79be9e5 100644
--- a/java/com/google/gerrit/server/account/StoredPreferences.java
+++ b/java/com/google/gerrit/server/account/StoredPreferences.java
@@ -15,19 +15,13 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.config.ConfigUtil.loadSection;
-import static com.google.gerrit.server.config.ConfigUtil.skipField;
 import static com.google.gerrit.server.config.ConfigUtil.storeSection;
-import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
 import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
 import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -36,13 +30,12 @@
 import com.google.gerrit.extensions.client.MenuItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.PreferencesParserUtil;
 import com.google.gerrit.server.config.VersionedDefaultPreferences;
 import com.google.gerrit.server.git.UserConfigSections;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import java.io.IOException;
-import java.lang.reflect.Field;
-import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -76,8 +69,6 @@
  * <p>The preferences are lazily parsed.
  */
 public class StoredPreferences {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public static final String PREFERENCES_CONFIG = "preferences.config";
 
   private final Account.Id accountId;
@@ -141,7 +132,7 @@
           UserConfigSections.GENERAL,
           null,
           mergedGeneralPreferencesInput,
-          parseDefaultGeneralPreferences(defaultCfg, null));
+          PreferencesParserUtil.parseDefaultGeneralPreferences(defaultCfg, null));
       setChangeTable(cfg, mergedGeneralPreferencesInput.changeTable);
       setMy(cfg, mergedGeneralPreferencesInput.my);
 
@@ -158,7 +149,7 @@
           UserConfigSections.DIFF,
           null,
           mergedDiffPreferencesInput,
-          parseDefaultDiffPreferences(defaultCfg, null));
+          PreferencesParserUtil.parseDefaultDiffPreferences(defaultCfg, null));
 
       // evict the cached diff preferences
       this.diffPreferences = null;
@@ -173,7 +164,7 @@
           UserConfigSections.EDIT,
           null,
           mergedEditPreferencesInput,
-          parseDefaultEditPreferences(defaultCfg, null));
+          PreferencesParserUtil.parseDefaultEditPreferences(defaultCfg, null));
 
       // evict the cached edit preferences
       this.editPreferences = null;
@@ -189,10 +180,10 @@
 
   private GeneralPreferencesInfo parseGeneralPreferences(@Nullable GeneralPreferencesInfo input) {
     try {
-      return parseGeneralPreferences(cfg, defaultCfg, input);
+      return PreferencesParserUtil.parseGeneralPreferences(cfg, defaultCfg, input);
     } catch (ConfigInvalidException e) {
       validationErrorSink.error(
-          new ValidationError(
+          ValidationError.create(
               PREFERENCES_CONFIG,
               String.format(
                   "Invalid general preferences for account %d: %s",
@@ -203,10 +194,10 @@
 
   private DiffPreferencesInfo parseDiffPreferences(@Nullable DiffPreferencesInfo input) {
     try {
-      return parseDiffPreferences(cfg, defaultCfg, input);
+      return PreferencesParserUtil.parseDiffPreferences(cfg, defaultCfg, input);
     } catch (ConfigInvalidException e) {
       validationErrorSink.error(
-          new ValidationError(
+          ValidationError.create(
               PREFERENCES_CONFIG,
               String.format(
                   "Invalid diff preferences for account %d: %s", accountId.get(), e.getMessage())));
@@ -216,10 +207,10 @@
 
   private EditPreferencesInfo parseEditPreferences(@Nullable EditPreferencesInfo input) {
     try {
-      return parseEditPreferences(cfg, defaultCfg, input);
+      return PreferencesParserUtil.parseEditPreferences(cfg, defaultCfg, input);
     } catch (ConfigInvalidException e) {
       validationErrorSink.error(
-          new ValidationError(
+          ValidationError.create(
               PREFERENCES_CONFIG,
               String.format(
                   "Invalid edit preferences for account %d: %s", accountId.get(), e.getMessage())));
@@ -227,218 +218,6 @@
     }
   }
 
-  /**
-   * Returns a {@link GeneralPreferencesInfo} that is the result of parsing {@code defaultCfg} for
-   * the server's default configs and {@code cfg} for the user's config. These configs are then
-   * overlaid to inherit values (default -> user -> input (if provided).
-   */
-  public static GeneralPreferencesInfo parseGeneralPreferences(
-      Config cfg, @Nullable Config defaultCfg, @Nullable GeneralPreferencesInfo input)
-      throws ConfigInvalidException {
-    GeneralPreferencesInfo r =
-        loadSection(
-            cfg,
-            UserConfigSections.GENERAL,
-            null,
-            new GeneralPreferencesInfo(),
-            defaultCfg != null
-                ? parseDefaultGeneralPreferences(defaultCfg, input)
-                : GeneralPreferencesInfo.defaults(),
-            input);
-    if (input != null) {
-      r.changeTable = input.changeTable;
-      r.my = input.my;
-    } else {
-      r.changeTable = parseChangeTableColumns(cfg, defaultCfg);
-      r.my = parseMyMenus(cfg, defaultCfg);
-    }
-    return r;
-  }
-
-  /**
-   * Returns a {@link DiffPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
-   * server's default configs and {@code cfg} for the user's config. These configs are then overlaid
-   * to inherit values (default -> user -> input (if provided).
-   */
-  public static DiffPreferencesInfo parseDiffPreferences(
-      Config cfg, @Nullable Config defaultCfg, @Nullable DiffPreferencesInfo input)
-      throws ConfigInvalidException {
-    return loadSection(
-        cfg,
-        UserConfigSections.DIFF,
-        null,
-        new DiffPreferencesInfo(),
-        defaultCfg != null
-            ? parseDefaultDiffPreferences(defaultCfg, input)
-            : DiffPreferencesInfo.defaults(),
-        input);
-  }
-
-  /**
-   * Returns a {@link EditPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
-   * server's default configs and {@code cfg} for the user's config. These configs are then overlaid
-   * to inherit values (default -> user -> input (if provided).
-   */
-  public static EditPreferencesInfo parseEditPreferences(
-      Config cfg, @Nullable Config defaultCfg, @Nullable EditPreferencesInfo input)
-      throws ConfigInvalidException {
-    return loadSection(
-        cfg,
-        UserConfigSections.EDIT,
-        null,
-        new EditPreferencesInfo(),
-        defaultCfg != null
-            ? parseDefaultEditPreferences(defaultCfg, input)
-            : EditPreferencesInfo.defaults(),
-        input);
-  }
-
-  private static GeneralPreferencesInfo parseDefaultGeneralPreferences(
-      Config defaultCfg, GeneralPreferencesInfo input) throws ConfigInvalidException {
-    GeneralPreferencesInfo allUserPrefs = new GeneralPreferencesInfo();
-    loadSection(
-        defaultCfg,
-        UserConfigSections.GENERAL,
-        null,
-        allUserPrefs,
-        GeneralPreferencesInfo.defaults(),
-        input);
-    return updateGeneralPreferencesDefaults(allUserPrefs);
-  }
-
-  private static DiffPreferencesInfo parseDefaultDiffPreferences(
-      Config defaultCfg, DiffPreferencesInfo input) throws ConfigInvalidException {
-    DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
-    loadSection(
-        defaultCfg,
-        UserConfigSections.DIFF,
-        null,
-        allUserPrefs,
-        DiffPreferencesInfo.defaults(),
-        input);
-    return updateDiffPreferencesDefaults(allUserPrefs);
-  }
-
-  private static EditPreferencesInfo parseDefaultEditPreferences(
-      Config defaultCfg, EditPreferencesInfo input) throws ConfigInvalidException {
-    EditPreferencesInfo allUserPrefs = new EditPreferencesInfo();
-    loadSection(
-        defaultCfg,
-        UserConfigSections.EDIT,
-        null,
-        allUserPrefs,
-        EditPreferencesInfo.defaults(),
-        input);
-    return updateEditPreferencesDefaults(allUserPrefs);
-  }
-
-  private static GeneralPreferencesInfo updateGeneralPreferencesDefaults(
-      GeneralPreferencesInfo input) {
-    GeneralPreferencesInfo result = GeneralPreferencesInfo.defaults();
-    try {
-      for (Field field : input.getClass().getDeclaredFields()) {
-        if (skipField(field)) {
-          continue;
-        }
-        Object newVal = field.get(input);
-        if (newVal != null) {
-          field.set(result, newVal);
-        }
-      }
-    } catch (IllegalAccessException e) {
-      logger.atSevere().withCause(e).log("Failed to apply default general preferences");
-      return GeneralPreferencesInfo.defaults();
-    }
-    return result;
-  }
-
-  private static DiffPreferencesInfo updateDiffPreferencesDefaults(DiffPreferencesInfo input) {
-    DiffPreferencesInfo result = DiffPreferencesInfo.defaults();
-    try {
-      for (Field field : input.getClass().getDeclaredFields()) {
-        if (skipField(field)) {
-          continue;
-        }
-        Object newVal = field.get(input);
-        if (newVal != null) {
-          field.set(result, newVal);
-        }
-      }
-    } catch (IllegalAccessException e) {
-      logger.atSevere().withCause(e).log("Failed to apply default diff preferences");
-      return DiffPreferencesInfo.defaults();
-    }
-    return result;
-  }
-
-  private static EditPreferencesInfo updateEditPreferencesDefaults(EditPreferencesInfo input) {
-    EditPreferencesInfo result = EditPreferencesInfo.defaults();
-    try {
-      for (Field field : input.getClass().getDeclaredFields()) {
-        if (skipField(field)) {
-          continue;
-        }
-        Object newVal = field.get(input);
-        if (newVal != null) {
-          field.set(result, newVal);
-        }
-      }
-    } catch (IllegalAccessException e) {
-      logger.atSevere().withCause(e).log("Failed to apply default edit preferences");
-      return EditPreferencesInfo.defaults();
-    }
-    return result;
-  }
-
-  private static List<String> parseChangeTableColumns(Config cfg, @Nullable Config defaultCfg) {
-    List<String> changeTable = changeTable(cfg);
-    if (changeTable == null && defaultCfg != null) {
-      changeTable = changeTable(defaultCfg);
-    }
-    return changeTable;
-  }
-
-  private static List<MenuItem> parseMyMenus(Config cfg, @Nullable Config defaultCfg) {
-    List<MenuItem> my = my(cfg);
-    if (my.isEmpty() && defaultCfg != null) {
-      my = my(defaultCfg);
-    }
-    if (my.isEmpty()) {
-      my.add(new MenuItem("Dashboard", "#/dashboard/self", null));
-      my.add(new MenuItem("Draft Comments", "#/q/has:draft", null));
-      my.add(new MenuItem("Edits", "#/q/has:edit", null));
-      my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
-      my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
-      my.add(new MenuItem("Groups", "#/settings/#Groups", null));
-    }
-    return my;
-  }
-
-  public static GeneralPreferencesInfo readDefaultGeneralPreferences(
-      AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    return parseGeneralPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
-  }
-
-  public static DiffPreferencesInfo readDefaultDiffPreferences(
-      AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    return parseDiffPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
-  }
-
-  public static EditPreferencesInfo readDefaultEditPreferences(
-      AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    return parseEditPreferences(readDefaultConfig(allUsersName, allUsersRepo), null, null);
-  }
-
-  static Config readDefaultConfig(AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
-    defaultPrefs.load(allUsersName, allUsersRepo);
-    return defaultPrefs.getConfig();
-  }
-
   public static GeneralPreferencesInfo updateDefaultGeneralPreferences(
       MetaDataUpdate md, GeneralPreferencesInfo input) throws IOException, ConfigInvalidException {
     VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
@@ -453,7 +232,7 @@
     setChangeTable(defaultPrefs.getConfig(), input.changeTable);
     defaultPrefs.commit(md);
 
-    return parseGeneralPreferences(defaultPrefs.getConfig(), null, null);
+    return PreferencesParserUtil.parseGeneralPreferences(defaultPrefs.getConfig(), null, null);
   }
 
   public static DiffPreferencesInfo updateDefaultDiffPreferences(
@@ -468,7 +247,7 @@
         DiffPreferencesInfo.defaults());
     defaultPrefs.commit(md);
 
-    return parseDiffPreferences(defaultPrefs.getConfig(), null, null);
+    return PreferencesParserUtil.parseDiffPreferences(defaultPrefs.getConfig(), null, null);
   }
 
   public static EditPreferencesInfo updateDefaultEditPreferences(
@@ -483,11 +262,24 @@
         EditPreferencesInfo.defaults());
     defaultPrefs.commit(md);
 
-    return parseEditPreferences(defaultPrefs.getConfig(), null, null);
+    return PreferencesParserUtil.parseEditPreferences(defaultPrefs.getConfig(), null, null);
   }
 
-  private static List<String> changeTable(Config cfg) {
-    return Lists.newArrayList(cfg.getStringList(CHANGE_TABLE, null, CHANGE_TABLE_COLUMN));
+  public static void validateMy(List<MenuItem> my) throws BadRequestException {
+    if (my == null) {
+      return;
+    }
+    for (MenuItem item : my) {
+      checkRequiredMenuItemField(item.name, "name");
+      checkRequiredMenuItemField(item.url, "URL");
+    }
+  }
+
+  static Config readDefaultConfig(AllUsersName allUsersName, Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    VersionedDefaultPreferences defaultPrefs = new VersionedDefaultPreferences();
+    defaultPrefs.load(allUsersName, allUsersRepo);
+    return defaultPrefs.getConfig();
   }
 
   private static void setChangeTable(Config cfg, List<String> changeTable) {
@@ -497,21 +289,6 @@
     }
   }
 
-  private static List<MenuItem> my(Config cfg) {
-    List<MenuItem> my = new ArrayList<>();
-    for (String subsection : cfg.getSubsections(UserConfigSections.MY)) {
-      String url = my(cfg, subsection, KEY_URL, "#/");
-      String target = my(cfg, subsection, KEY_TARGET, url.startsWith("#") ? null : "_blank");
-      my.add(new MenuItem(subsection, url, target, my(cfg, subsection, KEY_ID, null)));
-    }
-    return my;
-  }
-
-  private static String my(Config cfg, String subsection, String key, String defaultValue) {
-    String val = cfg.getString(UserConfigSections.MY, subsection, key);
-    return !Strings.isNullOrEmpty(val) ? val : defaultValue;
-  }
-
   private static void setMy(Config cfg, List<MenuItem> my) {
     if (my != null) {
       unsetSection(cfg, UserConfigSections.MY);
@@ -526,16 +303,6 @@
     }
   }
 
-  public static void validateMy(List<MenuItem> my) throws BadRequestException {
-    if (my == null) {
-      return;
-    }
-    for (MenuItem item : my) {
-      checkRequiredMenuItemField(item.name, "name");
-      checkRequiredMenuItemField(item.url, "URL");
-    }
-  }
-
   private static void checkRequiredMenuItemField(String value, String name)
       throws BadRequestException {
     if (isNullOrEmpty(value)) {
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index fddbd2b..a35b0ac 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -24,9 +24,9 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
diff --git a/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java b/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java
index 8dc44b7..6c79296 100644
--- a/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/AttentionSetApiImpl.java
@@ -17,7 +17,7 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.api.changes.AttentionSetApi;
-import com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.AttentionSetEntryResource;
 import com.google.gerrit.server.restapi.change.RemoveFromAttentionSet;
@@ -41,7 +41,7 @@
   }
 
   @Override
-  public void remove(RemoveFromAttentionSetInput input) throws RestApiException {
+  public void remove(AttentionSetInput input) throws RestApiException {
     try {
       removeFromAttentionSet.apply(attentionSetEntryResource, input);
     } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 5122f8a..b4a5da7 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -23,9 +23,9 @@
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
-import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetApi;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.ChangeMessageApi;
@@ -543,7 +543,7 @@
   }
 
   @Override
-  public AccountInfo addToAttentionSet(AddToAttentionSetInput input) throws RestApiException {
+  public AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException {
     try {
       return addToAttentionSet.apply(change, input).value();
     } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/api/changes/CommentApiImpl.java b/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
index c5fcab1..35dd9c1 100644
--- a/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.HumanCommentResource;
 import com.google.gerrit.server.restapi.change.DeleteComment;
 import com.google.gerrit.server.restapi.change.GetComment;
 import com.google.inject.Inject;
@@ -28,16 +28,16 @@
 
 class CommentApiImpl implements CommentApi {
   interface Factory {
-    CommentApiImpl create(CommentResource c);
+    CommentApiImpl create(HumanCommentResource c);
   }
 
   private final GetComment getComment;
   private final DeleteComment deleteComment;
-  private final CommentResource comment;
+  private final HumanCommentResource comment;
 
   @Inject
   CommentApiImpl(
-      GetComment getComment, DeleteComment deleteComment, @Assisted CommentResource comment) {
+      GetComment getComment, DeleteComment deleteComment, @Assisted HumanCommentResource comment) {
     this.getComment = getComment;
     this.deleteComment = deleteComment;
     this.comment = comment;
diff --git a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
index 20e8441..e88f6df 100644
--- a/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
@@ -16,9 +16,9 @@
 
 import static com.google.gerrit.util.cli.Localizable.localizable;
 
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupCache;
diff --git a/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java b/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
index c2123cb..63cd426 100644
--- a/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
+++ b/java/com/google/gerrit/server/auth/ldap/FakeLdapGroupBackend.java
@@ -17,9 +17,9 @@
 import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
index 1d85a5e..180612c 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
@@ -24,10 +24,10 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
@@ -91,7 +91,7 @@
 
   private static GroupReference groupReference(ParameterizedString p, LdapQuery.Result res)
       throws NamingException {
-    return new GroupReference(
+    return GroupReference.create(
         AccountGroup.uuid(LDAP_UUID + res.getDN()), LDAP_NAME + LdapRealm.apply(p, res));
   }
 
diff --git a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 1421f17..b5972e2 100644
--- a/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -20,10 +20,10 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.server.account.AbstractRealm;
diff --git a/java/com/google/gerrit/server/cache/CacheBinding.java b/java/com/google/gerrit/server/cache/CacheBinding.java
index 9d90d073..99db64e 100644
--- a/java/com/google/gerrit/server/cache/CacheBinding.java
+++ b/java/com/google/gerrit/server/cache/CacheBinding.java
@@ -29,6 +29,13 @@
   /** Set the time an element lives after last access before being expired. */
   CacheBinding<K, V> expireFromMemoryAfterAccess(Duration duration);
 
+  /**
+   * Set the time that an element will be refreshed after. Elements older than this but younger than
+   * {@link #expireAfterWrite(Duration)} will still be returned, but on access a task is queued to
+   * refresh their value asynchronously.
+   */
+  CacheBinding<K, V> refreshAfterWrite(Duration duration);
+
   /** Populate the cache with items from the CacheLoader. */
   CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> clazz);
 
diff --git a/java/com/google/gerrit/server/cache/CacheDef.java b/java/com/google/gerrit/server/cache/CacheDef.java
index d0c633e..31a453e 100644
--- a/java/com/google/gerrit/server/cache/CacheDef.java
+++ b/java/com/google/gerrit/server/cache/CacheDef.java
@@ -51,6 +51,9 @@
   Duration expireFromMemoryAfterAccess();
 
   @Nullable
+  Duration refreshAfterWrite();
+
+  @Nullable
   Weigher<K, V> weigher();
 
   @Nullable
diff --git a/java/com/google/gerrit/server/cache/CacheProvider.java b/java/com/google/gerrit/server/cache/CacheProvider.java
index fe4244c..2dd9e1f 100644
--- a/java/com/google/gerrit/server/cache/CacheProvider.java
+++ b/java/com/google/gerrit/server/cache/CacheProvider.java
@@ -38,6 +38,7 @@
   private long maximumWeight;
   private Duration expireAfterWrite;
   private Duration expireFromMemoryAfterAccess;
+  private Duration refreshAfterWrite;
   private Provider<CacheLoader<K, V>> loader;
   private Provider<Weigher<K, V>> weigher;
 
@@ -90,6 +91,13 @@
   }
 
   @Override
+  public CacheBinding<K, V> refreshAfterWrite(Duration duration) {
+    checkNotFrozen();
+    refreshAfterWrite = duration;
+    return this;
+  }
+
+  @Override
   public CacheBinding<K, V> loader(Class<? extends CacheLoader<K, V>> impl) {
     checkNotFrozen();
     loader = module.bindCacheLoader(this, impl);
@@ -151,6 +159,11 @@
   }
 
   @Override
+  public Duration refreshAfterWrite() {
+    return refreshAfterWrite;
+  }
+
+  @Override
   @Nullable
   public Weigher<K, V> weigher() {
     return weigher != null ? weigher.get() : null;
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
index 5d9ce60..aa62745 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
@@ -43,6 +43,11 @@
   }
 
   @Override
+  public Duration refreshAfterWrite() {
+    return source.refreshAfterWrite();
+  }
+
+  @Override
   public Weigher<K, V> weigher() {
     Weigher<K, V> weigher = source.weigher();
     if (weigher == null) {
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 8f7e360..82615a4 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -237,6 +237,7 @@
         def.valueSerializer(),
         def.version(),
         maxSize,
-        def.expireAfterWrite());
+        def.expireAfterWrite(),
+        def.expireFromMemoryAfterAccess());
   }
 }
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index ef4e44c..7a53600 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -23,6 +23,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.BloomFilter;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.cache.PersistentCache;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
@@ -40,6 +43,7 @@
 import java.sql.Statement;
 import java.sql.Timestamp;
 import java.time.Duration;
+import java.time.Instant;
 import java.util.Calendar;
 import java.util.Map;
 import java.util.concurrent.ArrayBlockingQueue;
@@ -122,7 +126,12 @@
   @Override
   public V get(K key) throws ExecutionException {
     if (mem instanceof LoadingCache) {
-      return ((LoadingCache<K, ValueHolder<V>>) mem).get(key).value;
+      LoadingCache<K, ValueHolder<V>> asLoadingCache = (LoadingCache<K, ValueHolder<V>>) mem;
+      ValueHolder<V> valueHolder = asLoadingCache.get(key);
+      if (store.needsRefresh(valueHolder.created)) {
+        asLoadingCache.refresh(key);
+      }
+      return valueHolder.value;
     }
     throw new UnsupportedOperationException();
   }
@@ -139,8 +148,8 @@
                 }
               }
 
-              ValueHolder<V> h = new ValueHolder<>(valueLoader.call());
-              h.created = TimeUtil.nowMs();
+              ValueHolder<V> h =
+                  new ValueHolder<>(valueLoader.call(), Instant.ofEpochMilli(TimeUtil.nowMs()));
               executor.execute(() -> store.put(key, h));
               return h;
             })
@@ -149,8 +158,7 @@
 
   @Override
   public void put(K key, V val) {
-    final ValueHolder<V> h = new ValueHolder<>(val);
-    h.created = TimeUtil.nowMs();
+    final ValueHolder<V> h = new ValueHolder<>(val, Instant.ofEpochMilli(TimeUtil.nowMs()));
     mem.put(key, h);
     executor.execute(() -> store.put(key, h));
   }
@@ -217,11 +225,12 @@
 
   static class ValueHolder<V> {
     final V value;
-    long created;
+    final Instant created;
     volatile boolean clean;
 
-    ValueHolder(V value) {
+    ValueHolder(V value, Instant created) {
       this.value = value;
+      this.created = created;
     }
   }
 
@@ -248,12 +257,34 @@
           }
         }
 
-        final ValueHolder<V> h = new ValueHolder<>(loader.load(key));
-        h.created = TimeUtil.nowMs();
+        final ValueHolder<V> h =
+            new ValueHolder<>(loader.load(key), Instant.ofEpochMilli(TimeUtil.nowMs()));
         executor.execute(() -> store.put(key, h));
         return h;
       }
     }
+
+    @Override
+    public ListenableFuture<ValueHolder<V>> reload(K key, ValueHolder<V> oldValue)
+        throws Exception {
+      ListenableFuture<V> reloadedValue = loader.reload(key, oldValue.value);
+      Futures.addCallback(
+          reloadedValue,
+          new FutureCallback<V>() {
+            @Override
+            public void onSuccess(V result) {
+              store.put(key, new ValueHolder<>(result, TimeUtil.now()));
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+              logger.atWarning().withCause(t).log("Unable to reload cache value");
+            }
+          },
+          executor);
+
+      return Futures.transform(reloadedValue, v -> new ValueHolder<>(v, TimeUtil.now()), executor);
+    }
   }
 
   static class SqlStore<K, V> {
@@ -263,6 +294,7 @@
     private final int version;
     private final long maxSize;
     @Nullable private final Duration expireAfterWrite;
+    @Nullable private final Duration refreshAfterWrite;
     private final BlockingQueue<SqlHandle> handles;
     private final AtomicLong hitCount = new AtomicLong();
     private final AtomicLong missCount = new AtomicLong();
@@ -276,13 +308,15 @@
         CacheSerializer<V> valueSerializer,
         int version,
         long maxSize,
-        @Nullable Duration expireAfterWrite) {
+        @Nullable Duration expireAfterWrite,
+        @Nullable Duration refreshAfterWrite) {
       this.url = jdbcUrl;
       this.keyType = createKeyType(keyType, keySerializer);
       this.valueSerializer = valueSerializer;
       this.version = version;
       this.maxSize = maxSize;
       this.expireAfterWrite = expireAfterWrite;
+      this.refreshAfterWrite = refreshAfterWrite;
 
       int cores = Runtime.getRuntime().availableProcessors();
       int keep = Math.min(cores, 16);
@@ -394,14 +428,14 @@
           }
 
           Timestamp created = r.getTimestamp(2);
-          if (expired(created)) {
+          if (expired(created.toInstant())) {
             invalidate(key);
             missCount.incrementAndGet();
             return null;
           }
 
           V val = valueSerializer.deserialize(r.getBytes(1));
-          ValueHolder<V> h = new ValueHolder<>(val);
+          ValueHolder<V> h = new ValueHolder<>(val, created.toInstant());
           h.clean = true;
           hitCount.incrementAndGet();
           touch(c, key);
@@ -429,14 +463,22 @@
       return false;
     }
 
-    private boolean expired(Timestamp created) {
+    private boolean expired(Instant created) {
       if (expireAfterWrite == null) {
         return false;
       }
-      Duration age = Duration.between(created.toInstant(), TimeUtil.now());
+      Duration age = Duration.between(created, TimeUtil.now());
       return age.compareTo(expireAfterWrite) > 0;
     }
 
+    private boolean needsRefresh(Instant created) {
+      if (refreshAfterWrite == null) {
+        return false;
+      }
+      Duration age = Duration.between(created, TimeUtil.now());
+      return age.compareTo(refreshAfterWrite) > 0;
+    }
+
     private void touch(SqlHandle c, K key) throws IOException, SQLException {
       if (c.touch == null) {
         c.touch = c.conn.prepareStatement("UPDATE data SET accessed=? WHERE k=? AND version=?");
@@ -474,7 +516,7 @@
           keyType.set(c.put, 1, key);
           c.put.setBytes(2, valueSerializer.serialize(holder.value));
           c.put.setInt(3, version);
-          c.put.setTimestamp(4, new Timestamp(holder.created));
+          c.put.setTimestamp(4, Timestamp.from(holder.created));
           c.put.setTimestamp(5, TimeUtil.nowTs());
           c.put.executeUpdate();
           holder.clean = true;
@@ -560,7 +602,7 @@
             while (maxSize < used && r.next()) {
               K key = keyType.get(r, 1);
               Timestamp created = r.getTimestamp(3);
-              if (mem.getIfPresent(key) != null && !expired(created)) {
+              if (mem.getIfPresent(key) != null && !expired(created.toInstant())) {
                 touch(c, key);
               } else {
                 invalidate(c, key);
diff --git a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
index 9906b3d..23caca7 100644
--- a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
@@ -105,6 +105,21 @@
       builder.expireAfterAccess(expireAfterAccess.toNanos(), NANOSECONDS);
     }
 
+    Duration refreshAfterWrite = def.refreshAfterWrite();
+    if (has(def.configKey(), "refreshAfterWrite")) {
+      builder.refreshAfterWrite(
+          ConfigUtil.getTimeUnit(
+              cfg,
+              "cache",
+              def.configKey(),
+              "refreshAfterWrite",
+              toSeconds(refreshAfterWrite),
+              SECONDS),
+          SECONDS);
+    } else if (refreshAfterWrite != null) {
+      builder.refreshAfterWrite(refreshAfterWrite.toNanos(), NANOSECONDS);
+    }
+
     return builder;
   }
 
@@ -141,6 +156,21 @@
       builder.expireAfterAccess(expireAfterAccess.toNanos(), NANOSECONDS);
     }
 
+    Duration refreshAfterWrite = def.refreshAfterWrite();
+    if (has(def.configKey(), "refreshAfterWrite")) {
+      builder.expireAfterAccess(
+          ConfigUtil.getTimeUnit(
+              cfg,
+              "cache",
+              def.configKey(),
+              "refreshAfterWrite",
+              toSeconds(refreshAfterWrite),
+              SECONDS),
+          SECONDS);
+    } else if (refreshAfterWrite != null) {
+      builder.refreshAfterWrite(refreshAfterWrite.toNanos(), NANOSECONDS);
+    }
+
     return builder;
   }
 
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializer.java
new file mode 100644
index 0000000..f572c62
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializer.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class AccessSectionSerializer {
+  public static AccessSection deserialize(Cache.AccessSectionProto proto) {
+    AccessSection.Builder builder = AccessSection.builder(proto.getName());
+    proto.getPermissionsList().stream()
+        .map(PermissionSerializer::deserialize)
+        .map(Permission::toBuilder)
+        .forEach(p -> builder.addPermission(p));
+    return builder.build();
+  }
+
+  public static Cache.AccessSectionProto serialize(AccessSection autoValue) {
+    return Cache.AccessSectionProto.newBuilder()
+        .setName(autoValue.getName())
+        .addAllPermissions(
+            autoValue.getPermissions().stream()
+                .map(PermissionSerializer::serialize)
+                .collect(toImmutableList()))
+        .build();
+  }
+
+  private AccessSectionSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/AddressSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/AddressSerializer.java
new file mode 100644
index 0000000..cc86109
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/AddressSerializer.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.nullToEmpty;
+
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class AddressSerializer {
+  public static Address deserialize(Cache.AddressProto proto) {
+    return Address.create(emptyToNull(proto.getName()), proto.getEmail());
+  }
+
+  public static Cache.AddressProto serialize(Address autoValue) {
+    return Cache.AddressProto.newBuilder()
+        .setName(nullToEmpty(autoValue.name()))
+        .setEmail(autoValue.email())
+        .build();
+  }
+
+  private AddressSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/BUILD b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
new file mode 100644
index 0000000..cb8c4ae
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -0,0 +1,20 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "entities",
+    srcs = glob(["*.java"]),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/proto",
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib:protobuf",
+        "//proto:cache_java_proto",
+    ],
+)
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializer.java
new file mode 100644
index 0000000..e86db74
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializer.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import com.google.gerrit.entities.BranchOrderSection;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class BranchOrderSectionSerializer {
+  public static BranchOrderSection deserialize(Cache.BranchOrderSectionProto proto) {
+    return BranchOrderSection.create(proto.getBranchesInOrderList());
+  }
+
+  public static Cache.BranchOrderSectionProto serialize(BranchOrderSection autoValue) {
+    return Cache.BranchOrderSectionProto.newBuilder()
+        .addAllBranchesInOrder(autoValue.order())
+        .build();
+  }
+
+  private BranchOrderSectionSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java
new file mode 100644
index 0000000..789a00c
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java
@@ -0,0 +1,157 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.AccountsSection;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.ConfiguredMimeTypes;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import java.util.Optional;
+
+/** Helper to (de)serialize values for caches. */
+public class CachedProjectConfigSerializer {
+  public static CachedProjectConfig deserialize(Cache.CachedProjectConfigProto proto) {
+    CachedProjectConfig.Builder builder =
+        CachedProjectConfig.builder()
+            .setProject(ProjectSerializer.deserialize(proto.getProject()))
+            .setMaxObjectSizeLimit(proto.getMaxObjectSizeLimit())
+            .setCheckReceivedObjects(proto.getCheckReceivedObjects());
+    if (proto.hasBranchOrderSection()) {
+      builder.setBranchOrderSection(
+          Optional.of(BranchOrderSectionSerializer.deserialize(proto.getBranchOrderSection())));
+    }
+    ImmutableList<ConfiguredMimeTypes.TypeMatcher> matchers =
+        proto.getMimeTypesList().stream()
+            .map(ConfiguredMimeTypeSerializer::deserialize)
+            .collect(toImmutableList());
+    builder.setMimeTypes(ConfiguredMimeTypes.create(matchers));
+    if (!proto.getRulesId().isEmpty()) {
+      builder.setRulesId(
+          Optional.of(ObjectIdConverter.create().fromByteString(proto.getRulesId())));
+    }
+    if (!proto.getRevision().isEmpty()) {
+      builder.setRevision(
+          Optional.of(ObjectIdConverter.create().fromByteString(proto.getRevision())));
+    }
+    proto
+        .getExtensionPanelsMap()
+        .entrySet()
+        .forEach(
+            panelSection -> {
+              builder
+                  .extensionPanelSectionsBuilder()
+                  .put(
+                      panelSection.getKey(),
+                      panelSection.getValue().getSectionList().stream().collect(toImmutableList()));
+            });
+    ImmutableList<PermissionRule> accounts =
+        proto.getAccountsSectionList().stream()
+            .map(PermissionRuleSerializer::deserialize)
+            .collect(toImmutableList());
+    builder.setAccountsSection(AccountsSection.create(accounts));
+
+    proto.getGroupListList().stream()
+        .map(GroupReferenceSerializer::deserialize)
+        .forEach(builder::addGroup);
+    proto.getAccessSectionsList().stream()
+        .map(AccessSectionSerializer::deserialize)
+        .forEach(builder::addAccessSection);
+    proto.getContributorAgreementsList().stream()
+        .map(ContributorAgreementSerializer::deserialize)
+        .forEach(builder::addContributorAgreement);
+    proto.getNotifyConfigsList().stream()
+        .map(NotifyConfigSerializer::deserialize)
+        .forEach(builder::addNotifySection);
+    proto.getLabelSectionsList().stream()
+        .map(LabelTypeSerializer::deserialize)
+        .forEach(builder::addLabelSection);
+    proto.getSubscribeSectionsList().stream()
+        .map(SubscribeSectionSerializer::deserialize)
+        .forEach(builder::addSubscribeSection);
+    proto.getCommentLinksList().stream()
+        .map(StoredCommentLinkInfoSerializer::deserialize)
+        .forEach(builder::addCommentLinkSection);
+
+    return builder.build();
+  }
+
+  public static Cache.CachedProjectConfigProto serialize(CachedProjectConfig autoValue) {
+    Cache.CachedProjectConfigProto.Builder builder =
+        Cache.CachedProjectConfigProto.newBuilder()
+            .setProject(ProjectSerializer.serialize(autoValue.getProject()))
+            .setMaxObjectSizeLimit(autoValue.getMaxObjectSizeLimit())
+            .setCheckReceivedObjects(autoValue.getCheckReceivedObjects());
+
+    if (autoValue.getBranchOrderSection().isPresent()) {
+      builder.setBranchOrderSection(
+          BranchOrderSectionSerializer.serialize(autoValue.getBranchOrderSection().get()));
+    }
+    autoValue.getMimeTypes().matchers().stream()
+        .map(ConfiguredMimeTypeSerializer::serialize)
+        .forEach(builder::addMimeTypes);
+
+    if (autoValue.getRulesId().isPresent()) {
+      builder.setRulesId(ObjectIdConverter.create().toByteString(autoValue.getRulesId().get()));
+    }
+    if (autoValue.getRevision().isPresent()) {
+      builder.setRevision(ObjectIdConverter.create().toByteString(autoValue.getRevision().get()));
+    }
+
+    autoValue
+        .getExtensionPanelSections()
+        .entrySet()
+        .forEach(
+            panelSection -> {
+              builder.putExtensionPanels(
+                  panelSection.getKey(),
+                  Cache.CachedProjectConfigProto.ExtensionPanelSectionProto.newBuilder()
+                      .addAllSection(panelSection.getValue())
+                      .build());
+            });
+    autoValue.getAccountsSection().getSameGroupVisibility().stream()
+        .map(PermissionRuleSerializer::serialize)
+        .forEach(builder::addAccountsSection);
+
+    autoValue.getGroups().values().stream()
+        .map(GroupReferenceSerializer::serialize)
+        .forEach(builder::addGroupList);
+    autoValue.getAccessSections().values().stream()
+        .map(AccessSectionSerializer::serialize)
+        .forEach(builder::addAccessSections);
+    autoValue.getContributorAgreements().values().stream()
+        .map(ContributorAgreementSerializer::serialize)
+        .forEach(builder::addContributorAgreements);
+    autoValue.getNotifySections().values().stream()
+        .map(NotifyConfigSerializer::serialize)
+        .forEach(builder::addNotifyConfigs);
+    autoValue.getLabelSections().values().stream()
+        .map(LabelTypeSerializer::serialize)
+        .forEach(builder::addLabelSections);
+    autoValue.getSubscribeSections().values().stream()
+        .map(SubscribeSectionSerializer::serialize)
+        .forEach(builder::addSubscribeSections);
+    autoValue.getCommentLinkSections().values().stream()
+        .map(StoredCommentLinkInfoSerializer::serialize)
+        .forEach(builder::addCommentLinks);
+    return builder.build();
+  }
+
+  private CachedProjectConfigSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/ConfiguredMimeTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/ConfiguredMimeTypeSerializer.java
new file mode 100644
index 0000000..6e0c923
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/ConfiguredMimeTypeSerializer.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import com.google.gerrit.entities.ConfiguredMimeTypes;
+import com.google.gerrit.server.cache.proto.Cache;
+import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.errors.InvalidPatternException;
+
+public class ConfiguredMimeTypeSerializer {
+  public static ConfiguredMimeTypes.TypeMatcher deserialize(Cache.ConfiguredMimeTypeProto proto) {
+    try {
+      return proto.getIsRegularExpression()
+          ? new ConfiguredMimeTypes.ReType(proto.getType(), proto.getPattern())
+          : new ConfiguredMimeTypes.FnType(proto.getType(), proto.getPattern());
+    } catch (PatternSyntaxException | InvalidPatternException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  public static Cache.ConfiguredMimeTypeProto serialize(ConfiguredMimeTypes.TypeMatcher value) {
+    return Cache.ConfiguredMimeTypeProto.newBuilder()
+        .setType(value.getType())
+        .setPattern(value.getPattern())
+        .setIsRegularExpression(value instanceof ConfiguredMimeTypes.ReType)
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializer.java
new file mode 100644
index 0000000..19edf4f
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializer.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.nullToEmpty;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class ContributorAgreementSerializer {
+  public static ContributorAgreement deserialize(Cache.ContributorAgreementProto proto) {
+    ContributorAgreement.Builder builder =
+        ContributorAgreement.builder(proto.getName())
+            .setDescription(emptyToNull(proto.getDescription()))
+            .setAccepted(
+                proto.getAcceptedList().stream()
+                    .map(PermissionRuleSerializer::deserialize)
+                    .collect(toImmutableList()))
+            .setAgreementUrl(emptyToNull(proto.getUrl()))
+            .setExcludeProjectsRegexes(proto.getExcludeRegularExpressionsList())
+            .setMatchProjectsRegexes(proto.getMatchRegularExpressionsList());
+    if (proto.hasAutoVerify()) {
+      builder.setAutoVerify(GroupReferenceSerializer.deserialize(proto.getAutoVerify()));
+    }
+    return builder.build();
+  }
+
+  public static Cache.ContributorAgreementProto serialize(ContributorAgreement autoValue) {
+    Cache.ContributorAgreementProto.Builder builder =
+        Cache.ContributorAgreementProto.newBuilder()
+            .setName(autoValue.getName())
+            .setDescription(nullToEmpty(autoValue.getDescription()))
+            .addAllAccepted(
+                autoValue.getAccepted().stream()
+                    .map(PermissionRuleSerializer::serialize)
+                    .collect(toImmutableList()))
+            .setUrl(nullToEmpty(autoValue.getAgreementUrl()))
+            .addAllExcludeRegularExpressions(autoValue.getExcludeProjectsRegexes())
+            .addAllMatchRegularExpressions(autoValue.getMatchProjectsRegexes());
+    if (autoValue.getAutoVerify() != null) {
+      builder.setAutoVerify(GroupReferenceSerializer.serialize(autoValue.getAutoVerify()));
+    }
+    return builder.build();
+  }
+
+  private ContributorAgreementSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializer.java
new file mode 100644
index 0000000..c5d4d07
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializer.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class GroupReferenceSerializer {
+  public static GroupReference deserialize(Cache.GroupReferenceProto proto) {
+    if (!proto.getUuid().isEmpty()) {
+      return GroupReference.create(AccountGroup.uuid(proto.getUuid()), proto.getName());
+    }
+    return GroupReference.create(proto.getName());
+  }
+
+  public static Cache.GroupReferenceProto serialize(GroupReference autoValue) {
+    return Cache.GroupReferenceProto.newBuilder()
+        .setName(autoValue.getName())
+        .setUuid(autoValue.getUUID() == null ? "" : autoValue.getUUID().get())
+        .build();
+  }
+
+  private GroupReferenceSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
new file mode 100644
index 0000000..b7d02d4
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Shorts;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class LabelTypeSerializer {
+  private static final Converter<String, LabelFunction> FUNCTION_CONVERTER =
+      Enums.stringConverter(LabelFunction.class);
+
+  public static LabelType deserialize(Cache.LabelTypeProto proto) {
+    return LabelType.builder(
+            proto.getName(),
+            proto.getValuesList().stream()
+                .map(LabelValueSerializer::deserialize)
+                .collect(toImmutableList()))
+        .setFunction(FUNCTION_CONVERTER.convert(proto.getFunction()))
+        .setAllowPostSubmit(proto.getAllowPostSubmit())
+        .setIgnoreSelfApproval(proto.getIgnoreSelfApproval())
+        .setDefaultValue(Shorts.saturatedCast(proto.getDefaultValue()))
+        .setCopyAnyScore(proto.getCopyAnyScore())
+        .setCopyMinScore(proto.getCopyMinScore())
+        .setCopyMaxScore(proto.getCopyMaxScore())
+        .setCopyAllScoresOnMergeFirstParentUpdate(proto.getCopyAllScoresOnMergeFirstParentUpdate())
+        .setCopyAllScoresOnTrivialRebase(proto.getCopyAllScoresOnTrivialRebase())
+        .setCopyAllScoresIfNoCodeChange(proto.getCopyAllScoresIfNoCodeChange())
+        .setCopyAllScoresIfNoChange(proto.getCopyAllScoresIfNoChange())
+        .setCopyValues(
+            proto.getCopyValuesList().stream()
+                .map(Shorts::saturatedCast)
+                .collect(toImmutableList()))
+        .setMaxNegative(Shorts.saturatedCast(proto.getMaxNegative()))
+        .setMaxPositive(Shorts.saturatedCast(proto.getMaxPositive()))
+        .setRefPatterns(proto.getRefPatternsList())
+        .build();
+  }
+
+  public static Cache.LabelTypeProto serialize(LabelType autoValue) {
+    return Cache.LabelTypeProto.newBuilder()
+        .setName(autoValue.getName())
+        .addAllValues(
+            autoValue.getValues().stream()
+                .map(LabelValueSerializer::serialize)
+                .collect(toImmutableList()))
+        .setFunction(FUNCTION_CONVERTER.reverse().convert(autoValue.getFunction()))
+        .setCopyAnyScore(autoValue.isCopyAnyScore())
+        .setCopyMinScore(autoValue.isCopyMinScore())
+        .setCopyMaxScore(autoValue.isCopyMaxScore())
+        .setCopyAllScoresOnMergeFirstParentUpdate(
+            autoValue.isCopyAllScoresOnMergeFirstParentUpdate())
+        .setCopyAllScoresOnTrivialRebase(autoValue.isCopyAllScoresOnTrivialRebase())
+        .setCopyAllScoresIfNoCodeChange(autoValue.isCopyAllScoresIfNoCodeChange())
+        .setCopyAllScoresIfNoChange(autoValue.isCopyAllScoresIfNoChange())
+        .addAllCopyValues(
+            autoValue.getCopyValues().stream().map(c -> (int) c).collect(toImmutableList()))
+        .setAllowPostSubmit(autoValue.isAllowPostSubmit())
+        .setIgnoreSelfApproval(autoValue.isIgnoreSelfApproval())
+        .setDefaultValue(Shorts.saturatedCast(autoValue.getDefaultValue()))
+        .setMaxNegative(Shorts.saturatedCast(autoValue.getMaxNegative()))
+        .setMaxPositive(Shorts.saturatedCast(autoValue.getMaxPositive()))
+        .addAllRefPatterns(
+            autoValue.getRefPatterns() == null ? ImmutableList.of() : autoValue.getRefPatterns())
+        .build();
+  }
+
+  private LabelTypeSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/LabelValueSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelValueSerializer.java
new file mode 100644
index 0000000..c1ca9a1
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelValueSerializer.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import com.google.common.primitives.Shorts;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class LabelValueSerializer {
+  public static LabelValue deserialize(Cache.LabelValueProto proto) {
+    return LabelValue.create(Shorts.saturatedCast(proto.getValue()), proto.getText());
+  }
+
+  public static Cache.LabelValueProto serialize(LabelValue autoValue) {
+    return Cache.LabelValueProto.newBuilder()
+        .setText(autoValue.getText())
+        .setValue(autoValue.getValue())
+        .build();
+  }
+
+  private LabelValueSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializer.java
new file mode 100644
index 0000000..f0f7d905
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializer.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.nullToEmpty;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
+import com.google.gerrit.entities.NotifyConfig;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class NotifyConfigSerializer {
+  private static final Converter<String, NotifyConfig.Header> HEADER_CONVERTER =
+      Enums.stringConverter(NotifyConfig.Header.class);
+
+  private static final Converter<String, NotifyConfig.NotifyType> NOTIFY_TYPE_CONVERTER =
+      Enums.stringConverter(NotifyConfig.NotifyType.class);
+
+  public static NotifyConfig deserialize(Cache.NotifyConfigProto proto) {
+    NotifyConfig.Builder builder =
+        NotifyConfig.builder()
+            .setName(emptyToNull(proto.getName()))
+            .setNotify(
+                proto.getTypeList().stream()
+                    .map(t -> NOTIFY_TYPE_CONVERTER.convert(t))
+                    .collect(toImmutableSet()))
+            .setFilter(emptyToNull(proto.getFilter()))
+            .setHeader(
+                proto.getHeader().isEmpty() ? null : HEADER_CONVERTER.convert(proto.getHeader()));
+    proto.getGroupsList().stream()
+        .map(GroupReferenceSerializer::deserialize)
+        .forEach(g -> builder.addGroup(g));
+    proto.getAddressesList().stream()
+        .map(AddressSerializer::deserialize)
+        .forEach(a -> builder.addAddress(a));
+    return builder.build();
+  }
+
+  public static Cache.NotifyConfigProto serialize(NotifyConfig autoValue) {
+    return Cache.NotifyConfigProto.newBuilder()
+        .setName(nullToEmpty(autoValue.getName()))
+        .addAllType(
+            autoValue.getNotify().stream()
+                .map(t -> NOTIFY_TYPE_CONVERTER.reverse().convert(t))
+                .collect(toImmutableSet()))
+        .setFilter(nullToEmpty(autoValue.getFilter()))
+        .setHeader(
+            autoValue.getHeader() == null
+                ? ""
+                : HEADER_CONVERTER.reverse().convert(autoValue.getHeader()))
+        .addAllGroups(
+            autoValue.getGroups().stream()
+                .map(GroupReferenceSerializer::serialize)
+                .collect(toImmutableSet()))
+        .addAllAddresses(
+            autoValue.getAddresses().stream()
+                .map(AddressSerializer::serialize)
+                .collect(toImmutableList()))
+        .build();
+  }
+
+  private NotifyConfigSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializer.java
new file mode 100644
index 0000000..41fb85f
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializer.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class PermissionRuleSerializer {
+  private static final Converter<String, PermissionRule.Action> ACTION_CONVERTER =
+      Enums.stringConverter(PermissionRule.Action.class);
+
+  public static PermissionRule deserialize(Cache.PermissionRuleProto proto) {
+    return PermissionRule.builder(GroupReferenceSerializer.deserialize(proto.getGroup()))
+        .setAction(ACTION_CONVERTER.convert(proto.getAction()))
+        .setForce(proto.getForce())
+        .setMin(proto.getMin())
+        .setMax(proto.getMax())
+        .build();
+  }
+
+  public static Cache.PermissionRuleProto serialize(PermissionRule autoValue) {
+    return Cache.PermissionRuleProto.newBuilder()
+        .setAction(ACTION_CONVERTER.reverse().convert(autoValue.getAction()))
+        .setForce(autoValue.getForce())
+        .setMin(autoValue.getMin())
+        .setMax(autoValue.getMax())
+        .setGroup(GroupReferenceSerializer.serialize(autoValue.getGroup()))
+        .build();
+  }
+
+  private PermissionRuleSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/PermissionSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/PermissionSerializer.java
new file mode 100644
index 0000000..01d3393
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/PermissionSerializer.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class PermissionSerializer {
+  public static Permission deserialize(Cache.PermissionProto proto) {
+    Permission.Builder builder =
+        Permission.builder(proto.getName()).setExclusiveGroup(proto.getExclusiveGroup());
+    proto.getRulesList().stream()
+        .map(PermissionRuleSerializer::deserialize)
+        .map(PermissionRule::toBuilder)
+        .forEach(rule -> builder.add(rule));
+    return builder.build();
+  }
+
+  public static Cache.PermissionProto serialize(Permission autoValue) {
+    return Cache.PermissionProto.newBuilder()
+        .setName(autoValue.getName())
+        .setExclusiveGroup(autoValue.getExclusiveGroup())
+        .addAllRules(
+            autoValue.getRules().stream()
+                .map(PermissionRuleSerializer::serialize)
+                .collect(toImmutableList()))
+        .build();
+  }
+
+  private PermissionSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/ProjectSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/ProjectSerializer.java
new file mode 100644
index 0000000..aa1f4ce
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/ProjectSerializer.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.nullToEmpty;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.server.cache.proto.Cache;
+import java.util.Arrays;
+import java.util.Set;
+
+/** Helper to (de)serialize values for caches. */
+public class ProjectSerializer {
+  private static final Converter<String, ProjectState> PROJECT_STATE_CONVERTER =
+      Enums.stringConverter(ProjectState.class);
+  private static final Converter<String, SubmitType> SUBMIT_TYPE_CONVERTER =
+      Enums.stringConverter(SubmitType.class);
+
+  public static Project deserialize(Cache.ProjectProto proto) {
+    Project.Builder builder =
+        Project.builder(Project.nameKey(proto.getName()))
+            .setSubmitType(SUBMIT_TYPE_CONVERTER.convert(proto.getSubmitType()))
+            .setState(PROJECT_STATE_CONVERTER.convert(proto.getState()))
+            .setDescription(emptyToNull(proto.getDescription()))
+            .setParent(emptyToNull(proto.getParent()))
+            .setMaxObjectSizeLimit(emptyToNull(proto.getMaxObjectSizeLimit()))
+            .setDefaultDashboard(emptyToNull(proto.getDefaultDashboard()))
+            .setLocalDefaultDashboard(emptyToNull(proto.getLocalDefaultDashboard()))
+            .setConfigRefState(emptyToNull(proto.getConfigRefState()));
+
+    Set<String> configs =
+        Arrays.stream(BooleanProjectConfig.values())
+            .map(BooleanProjectConfig::name)
+            .collect(toImmutableSet());
+    proto
+        .getBooleanConfigsMap()
+        .entrySet()
+        .forEach(
+            configEntry -> {
+              if (configs.contains(configEntry.getKey())) {
+                builder.setBooleanConfig(
+                    BooleanProjectConfig.valueOf(configEntry.getKey()),
+                    InheritableBoolean.valueOf(configEntry.getValue()));
+              }
+            });
+
+    return builder.build();
+  }
+
+  public static Cache.ProjectProto serialize(Project autoValue) {
+    Cache.ProjectProto.Builder builder =
+        Cache.ProjectProto.newBuilder()
+            .setName(autoValue.getName())
+            .setSubmitType(SUBMIT_TYPE_CONVERTER.reverse().convert(autoValue.getSubmitType()))
+            .setState(PROJECT_STATE_CONVERTER.reverse().convert(autoValue.getState()))
+            .setDescription(nullToEmpty(autoValue.getDescription()))
+            .setParent(nullToEmpty(autoValue.getParentName()))
+            .setMaxObjectSizeLimit(nullToEmpty(autoValue.getMaxObjectSizeLimit()))
+            .setDefaultDashboard(nullToEmpty(autoValue.getDefaultDashboard()))
+            .setLocalDefaultDashboard(nullToEmpty(autoValue.getLocalDefaultDashboard()))
+            .setConfigRefState(nullToEmpty(autoValue.getConfigRefState()));
+
+    autoValue
+        .getBooleanConfigs()
+        .entrySet()
+        .forEach(
+            configEntry -> {
+              builder.putBooleanConfigs(configEntry.getKey().name(), configEntry.getValue().name());
+            });
+
+    return builder.build();
+  }
+
+  private ProjectSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
new file mode 100644
index 0000000..d7bd373
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.nullToEmpty;
+
+import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class StoredCommentLinkInfoSerializer {
+  public static StoredCommentLinkInfo deserialize(Cache.StoredCommentLinkInfoProto proto) {
+    return StoredCommentLinkInfo.builder(proto.getName())
+        .setMatch(emptyToNull(proto.getMatch()))
+        .setLink(emptyToNull(proto.getLink()))
+        .setHtml(emptyToNull(proto.getHtml()))
+        .setEnabled(proto.getEnabled())
+        .setOverrideOnly(proto.getOverrideOnly())
+        .build();
+  }
+
+  public static Cache.StoredCommentLinkInfoProto serialize(StoredCommentLinkInfo autoValue) {
+    return Cache.StoredCommentLinkInfoProto.newBuilder()
+        .setName(autoValue.getName())
+        .setMatch(nullToEmpty(autoValue.getMatch()))
+        .setLink(nullToEmpty(autoValue.getLink()))
+        .setHtml(nullToEmpty(autoValue.getHtml()))
+        .setEnabled(autoValue.getEnabled())
+        .setOverrideOnly(autoValue.getOverrideOnly())
+        .build();
+  }
+
+  private StoredCommentLinkInfoSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializer.java
new file mode 100644
index 0000000..2046f3a
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializer.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubscribeSection;
+import com.google.gerrit.server.cache.proto.Cache;
+
+/** Helper to (de)serialize values for caches. */
+public class SubscribeSectionSerializer {
+  public static SubscribeSection deserialize(Cache.SubscribeSectionProto proto) {
+    SubscribeSection.Builder builder =
+        SubscribeSection.builder(Project.nameKey(proto.getProjectName()));
+    proto.getMatchingRefSpecsList().forEach(rs -> builder.addMatchingRefSpec(rs));
+    proto.getMultiMatchRefSpecsList().forEach(rs -> builder.addMultiMatchRefSpec(rs));
+    return builder.build();
+  }
+
+  public static Cache.SubscribeSectionProto serialize(SubscribeSection autoValue) {
+    Cache.SubscribeSectionProto.Builder builder =
+        Cache.SubscribeSectionProto.newBuilder().setProjectName(autoValue.project().get());
+    autoValue.multiMatchRefSpecsAsString().forEach(rs -> builder.addMultiMatchRefSpecs(rs));
+    autoValue.matchingRefSpecsAsString().forEach(rs -> builder.addMatchingRefSpecs(rs));
+    return builder.build();
+  }
+
+  private SubscribeSectionSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index eb6e8d7..6c39ed0 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.extensions.events.ChangeAbandoned;
 import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -42,6 +43,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final ChangeAbandoned changeAbandoned;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final String msgTxt;
   private final AccountState accountState;
@@ -61,12 +63,14 @@
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
       ChangeAbandoned changeAbandoned,
+      MessageIdGenerator messageIdGenerator,
       @Assisted @Nullable AccountState accountState,
       @Assisted @Nullable String msgTxt) {
     this.abandonedSenderFactory = abandonedSenderFactory;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
     this.changeAbandoned = changeAbandoned;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.accountState = accountState;
     this.msgTxt = Strings.nullToEmpty(msgTxt);
@@ -110,13 +114,16 @@
   public void postUpdate(Context ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     try {
-      ReplyToChangeSender cm = abandonedSenderFactory.create(ctx.getProject(), change.getId());
+      ReplyToChangeSender emailSender =
+          abandonedSenderFactory.create(ctx.getProject(), change.getId());
       if (accountState != null) {
-        cm.setFrom(accountState.account().id());
+        emailSender.setFrom(accountState.account().id());
       }
-      cm.setChangeMessage(message.getMessage(), ctx.getWhen());
-      cm.setNotify(notify);
-      cm.send();
+      emailSender.setChangeMessage(message.getMessage(), ctx.getWhen());
+      emailSender.setNotify(notify);
+      emailSender.setMessageId(
+          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+      emailSender.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
diff --git a/java/com/google/gerrit/server/change/AddReviewersEmail.java b/java/com/google/gerrit/server/change/AddReviewersEmail.java
index 2778bdd..4a3f638 100644
--- a/java/com/google/gerrit/server/change/AddReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/AddReviewersEmail.java
@@ -19,12 +19,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -37,13 +38,16 @@
 
   private final AddReviewerSender.Factory addReviewerSenderFactory;
   private final ExecutorService sendEmailsExecutor;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   AddReviewersEmail(
       AddReviewerSender.Factory addReviewerSenderFactory,
-      @SendEmailExecutor ExecutorService sendEmailsExecutor) {
+      @SendEmailExecutor ExecutorService sendEmailsExecutor,
+      MessageIdGenerator messageIdGenerator) {
     this.addReviewerSenderFactory = addReviewerSenderFactory;
     this.sendEmailsExecutor = sendEmailsExecutor;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   public void emailReviewersAsync(
@@ -79,14 +83,18 @@
         sendEmailsExecutor.submit(
             () -> {
               try {
-                AddReviewerSender cm = addReviewerSenderFactory.create(projectNameKey, cId);
-                cm.setNotify(notify);
-                cm.setFrom(userId);
-                cm.addReviewers(immutableToMail);
-                cm.addReviewersByEmail(immutableAddedByEmail);
-                cm.addExtraCC(immutableToCopy);
-                cm.addExtraCCByEmail(immutableCopiedByEmail);
-                cm.send();
+                AddReviewerSender emailSender =
+                    addReviewerSenderFactory.create(projectNameKey, cId);
+                emailSender.setNotify(notify);
+                emailSender.setFrom(userId);
+                emailSender.addReviewers(immutableToMail);
+                emailSender.addReviewersByEmail(immutableAddedByEmail);
+                emailSender.addExtraCC(immutableToCopy);
+                emailSender.addExtraCCByEmail(immutableCopiedByEmail);
+                emailSender.setMessageId(
+                    messageIdGenerator.fromChangeUpdate(
+                        change.getProject(), change.currentPatchSetId()));
+                emailSender.send();
               } catch (Exception err) {
                 logger.atSevere().withCause(err).log(
                     "Cannot send email to new reviewers of change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index 7b87a29..ff8e5c6 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -29,12 +29,12 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index 829c290..8053b30 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -14,72 +14,109 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
-import com.google.gerrit.entities.AttentionSetUpdate.Operation;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.Map;
-import java.util.function.Function;
+import java.io.IOException;
 
 /** Add a specified user to the attention set. */
 public class AddToAttentionSetOp implements BatchUpdateOp {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    AddToAttentionSetOp create(Account.Id attentionUserId, String reason);
+    AddToAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final ChangeMessagesUtil cmUtil;
+  private final MessageIdGenerator messageIdGenerator;
+  private final AddToAttentionSetSender.Factory addToAttentionSetSender;
+  private final AttentionSetEmail.Factory attentionSetEmailFactory;
+
   private final Account.Id attentionUserId;
   private final String reason;
 
+  private Change change;
+  private boolean notify;
+
+  /**
+   * Add a specified user to the attention set.
+   *
+   * @param attentionUserId the id of the user we want to add to the attention set.
+   * @param reason the reason for adding that user.
+   * @param notify whether or not to send emails if the operation is successful.
+   */
   @Inject
   AddToAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      ChangeMessagesUtil cmUtil,
+      AddToAttentionSetSender.Factory addToAttentionSetSender,
+      MessageIdGenerator messageIdGenerator,
+      AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
-      @Assisted String reason) {
+      @Assisted String reason,
+      @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.cmUtil = cmUtil;
+    this.addToAttentionSetSender = addToAttentionSetSender;
+    this.messageIdGenerator = messageIdGenerator;
+    this.attentionSetEmailFactory = attentionSetEmailFactory;
+
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
+    this.notify = notify;
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx) throws RestApiException {
     ChangeData changeData = changeDataFactory.create(ctx.getNotes());
-    Map<Account.Id, AttentionSetUpdate> attentionMap =
-        changeData.attentionSet().stream()
-            .collect(toImmutableMap(AttentionSetUpdate::account, Function.identity()));
-    AttentionSetUpdate existingEntry = attentionMap.get(attentionUserId);
-    if (existingEntry != null && existingEntry.operation() == Operation.ADD) {
-      return false;
+    if (changeData.attentionSet().stream()
+        .anyMatch(
+            u ->
+                u.account().equals(attentionUserId)
+                    && u.operation() == AttentionSetUpdate.Operation.ADD)) {
+      // We still need to perform this update to ensure that we don't remove the user in a follow-up
+      // operation, but no need to send an email about it.
+      notify = false;
     }
 
+    change = ctx.getChange();
+
     ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.setAttentionSetUpdates(
-        ImmutableSet.of(
-            AttentionSetUpdate.createForWrite(
-                attentionUserId, AttentionSetUpdate.Operation.ADD, reason)));
-    addMessage(ctx, update);
+    update.addToPlannedAttentionSetUpdates(
+        AttentionSetUpdate.createForWrite(
+            attentionUserId, AttentionSetUpdate.Operation.ADD, reason));
     return true;
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
-    String message = "Added to attention set: " + attentionUserId;
-    cmUtil.addChangeMessage(
-        update,
-        ChangeMessagesUtil.newMessage(ctx, message, ChangeMessagesUtil.TAG_UPDATE_ATTENTION_SET));
+  @Override
+  public void postUpdate(Context ctx) {
+    if (!notify) {
+      return;
+    }
+    try {
+      attentionSetEmailFactory
+          .create(
+              addToAttentionSetSender.create(ctx.getProject(), change.getId()),
+              ctx,
+              change,
+              reason,
+              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
+              attentionUserId)
+          .sendAsync();
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/change/AttentionSetUnchangedOp.java b/java/com/google/gerrit/server/change/AttentionSetUnchangedOp.java
new file mode 100644
index 0000000..8f8a57c
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AttentionSetUnchangedOp.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+
+/**
+ * Ensures that the attention set will not be changed, thus blocks {@link RemoveFromAttentionSetOp}
+ * and {@link AddToAttentionSetOp} and updates in {@link ChangeUpdate}.
+ */
+public class AttentionSetUnchangedOp implements BatchUpdateOp {
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws RestApiException {
+    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    update.ignoreFurtherAttentionSetUpdates();
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index bbb94ea..f49a21e 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -29,12 +29,12 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetInfo;
@@ -59,6 +59,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -108,6 +109,7 @@
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final ReviewerAdder reviewerAdder;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
@@ -156,6 +158,7 @@
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
       ReviewerAdder reviewerAdder,
+      MessageIdGenerator messageIdGenerator,
       @Assisted Change.Id changeId,
       @Assisted ObjectId commitId,
       @Assisted String refName) {
@@ -171,6 +174,7 @@
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
     this.reviewerAdder = reviewerAdder;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.changeId = changeId;
     this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID);
@@ -461,21 +465,24 @@
             @Override
             public void run() {
               try {
-                CreateChangeSender cm =
+                CreateChangeSender emailSender =
                     createChangeSenderFactory.create(change.getProject(), change.getId());
-                cm.setFrom(change.getOwner());
-                cm.setPatchSet(patchSet, patchSetInfo);
-                cm.setNotify(notify);
-                cm.addReviewers(
+                emailSender.setFrom(change.getOwner());
+                emailSender.setPatchSet(patchSet, patchSetInfo);
+                emailSender.setNotify(notify);
+                emailSender.addReviewers(
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
                         .map(PatchSetApproval::accountId)
                         .collect(toImmutableSet()));
-                cm.addReviewersByEmail(
+                emailSender.addReviewersByEmail(
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewersByEmail));
-                cm.addExtraCC(reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
-                cm.addExtraCCByEmail(
+                emailSender.addExtraCC(
+                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
+                emailSender.addExtraCCByEmail(
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCsByEmail));
-                cm.send();
+                emailSender.setMessageId(
+                    messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+                emailSender.send();
               } catch (Exception e) {
                 logger.atSevere().withCause(e).log(
                     "Cannot send email for new change %s", change.getId());
@@ -577,13 +584,15 @@
                     change,
                     patchSetInfo.getCommitId(),
                     patchSetInfo.getAuthor().getAccount(),
-                    NotifyHandling.NONE)),
+                    NotifyHandling.NONE,
+                    change.getOwner())),
             Streams.stream(
                 newAddReviewerInputFromCommitIdentity(
                     change,
                     patchSetInfo.getCommitId(),
                     patchSetInfo.getCommitter().getAccount(),
-                    NotifyHandling.NONE)))
+                    NotifyHandling.NONE,
+                    change.getOwner())))
         .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 8c4f275..014955c9 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -46,23 +46,24 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRecord.Status;
-import com.google.gerrit.common.data.SubmitRequirement;
-import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Status;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.AttentionSetEntry;
+import com.google.gerrit.extensions.common.AttentionSetInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
@@ -73,7 +74,6 @@
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.index.query.QueryResult;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
@@ -140,7 +140,6 @@
           COMMIT_FOOTERS,
           CURRENT_ACTIONS,
           CURRENT_COMMIT,
-          DETAILED_LABELS, // may need to load ChangeNotes to check remove reviewer permissions
           MESSAGES);
 
   @Singleton
@@ -516,7 +515,7 @@
                   toImmutableMap(
                       a -> a.account().get(),
                       a ->
-                          new AttentionSetEntry(
+                          new AttentionSetInfo(
                               accountLoader.get(a.account()),
                               Timestamp.from(a.timestamp()),
                               a.reason())));
@@ -722,7 +721,10 @@
     // testRemoveReviewer check for a specific reviewer in the loop saving potentially many
     // permission checks.
     boolean canRemoveAnyReviewer =
-        permissionBackendForChange(userProvider.get(), cd).test(ChangePermission.REMOVE_REVIEWER);
+        permissionBackend
+            .user(userProvider.get())
+            .change(cd)
+            .test(ChangePermission.REMOVE_REVIEWER);
     for (LabelInfo label : labels) {
       if (label.all == null) {
         continue;
@@ -817,16 +819,4 @@
     }
     return map;
   }
-
-  /**
-   * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
-   *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
-   *     lazyload}.
-   */
-  private PermissionBackend.ForChange permissionBackendForChange(CurrentUser user, ChangeData cd) {
-    PermissionBackend.WithUser withUser = permissionBackend.user(user);
-    return lazyLoad
-        ? withUser.change(cd)
-        : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
-  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index a7fc5de..5a1798d 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -20,6 +20,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
@@ -188,7 +189,7 @@
     Iterable<ProjectState> projectStateTree =
         projectCache.get(getProject()).orElseThrow(illegalState(getProject())).tree();
     for (ProjectState p : projectStateTree) {
-      hashObjectId(h, p.getConfig().getRevision(), buf);
+      hashObjectId(h, p.getConfig().getRevision().orElse(null), buf);
     }
 
     changeETagComputation.runEach(
@@ -218,7 +219,7 @@
     }
   }
 
-  private void hashObjectId(Hasher h, ObjectId id, byte[] buf) {
+  private void hashObjectId(Hasher h, @Nullable ObjectId id, byte[] buf) {
     MoreObjects.firstNonNull(id, ObjectId.zeroId()).copyRawTo(buf, 0);
     h.putBytes(buf);
   }
diff --git a/java/com/google/gerrit/server/change/CommentResource.java b/java/com/google/gerrit/server/change/CommentResource.java
deleted file mode 100644
index dbe7a76..0000000
--- a/java/com/google/gerrit/server/change/CommentResource.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.inject.TypeLiteral;
-
-public class CommentResource implements RestResource {
-  public static final TypeLiteral<RestView<CommentResource>> COMMENT_KIND =
-      new TypeLiteral<RestView<CommentResource>>() {};
-
-  private final RevisionResource rev;
-  private final Comment comment;
-
-  public CommentResource(RevisionResource rev, Comment c) {
-    this.rev = rev;
-    this.comment = c;
-  }
-
-  public PatchSet getPatchSet() {
-    return rev.getPatchSet();
-  }
-
-  public Comment getComment() {
-    return comment;
-  }
-
-  public String getId() {
-    return comment.key.uuid;
-  }
-
-  public Account.Id getAuthorId() {
-    return comment.author.getId();
-  }
-
-  public RevisionResource getRevisionResource() {
-    return rev;
-  }
-}
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index 3bc9324..255e13a 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -15,12 +15,13 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -36,6 +37,8 @@
   }
 
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final MessageIdGenerator messageIdGenerator;
+
   private final Address reviewer;
 
   private ChangeMessage changeMessage;
@@ -43,8 +46,11 @@
 
   @Inject
   DeleteReviewerByEmailOp(
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory, @Assisted Address reviewer) {
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted Address reviewer) {
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.messageIdGenerator = messageIdGenerator;
     this.reviewer = reviewer;
   }
 
@@ -73,13 +79,15 @@
       if (!notify.shouldNotify()) {
         return;
       }
-      DeleteReviewerSender cm =
+      DeleteReviewerSender emailSender =
           deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
-      cm.setFrom(ctx.getAccountId());
-      cm.addReviewersByEmail(Collections.singleton(reviewer));
-      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-      cm.setNotify(notify);
-      cm.send();
+      emailSender.setFrom(ctx.getAccountId());
+      emailSender.addReviewersByEmail(Collections.singleton(reviewer));
+      emailSender.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+      emailSender.setNotify(notify);
+      emailSender.setMessageId(
+          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+      emailSender.send();
     } catch (Exception err) {
       logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
     }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index b70b059..07cb04f 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -18,11 +18,11 @@
 
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
@@ -38,6 +38,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -45,14 +46,13 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
 
 public class DeleteReviewerOp implements BatchUpdateOp {
@@ -71,6 +71,7 @@
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final AccountState reviewer;
   private final DeleteReviewerInput input;
@@ -92,6 +93,7 @@
       DeleteReviewerSender.Factory deleteReviewerSenderFactory,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
+      MessageIdGenerator messageIdGenerator,
       @Assisted AccountState reviewerAccount,
       @Assisted DeleteReviewerInput input) {
     this.approvalsUtil = approvalsUtil;
@@ -103,6 +105,7 @@
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
+    this.messageIdGenerator = messageIdGenerator;
     this.reviewer = reviewerAccount;
     this.input = input;
   }
@@ -134,12 +137,10 @@
     msg.append("Removed reviewer " + reviewer.account().fullName());
     StringBuilder removedVotesMsg = new StringBuilder();
     removedVotesMsg.append(" with the following votes:\n\n");
-    List<PatchSetApproval> del = new ArrayList<>();
     boolean votesRemoved = false;
     for (PatchSetApproval a : approvals(ctx, reviewerId)) {
       // Check if removing this vote is OK
       removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-      del.add(a);
       if (a.patchSetId().equals(currPs.id()) && a.value() != 0) {
         oldApprovals.put(a.label(), a.value());
         removedVotesMsg
@@ -181,7 +182,7 @@
     }
     try {
       if (notify.shouldNotify()) {
-        emailReviewers(ctx.getProject(), currChange, changeMessage, notify);
+        emailReviewers(ctx.getProject(), currChange, changeMessage, notify, ctx.getRepoView());
       }
     } catch (Exception err) {
       logger.atSevere().withCause(err).log("Cannot email update for change %s", currChange.getId());
@@ -215,18 +216,22 @@
       Project.NameKey projectName,
       Change change,
       ChangeMessage changeMessage,
-      NotifyResolver.Result notify)
+      NotifyResolver.Result notify,
+      RepoView repoView)
       throws EmailException {
     Account.Id userId = user.get().getAccountId();
     if (userId.equals(reviewer.account().id())) {
       // The user knows they removed themselves, don't bother emailing them.
       return;
     }
-    DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
-    cm.setFrom(userId);
-    cm.addReviewers(Collections.singleton(reviewer.account().id()));
-    cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-    cm.setNotify(notify);
-    cm.send();
+    DeleteReviewerSender emailSender =
+        deleteReviewerSenderFactory.create(projectName, change.getId());
+    emailSender.setFrom(userId);
+    emailSender.addReviewers(Collections.singleton(reviewer.account().id()));
+    emailSender.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+    emailSender.setNotify(notify);
+    emailSender.setMessageId(
+        messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
+    emailSender.send();
   }
 }
diff --git a/java/com/google/gerrit/server/change/DraftCommentResource.java b/java/com/google/gerrit/server/change/DraftCommentResource.java
index 3d3e8f9..e0648cf 100644
--- a/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ b/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.change;
 
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -27,9 +27,9 @@
       new TypeLiteral<RestView<DraftCommentResource>>() {};
 
   private final RevisionResource rev;
-  private final Comment comment;
+  private final HumanComment comment;
 
-  public DraftCommentResource(RevisionResource rev, Comment c) {
+  public DraftCommentResource(RevisionResource rev, HumanComment c) {
     this.rev = rev;
     this.comment = c;
   }
@@ -46,7 +46,7 @@
     return rev.getPatchSet();
   }
 
-  public Comment getComment() {
+  public HumanComment getComment() {
     return comment;
   }
 
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index f7e45e7..fe254e0 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -25,8 +25,10 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.CommentSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -63,24 +65,27 @@
         PatchSet patchSet,
         IdentifiedUser user,
         ChangeMessage message,
-        List<Comment> comments,
+        List<? extends Comment> comments,
         String patchSetComment,
-        List<LabelVote> labels);
+        List<LabelVote> labels,
+        RepoView repoView);
   }
 
   private final ExecutorService sendEmailsExecutor;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final CommentSender.Factory commentSenderFactory;
   private final ThreadLocalRequestContext requestContext;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final NotifyResolver.Result notify;
   private final ChangeNotes notes;
   private final PatchSet patchSet;
   private final IdentifiedUser user;
   private final ChangeMessage message;
-  private final List<Comment> comments;
+  private final List<? extends Comment> comments;
   private final String patchSetComment;
   private final List<LabelVote> labels;
+  private final RepoView repoView;
 
   @Inject
   EmailReviewComments(
@@ -88,18 +93,21 @@
       PatchSetInfoFactory patchSetInfoFactory,
       CommentSender.Factory commentSenderFactory,
       ThreadLocalRequestContext requestContext,
+      MessageIdGenerator messageIdGenerator,
       @Assisted NotifyResolver.Result notify,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet patchSet,
       @Assisted IdentifiedUser user,
       @Assisted ChangeMessage message,
-      @Assisted List<Comment> comments,
+      @Assisted List<? extends Comment> comments,
       @Nullable @Assisted String patchSetComment,
-      @Assisted List<LabelVote> labels) {
+      @Assisted List<LabelVote> labels,
+      @Assisted RepoView repoView) {
     this.sendEmailsExecutor = executor;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.commentSenderFactory = commentSenderFactory;
     this.requestContext = requestContext;
+    this.messageIdGenerator = messageIdGenerator;
     this.notify = notify;
     this.notes = notes;
     this.patchSet = patchSet;
@@ -108,6 +116,7 @@
     this.comments = COMMENT_ORDER.sortedCopy(comments);
     this.patchSetComment = patchSetComment;
     this.labels = labels;
+    this.repoView = repoView;
   }
 
   public void sendAsync() {
@@ -119,15 +128,17 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      CommentSender cm = commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
-      cm.setFrom(user.getAccountId());
-      cm.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
-      cm.setChangeMessage(message.getMessage(), message.getWrittenOn());
-      cm.setComments(comments);
-      cm.setPatchSetComment(patchSetComment);
-      cm.setLabels(labels);
-      cm.setNotify(notify);
-      cm.send();
+      CommentSender emailSender =
+          commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
+      emailSender.setFrom(user.getAccountId());
+      emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
+      emailSender.setChangeMessage(message.getMessage(), message.getWrittenOn());
+      emailSender.setComments(comments);
+      emailSender.setPatchSetComment(patchSetComment);
+      emailSender.setLabels(labels);
+      emailSender.setNotify(notify);
+      emailSender.setMessageId(messageIdGenerator.fromChangeUpdate(repoView, patchSet.id()));
+      emailSender.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
     } finally {
diff --git a/java/com/google/gerrit/server/change/HumanCommentResource.java b/java/com/google/gerrit/server/change/HumanCommentResource.java
new file mode 100644
index 0000000..1611aaa
--- /dev/null
+++ b/java/com/google/gerrit/server/change/HumanCommentResource.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class HumanCommentResource implements RestResource {
+  public static final TypeLiteral<RestView<HumanCommentResource>> COMMENT_KIND =
+      new TypeLiteral<RestView<HumanCommentResource>>() {};
+
+  private final RevisionResource rev;
+  private final HumanComment comment;
+
+  public HumanCommentResource(RevisionResource rev, HumanComment c) {
+    this.rev = rev;
+    this.comment = c;
+  }
+
+  public PatchSet getPatchSet() {
+    return rev.getPatchSet();
+  }
+
+  public HumanComment getComment() {
+    return comment;
+  }
+
+  public String getId() {
+    return comment.key.uuid;
+  }
+
+  public Account.Id getAuthorId() {
+    return comment.author.getId();
+  }
+
+  public RevisionResource getRevisionResource() {
+    return rev;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index 67cd0df..30343d4 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -22,10 +22,10 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectCache;
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index c6f4969..b1d154c 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -32,12 +32,12 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
@@ -122,7 +122,7 @@
       if (rec.labels != null) {
         for (SubmitRecord.Label r : rec.labels) {
           LabelType type = labelTypes.byLabel(r.label);
-          if (type != null && (!isMerged || type.allowPostSubmit())) {
+          if (type != null && (!isMerged || type.isAllowPostSubmit())) {
             toCheck.put(type.getName(), type);
           }
         }
@@ -131,7 +131,7 @@
 
     Map<String, Short> labels = null;
     Set<LabelPermission.WithValue> can =
-        permissionBackendForChange(filterApprovalsBy, cd).testLabels(toCheck.values());
+        permissionBackend.absentUser(filterApprovalsBy).change(cd).testLabels(toCheck.values());
     SetMultimap<String, String> permitted = LinkedHashMultimap.create();
     for (SubmitRecord rec : submitRecords(cd)) {
       if (rec.labels == null) {
@@ -139,7 +139,7 @@
       }
       for (SubmitRecord.Label r : rec.labels) {
         LabelType type = labelTypes.byLabel(r.label);
-        if (type == null || (isMerged && !type.allowPostSubmit())) {
+        if (type == null || (isMerged && !type.isAllowPostSubmit())) {
           continue;
         }
 
@@ -452,7 +452,7 @@
 
     LabelTypes labelTypes = cd.getLabelTypes();
     for (Account.Id accountId : allUsers) {
-      PermissionBackend.ForChange perm = permissionBackendForChange(accountId, cd);
+      PermissionBackend.ForChange perm = permissionBackend.absentUser(accountId).change(cd);
       Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
         LabelType lt = labelTypes.byLabel(e.getKey());
@@ -492,18 +492,6 @@
     }
   }
 
-  /**
-   * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
-   *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
-   *     lazyload}.
-   */
-  private PermissionBackend.ForChange permissionBackendForChange(Account.Id user, ChangeData cd) {
-    PermissionBackend.WithUser withUser = permissionBackend.absentUser(user);
-    return lazyLoad
-        ? withUser.change(cd)
-        : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
-  }
-
   private List<SubmitRecord> submitRecords(ChangeData cd) {
     return cd.submitRecords(ChangeJson.SUBMIT_RULE_OPTIONS_LENIENT);
   }
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 988d178..882352d 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -79,6 +80,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final WorkInProgressStateChanged wipStateChanged;
+  private final MessageIdGenerator messageIdGenerator;
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
@@ -120,6 +122,7 @@
       RevisionCreated revisionCreated,
       ProjectCache projectCache,
       WorkInProgressStateChanged wipStateChanged,
+      MessageIdGenerator messageIdGenerator,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
       @Assisted ObjectId commitId) {
@@ -133,6 +136,7 @@
     this.revisionCreated = revisionCreated;
     this.projectCache = projectCache;
     this.wipStateChanged = wipStateChanged;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.origNotes = notes;
     this.psId = psId;
@@ -284,14 +288,17 @@
     if (notify.shouldNotify() && sendEmail) {
       requireNonNull(changeMessage);
       try {
-        ReplacePatchSetSender cm = replacePatchSetFactory.create(ctx.getProject(), change.getId());
-        cm.setFrom(ctx.getAccountId());
-        cm.setPatchSet(patchSet, patchSetInfo);
-        cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
-        cm.addReviewers(oldReviewers.byState(REVIEWER));
-        cm.addExtraCC(oldReviewers.byState(CC));
-        cm.setNotify(notify);
-        cm.send();
+        ReplacePatchSetSender emailSender =
+            replacePatchSetFactory.create(ctx.getProject(), change.getId());
+        emailSender.setFrom(ctx.getAccountId());
+        emailSender.setPatchSet(patchSet, patchSetInfo);
+        emailSender.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+        emailSender.addReviewers(oldReviewers.byState(REVIEWER));
+        emailSender.addExtraCC(oldReviewers.byState(CC));
+        emailSender.setNotify(notify);
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+        emailSender.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
             "Cannot send email for new patch set on change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 07f0d78..e532409 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -14,71 +14,109 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.Map;
-import java.util.function.Function;
+import java.io.IOException;
+import java.util.Optional;
 
 /** Remove a specified user from the attention set. */
 public class RemoveFromAttentionSetOp implements BatchUpdateOp {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason);
+    RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final ChangeMessagesUtil cmUtil;
+  private final MessageIdGenerator messageIdGenerator;
+  private final RemoveFromAttentionSetSender.Factory removeFromAttentionSetSender;
+  private final AttentionSetEmail.Factory attentionSetEmailFactory;
+
   private final Account.Id attentionUserId;
   private final String reason;
 
+  private Change change;
+  private boolean notify;
+
+  /**
+   * Remove a specified user from the attention set.
+   *
+   * @param attentionUserId the id of the user we want to add to the attention set.
+   * @param reason the reason for adding that user.
+   * @param notify whether or not to send emails if the operation is successful.
+   */
   @Inject
   RemoveFromAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      ChangeMessagesUtil cmUtil,
+      MessageIdGenerator messageIdGenerator,
+      RemoveFromAttentionSetSender.Factory removeFromAttentionSetSenderFactory,
+      AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
-      @Assisted String reason) {
+      @Assisted String reason,
+      @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.cmUtil = cmUtil;
+    this.messageIdGenerator = messageIdGenerator;
+    this.removeFromAttentionSetSender = removeFromAttentionSetSenderFactory;
+    this.attentionSetEmailFactory = attentionSetEmailFactory;
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
+    this.notify = notify;
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx) throws RestApiException {
     ChangeData changeData = changeDataFactory.create(ctx.getNotes());
-    Map<Account.Id, AttentionSetUpdate> attentionMap =
+    Optional<AttentionSetUpdate> existingEntry =
         changeData.attentionSet().stream()
-            .collect(toImmutableMap(AttentionSetUpdate::account, Function.identity()));
-    AttentionSetUpdate existingEntry = attentionMap.get(attentionUserId);
-    if (existingEntry == null || existingEntry.operation() == Operation.REMOVE) {
-      return false;
+            .filter(u -> u.account().equals(attentionUserId))
+            .findAny();
+    if (!existingEntry.isPresent() || existingEntry.get().operation() == Operation.REMOVE) {
+      // We still need to perform this update to ensure that we don't add the user in a follow-up
+      // operation, but no need to send an email about it.
+      notify = false;
     }
 
+    change = ctx.getChange();
+
     ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.setAttentionSetUpdates(
-        ImmutableSet.of(
-            AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason)));
-    addMessage(ctx, update);
+    update.addToPlannedAttentionSetUpdates(
+        AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason));
     return true;
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
-    String message = "Removed from attention set: " + attentionUserId;
-    cmUtil.addChangeMessage(
-        update,
-        ChangeMessagesUtil.newMessage(ctx, message, ChangeMessagesUtil.TAG_UPDATE_ATTENTION_SET));
+  @Override
+  public void postUpdate(Context ctx) {
+    if (!notify) {
+      return;
+    }
+    try {
+      attentionSetEmailFactory
+          .create(
+              removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
+              ctx,
+              change,
+              reason,
+              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
+              attentionUserId)
+          .sendAsync();
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index d9462bf..3d986d2 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -31,12 +31,13 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -48,7 +49,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -129,8 +129,12 @@
   }
 
   public static Optional<InternalAddReviewerInput> newAddReviewerInputFromCommitIdentity(
-      Change change, ObjectId commitId, @Nullable Account.Id accountId, NotifyHandling notify) {
-    if (accountId == null || accountId.equals(change.getOwner())) {
+      Change change,
+      ObjectId commitId,
+      @Nullable Account.Id accountId,
+      NotifyHandling notify,
+      Account.Id mostRecentUploader) {
+    if (accountId == null || accountId.equals(mostRecentUploader)) {
       // If git ident couldn't be resolved to a user, or if it's not forged, do nothing.
       return Optional.empty();
     }
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index 39e5f74..a3136d4a 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -14,19 +14,19 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.gerrit.common.data.LabelValue.formatValue;
+import static com.google.gerrit.entities.LabelValue.formatValue;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.permissions.LabelPermission;
diff --git a/java/com/google/gerrit/server/change/ReviewerResource.java b/java/com/google/gerrit/server/change/ReviewerResource.java
index df0a03f..7a98f2b 100644
--- a/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -18,10 +18,10 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 001a532..414107f 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -31,7 +31,6 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
@@ -60,7 +59,6 @@
 import com.google.gerrit.server.account.GpgApiAdapter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -107,8 +105,6 @@
   private final AnonymousUser anonymous;
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
-  private final ChangeNotes.Factory notesFactory;
-  private final boolean lazyLoad;
 
   @Inject
   RevisionJson(
@@ -128,7 +124,6 @@
       ChangeKindCache changeKindCache,
       GitRepositoryManager repoManager,
       PermissionBackend permissionBackend,
-      ChangeNotes.Factory notesFactory,
       @Assisted Iterable<ListChangesOption> options) {
     this.userProvider = userProvider;
     this.anonymous = anonymous;
@@ -145,10 +140,8 @@
     this.changeResourceFactory = changeResourceFactory;
     this.changeKindCache = changeKindCache;
     this.permissionBackend = permissionBackend;
-    this.notesFactory = notesFactory;
     this.repoManager = repoManager;
     this.options = ImmutableSet.copyOf(options);
-    this.lazyLoad = containsAnyOf(this.options, ChangeJson.REQUIRE_LAZY_LOAD);
   }
 
   /**
@@ -346,22 +339,9 @@
     return options.contains(option);
   }
 
-  /**
-   * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
-   *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
-   *     lazyload}.
-   */
-  private PermissionBackend.ForChange permissionBackendForChange(
-      PermissionBackend.WithUser withUser, ChangeData cd) {
-    return lazyLoad
-        ? withUser.change(cd)
-        : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
-  }
-
   private boolean isWorldReadable(ChangeData cd) throws PermissionBackendException {
     try {
-      permissionBackendForChange(permissionBackend.user(anonymous), cd)
-          .check(ChangePermission.READ);
+      permissionBackend.user(anonymous).change(cd).check(ChangePermission.READ);
     } catch (AuthException ae) {
       return false;
     }
@@ -382,9 +362,4 @@
   private RevWalk newRevWalk(@Nullable Repository repo) {
     return repo != null ? new RevWalk(repo) : null;
   }
-
-  private static boolean containsAnyOf(
-      ImmutableSet<ListChangesOption> set, ImmutableSet<ListChangesOption> toFind) {
-    return !Sets.intersection(toFind, set).isEmpty();
-  }
 }
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
index 9848150..411c9b6 100644
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.AssigneeChanged;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.SetAssigneeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -50,6 +51,7 @@
   private final SetAssigneeSender.Factory setAssigneeSenderFactory;
   private final Provider<IdentifiedUser> user;
   private final IdentifiedUser.GenericFactory userFactory;
+  private final MessageIdGenerator messageIdGenerator;
 
   private Change change;
   private IdentifiedUser oldAssignee;
@@ -62,6 +64,7 @@
       SetAssigneeSender.Factory setAssigneeSenderFactory,
       Provider<IdentifiedUser> user,
       IdentifiedUser.GenericFactory userFactory,
+      MessageIdGenerator messageIdGenerator,
       @Assisted IdentifiedUser newAssignee) {
     this.cmUtil = cmUtil;
     this.validationListeners = validationListeners;
@@ -69,6 +72,7 @@
     this.setAssigneeSenderFactory = setAssigneeSenderFactory;
     this.user = user;
     this.userFactory = userFactory;
+    this.messageIdGenerator = messageIdGenerator;
     this.newAssignee = requireNonNull(newAssignee, "assignee");
   }
 
@@ -118,11 +122,13 @@
   @Override
   public void postUpdate(Context ctx) {
     try {
-      SetAssigneeSender cm =
+      SetAssigneeSender emailSender =
           setAssigneeSenderFactory.create(
               change.getProject(), change.getId(), newAssignee.getAccountId());
-      cm.setFrom(user.get().getAccountId());
-      cm.send();
+      emailSender.setFrom(user.get().getAccountId());
+      emailSender.setMessageId(
+          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+      emailSender.send();
     } catch (Exception err) {
       logger.atSevere().withCause(err).log(
           "Cannot send email to new assignee of change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 283cff8..f0ebb80 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -30,8 +31,10 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
 
 /* Set work in progress or ready for review state on a change */
 public class WorkInProgressOp implements BatchUpdateOp {
@@ -131,6 +134,13 @@
         || !sendEmail) {
       return;
     }
+    RepoView repoView;
+    try {
+      repoView = ctx.getRepoView();
+    } catch (IOException ex) {
+      throw new StorageException(
+          String.format("Repository %s not found", ctx.getProject().get()), ex);
+    }
     email
         .create(
             notify,
@@ -140,7 +150,8 @@
             cmsg,
             ImmutableList.of(),
             cmsg.getMessage(),
-            ImmutableList.of())
+            ImmutableList.of(),
+            repoView)
         .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java b/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
index d6e61c4..9f6ecfb5 100644
--- a/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
+++ b/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
@@ -16,8 +16,8 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.util.RequestContext;
diff --git a/java/com/google/gerrit/server/config/AllProjectsName.java b/java/com/google/gerrit/server/config/AllProjectsName.java
index 6d5525c..3a13a58 100644
--- a/java/com/google/gerrit/server/config/AllProjectsName.java
+++ b/java/com/google/gerrit/server/config/AllProjectsName.java
@@ -14,9 +14,15 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.entities.Project;
 
-/** Special name of the project that all projects derive from. */
+/**
+ * Special name of the project that all projects derive from.
+ *
+ * <p>This class is immutable and thread safe.
+ */
+@Immutable
 public class AllProjectsName extends Project.NameKey {
   private static final long serialVersionUID = 1L;
 
diff --git a/java/com/google/gerrit/server/config/AllUsersName.java b/java/com/google/gerrit/server/config/AllUsersName.java
index aa92db8..393fb6b 100644
--- a/java/com/google/gerrit/server/config/AllUsersName.java
+++ b/java/com/google/gerrit/server/config/AllUsersName.java
@@ -14,9 +14,15 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.errorprone.annotations.Immutable;
 import com.google.gerrit.entities.Project;
 
-/** Special name of the project in which meta data for all users is stored. */
+/**
+ * Special name of the project in which meta data for all users is stored.
+ *
+ * <p>This class is immutable and thread safe.
+ */
+@Immutable
 public class AllUsersName extends Project.NameKey {
   private static final long serialVersionUID = 1L;
 
diff --git a/java/com/google/gerrit/server/config/CachedPreferences.java b/java/com/google/gerrit/server/config/CachedPreferences.java
index f4dcd10..388f58a 100644
--- a/java/com/google/gerrit/server/config/CachedPreferences.java
+++ b/java/com/google/gerrit/server/config/CachedPreferences.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
-import com.google.gerrit.server.account.StoredPreferences;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -52,7 +51,7 @@
   public static GeneralPreferencesInfo general(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
     try {
-      return StoredPreferences.parseGeneralPreferences(
+      return PreferencesParserUtil.parseGeneralPreferences(
           userPreferences.asConfig(), configOrNull(defaultPreferences), null);
     } catch (ConfigInvalidException e) {
       return GeneralPreferencesInfo.defaults();
@@ -62,7 +61,7 @@
   public static EditPreferencesInfo edit(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
     try {
-      return StoredPreferences.parseEditPreferences(
+      return PreferencesParserUtil.parseEditPreferences(
           userPreferences.asConfig(), configOrNull(defaultPreferences), null);
     } catch (ConfigInvalidException e) {
       return EditPreferencesInfo.defaults();
@@ -72,7 +71,7 @@
   public static DiffPreferencesInfo diff(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
     try {
-      return StoredPreferences.parseDiffPreferences(
+      return PreferencesParserUtil.parseDiffPreferences(
           userPreferences.asConfig(), configOrNull(defaultPreferences), null);
     } catch (ConfigInvalidException e) {
       return DiffPreferencesInfo.defaults();
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index cf592bf..c7a4a9a 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -95,6 +95,7 @@
 import com.google.gerrit.server.account.GroupCacheImpl;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
+import com.google.gerrit.server.account.NonInteractiveUserGroupRobotClassifier;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.auth.AuthBackend;
@@ -239,6 +240,7 @@
     install(GroupCacheImpl.module());
     install(GroupIncludeCacheImpl.module());
     install(MergeabilityCacheImpl.module());
+    install(NonInteractiveUserGroupRobotClassifier.module());
     install(PatchListCacheImpl.module());
     install(ProjectCacheImpl.module());
     install(SectionSortCache.module());
diff --git a/java/com/google/gerrit/server/config/GroupSetProvider.java b/java/com/google/gerrit/server/config/GroupSetProvider.java
index 7f487e1..025946d 100644
--- a/java/com/google/gerrit/server/config/GroupSetProvider.java
+++ b/java/com/google/gerrit/server/config/GroupSetProvider.java
@@ -16,8 +16,8 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.util.RequestContext;
diff --git a/java/com/google/gerrit/server/config/PluginConfig.java b/java/com/google/gerrit/server/config/PluginConfig.java
index 13c5442..7dd981c 100644
--- a/java/com/google/gerrit/server/config/PluginConfig.java
+++ b/java/com/google/gerrit/server/config/PluginConfig.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2013 The Android Open Source Project
+// Copyright (C) 2020 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,63 +14,87 @@
 
 package com.google.gerrit.server.config;
 
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
-public class PluginConfig {
+@AutoValue
+public abstract class PluginConfig {
   private static final String PLUGIN = "plugin";
 
-  private final String pluginName;
-  private Config cfg;
-  private final ProjectConfig projectConfig;
+  protected abstract String pluginName();
 
-  public PluginConfig(String pluginName, Config cfg) {
-    this(pluginName, cfg, null);
-  }
+  protected abstract Config cfg();
 
-  public PluginConfig(String pluginName, Config cfg, ProjectConfig projectConfig) {
-    this.pluginName = pluginName;
-    this.cfg = cfg;
-    this.projectConfig = projectConfig;
+  protected abstract Optional<CachedProjectConfig> projectConfig();
+
+  /** Mappings parsed from {@code groups} files. */
+  protected abstract ImmutableMap<AccountGroup.UUID, GroupReference> groupReferences();
+
+  public static PluginConfig create(
+      String pluginName, Config cfg, @Nullable CachedProjectConfig projectConfig) {
+    ImmutableMap.Builder<AccountGroup.UUID, GroupReference> groupReferences =
+        ImmutableMap.builder();
+    if (projectConfig != null) {
+      groupReferences.putAll(projectConfig.getGroups());
+    }
+    return new AutoValue_PluginConfig(
+        pluginName, copyConfig(cfg), Optional.ofNullable(projectConfig), groupReferences.build());
   }
 
   PluginConfig withInheritance(ProjectState.Factory projectStateFactory) {
-    if (projectConfig == null) {
+    checkState(projectConfig().isPresent(), "no project config provided");
+
+    ProjectState state = projectStateFactory.create(projectConfig().get());
+    ProjectState parent = Iterables.getFirst(state.parents(), null);
+    if (parent == null) {
       return this;
     }
 
-    ProjectState state = projectStateFactory.create(projectConfig);
-    ProjectState parent = Iterables.getFirst(state.parents(), null);
-    if (parent != null) {
-      PluginConfig parentPluginConfig =
-          parent.getConfig().getPluginConfig(pluginName).withInheritance(projectStateFactory);
-      Set<String> allNames = cfg.getNames(PLUGIN, pluginName);
-      cfg = copyConfig(cfg);
-      for (String name : parentPluginConfig.cfg.getNames(PLUGIN, pluginName)) {
-        if (!allNames.contains(name)) {
-          List<String> values =
-              Arrays.asList(parentPluginConfig.cfg.getStringList(PLUGIN, pluginName, name));
-          for (String value : values) {
-            GroupReference groupRef =
-                parentPluginConfig.projectConfig.getGroup(GroupReference.extractGroupName(value));
-            if (groupRef != null) {
-              projectConfig.resolve(groupRef);
-            }
+    Map<AccountGroup.UUID, GroupReference> groupReferences = new HashMap<>();
+    groupReferences.putAll(groupReferences());
+    PluginConfig parentPluginConfig =
+        parent.getPluginConfig(pluginName()).withInheritance(projectStateFactory);
+    Set<String> allNames = cfg().getNames(PLUGIN, pluginName());
+    Config newCfg = copyConfig(cfg());
+    for (String name : parentPluginConfig.cfg().getNames(PLUGIN, pluginName())) {
+      if (!allNames.contains(name)) {
+        List<String> values =
+            Arrays.asList(parentPluginConfig.cfg().getStringList(PLUGIN, pluginName(), name));
+        for (String value : values) {
+          Optional<GroupReference> groupRef =
+              parentPluginConfig
+                  .projectConfig()
+                  .get()
+                  .getGroupByName(GroupReference.extractGroupName(value));
+          if (groupRef.isPresent()) {
+            groupReferences.putIfAbsent(groupRef.get().getUUID(), groupRef.get());
           }
-          cfg.setStringList(PLUGIN, pluginName, name, values);
         }
+        newCfg.setStringList(PLUGIN, pluginName(), name, values);
       }
     }
-    return this;
+    return new AutoValue_PluginConfig(
+        pluginName(), newCfg, projectConfig(), ImmutableMap.copyOf(groupReferences));
   }
 
   private static Config copyConfig(Config cfg) {
@@ -85,86 +109,150 @@
   }
 
   public String getString(String name) {
-    return cfg.getString(PLUGIN, pluginName, name);
+    return cfg().getString(PLUGIN, pluginName(), name);
   }
 
   public String getString(String name, String defaultValue) {
     if (defaultValue == null) {
-      return cfg.getString(PLUGIN, pluginName, name);
+      return cfg().getString(PLUGIN, pluginName(), name);
     }
-    return MoreObjects.firstNonNull(cfg.getString(PLUGIN, pluginName, name), defaultValue);
-  }
-
-  public void setString(String name, String value) {
-    if (Strings.isNullOrEmpty(value)) {
-      cfg.unset(PLUGIN, pluginName, name);
-    } else {
-      cfg.setString(PLUGIN, pluginName, name, value);
-    }
+    return MoreObjects.firstNonNull(cfg().getString(PLUGIN, pluginName(), name), defaultValue);
   }
 
   public String[] getStringList(String name) {
-    return cfg.getStringList(PLUGIN, pluginName, name);
-  }
-
-  public void setStringList(String name, List<String> values) {
-    if (values == null || values.isEmpty()) {
-      cfg.unset(PLUGIN, pluginName, name);
-    } else {
-      cfg.setStringList(PLUGIN, pluginName, name, values);
-    }
+    return cfg().getStringList(PLUGIN, pluginName(), name);
   }
 
   public int getInt(String name, int defaultValue) {
-    return cfg.getInt(PLUGIN, pluginName, name, defaultValue);
-  }
-
-  public void setInt(String name, int value) {
-    cfg.setInt(PLUGIN, pluginName, name, value);
+    return cfg().getInt(PLUGIN, pluginName(), name, defaultValue);
   }
 
   public long getLong(String name, long defaultValue) {
-    return cfg.getLong(PLUGIN, pluginName, name, defaultValue);
-  }
-
-  public void setLong(String name, long value) {
-    cfg.setLong(PLUGIN, pluginName, name, value);
+    return cfg().getLong(PLUGIN, pluginName(), name, defaultValue);
   }
 
   public boolean getBoolean(String name, boolean defaultValue) {
-    return cfg.getBoolean(PLUGIN, pluginName, name, defaultValue);
-  }
-
-  public void setBoolean(String name, boolean value) {
-    cfg.setBoolean(PLUGIN, pluginName, name, value);
+    return cfg().getBoolean(PLUGIN, pluginName(), name, defaultValue);
   }
 
   public <T extends Enum<?>> T getEnum(String name, T defaultValue) {
-    return cfg.getEnum(PLUGIN, pluginName, name, defaultValue);
-  }
-
-  public <T extends Enum<?>> void setEnum(String name, T value) {
-    cfg.setEnum(PLUGIN, pluginName, name, value);
+    return cfg().getEnum(PLUGIN, pluginName(), name, defaultValue);
   }
 
   public <T extends Enum<?>> T getEnum(T[] all, String name, T defaultValue) {
-    return cfg.getEnum(all, PLUGIN, pluginName, name, defaultValue);
-  }
-
-  public void unset(String name) {
-    cfg.unset(PLUGIN, pluginName, name);
+    return cfg().getEnum(all, PLUGIN, pluginName(), name, defaultValue);
   }
 
   public Set<String> getNames() {
-    return cfg.getNames(PLUGIN, pluginName, true);
+    return cfg().getNames(PLUGIN, pluginName(), true);
   }
 
-  public GroupReference getGroupReference(String name) {
-    return projectConfig.getGroup(GroupReference.extractGroupName(getString(name)));
+  public Optional<GroupReference> getGroupReference(String name) {
+    String exactName = GroupReference.extractGroupName(getString(name));
+    return groupReferences().values().stream().filter(g -> exactName.equals(g.getName())).findAny();
   }
 
-  public void setGroupReference(String name, GroupReference value) {
-    GroupReference groupRef = projectConfig.resolve(value);
-    setString(name, groupRef.toConfigValue());
+  /** Mutable representation of {@link PluginConfig}. Used for updates. */
+  public static class Update {
+    private final String pluginName;
+    private Config cfg;
+    private final Optional<ProjectConfig> projectConfig;
+
+    public Update(String pluginName, Config cfg, Optional<ProjectConfig> projectConfig) {
+      this.pluginName = pluginName;
+      this.cfg = cfg;
+      this.projectConfig = projectConfig;
+    }
+
+    @VisibleForTesting
+    public static Update forTest(String pluginName, Config cfg) {
+      return new Update(pluginName, cfg, Optional.empty());
+    }
+
+    public PluginConfig asPluginConfig() {
+      return PluginConfig.create(
+          pluginName, cfg, projectConfig.map(ProjectConfig::getCacheable).orElse(null));
+    }
+
+    public String getString(String name) {
+      return cfg.getString(PLUGIN, pluginName, name);
+    }
+
+    public String getString(String name, String defaultValue) {
+      if (defaultValue == null) {
+        return cfg.getString(PLUGIN, pluginName, name);
+      }
+      return MoreObjects.firstNonNull(cfg.getString(PLUGIN, pluginName, name), defaultValue);
+    }
+
+    public String[] getStringList(String name) {
+      return cfg.getStringList(PLUGIN, pluginName, name);
+    }
+
+    public int getInt(String name, int defaultValue) {
+      return cfg.getInt(PLUGIN, pluginName, name, defaultValue);
+    }
+
+    public long getLong(String name, long defaultValue) {
+      return cfg.getLong(PLUGIN, pluginName, name, defaultValue);
+    }
+
+    public boolean getBoolean(String name, boolean defaultValue) {
+      return cfg.getBoolean(PLUGIN, pluginName, name, defaultValue);
+    }
+
+    public <T extends Enum<?>> T getEnum(String name, T defaultValue) {
+      return cfg.getEnum(PLUGIN, pluginName, name, defaultValue);
+    }
+
+    public <T extends Enum<?>> T getEnum(T[] all, String name, T defaultValue) {
+      return cfg.getEnum(all, PLUGIN, pluginName, name, defaultValue);
+    }
+
+    public Set<String> getNames() {
+      return cfg.getNames(PLUGIN, pluginName, true);
+    }
+
+    public void setString(String name, String value) {
+      if (Strings.isNullOrEmpty(value)) {
+        cfg.unset(PLUGIN, pluginName, name);
+      } else {
+        cfg.setString(PLUGIN, pluginName, name, value);
+      }
+    }
+
+    public void setStringList(String name, List<String> values) {
+      if (values == null || values.isEmpty()) {
+        cfg.unset(PLUGIN, pluginName, name);
+      } else {
+        cfg.setStringList(PLUGIN, pluginName, name, values);
+      }
+    }
+
+    public void setInt(String name, int value) {
+      cfg.setInt(PLUGIN, pluginName, name, value);
+    }
+
+    public void setLong(String name, long value) {
+      cfg.setLong(PLUGIN, pluginName, name, value);
+    }
+
+    public void setBoolean(String name, boolean value) {
+      cfg.setBoolean(PLUGIN, pluginName, name, value);
+    }
+
+    public <T extends Enum<?>> void setEnum(String name, T value) {
+      cfg.setEnum(PLUGIN, pluginName, name, value);
+    }
+
+    public void unset(String name) {
+      cfg.unset(PLUGIN, pluginName, name);
+    }
+
+    public void setGroupReference(String name, GroupReference value) {
+      checkState(projectConfig.isPresent(), "no project config provided");
+      GroupReference groupRef = projectConfig.get().resolve(value);
+      setString(name, groupRef.toConfigValue());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index 483fc0a..0028d63 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -111,7 +111,7 @@
       cfgSnapshot = FileSnapshot.save(configFile);
       cfg = cfgProvider.get();
     }
-    return new PluginConfig(pluginName, cfg);
+    return PluginConfig.create(pluginName, cfg, null);
   }
 
   /**
@@ -150,7 +150,7 @@
    * @return the plugin configuration from the 'project.config' file of the specified project
    */
   public PluginConfig getFromProjectConfig(ProjectState projectState, String pluginName) {
-    return projectState.getConfig().getPluginConfig(pluginName);
+    return projectState.getPluginConfig(pluginName);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/config/PreferencesParserUtil.java b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
new file mode 100644
index 0000000..69d75be
--- /dev/null
+++ b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
@@ -0,0 +1,266 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static com.google.gerrit.server.config.ConfigUtil.loadSection;
+import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
+import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_TARGET;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.server.git.UserConfigSections;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/** Helper to read default or user preferences from Git-style config files. */
+public class PreferencesParserUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private PreferencesParserUtil() {}
+
+  /**
+   * Returns a {@link GeneralPreferencesInfo} that is the result of parsing {@code defaultCfg} for
+   * the server's default configs and {@code cfg} for the user's config. These configs are then
+   * overlaid to inherit values (default -> user -> input (if provided).
+   */
+  public static GeneralPreferencesInfo parseGeneralPreferences(
+      Config cfg, @Nullable Config defaultCfg, @Nullable GeneralPreferencesInfo input)
+      throws ConfigInvalidException {
+    GeneralPreferencesInfo r =
+        loadSection(
+            cfg,
+            UserConfigSections.GENERAL,
+            null,
+            new GeneralPreferencesInfo(),
+            defaultCfg != null
+                ? parseDefaultGeneralPreferences(defaultCfg, input)
+                : GeneralPreferencesInfo.defaults(),
+            input);
+    if (input != null) {
+      r.changeTable = input.changeTable;
+      r.my = input.my;
+    } else {
+      r.changeTable = parseChangeTableColumns(cfg, defaultCfg);
+      r.my = parseMyMenus(cfg, defaultCfg);
+    }
+    return r;
+  }
+
+  /**
+   * Returns a {@link GeneralPreferencesInfo} that is the result of parsing {@code defaultCfg} for
+   * the server's default configs. These configs are then overlaid to inherit values (default ->
+   * input (if provided).
+   */
+  public static GeneralPreferencesInfo parseDefaultGeneralPreferences(
+      Config defaultCfg, GeneralPreferencesInfo input) throws ConfigInvalidException {
+    GeneralPreferencesInfo allUserPrefs = new GeneralPreferencesInfo();
+    loadSection(
+        defaultCfg,
+        UserConfigSections.GENERAL,
+        null,
+        allUserPrefs,
+        GeneralPreferencesInfo.defaults(),
+        input);
+    return updateGeneralPreferencesDefaults(allUserPrefs);
+  }
+
+  /**
+   * Returns a {@link DiffPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs and {@code cfg} for the user's config. These configs are then overlaid
+   * to inherit values (default -> user -> input (if provided).
+   */
+  public static DiffPreferencesInfo parseDiffPreferences(
+      Config cfg, @Nullable Config defaultCfg, @Nullable DiffPreferencesInfo input)
+      throws ConfigInvalidException {
+    return loadSection(
+        cfg,
+        UserConfigSections.DIFF,
+        null,
+        new DiffPreferencesInfo(),
+        defaultCfg != null
+            ? parseDefaultDiffPreferences(defaultCfg, input)
+            : DiffPreferencesInfo.defaults(),
+        input);
+  }
+
+  /**
+   * Returns a {@link DiffPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs. These configs are then overlaid to inherit values (default -> input
+   * (if provided).
+   */
+  public static DiffPreferencesInfo parseDefaultDiffPreferences(
+      Config defaultCfg, DiffPreferencesInfo input) throws ConfigInvalidException {
+    DiffPreferencesInfo allUserPrefs = new DiffPreferencesInfo();
+    loadSection(
+        defaultCfg,
+        UserConfigSections.DIFF,
+        null,
+        allUserPrefs,
+        DiffPreferencesInfo.defaults(),
+        input);
+    return updateDiffPreferencesDefaults(allUserPrefs);
+  }
+
+  /**
+   * Returns a {@link EditPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs and {@code cfg} for the user's config. These configs are then overlaid
+   * to inherit values (default -> user -> input (if provided).
+   */
+  public static EditPreferencesInfo parseEditPreferences(
+      Config cfg, @Nullable Config defaultCfg, @Nullable EditPreferencesInfo input)
+      throws ConfigInvalidException {
+    return loadSection(
+        cfg,
+        UserConfigSections.EDIT,
+        null,
+        new EditPreferencesInfo(),
+        defaultCfg != null
+            ? parseDefaultEditPreferences(defaultCfg, input)
+            : EditPreferencesInfo.defaults(),
+        input);
+  }
+
+  /**
+   * Returns a {@link EditPreferencesInfo} that is the result of parsing {@code defaultCfg} for the
+   * server's default configs. These configs are then overlaid to inherit values (default -> input
+   * (if provided).
+   */
+  public static EditPreferencesInfo parseDefaultEditPreferences(
+      Config defaultCfg, EditPreferencesInfo input) throws ConfigInvalidException {
+    EditPreferencesInfo allUserPrefs = new EditPreferencesInfo();
+    loadSection(
+        defaultCfg,
+        UserConfigSections.EDIT,
+        null,
+        allUserPrefs,
+        EditPreferencesInfo.defaults(),
+        input);
+    return updateEditPreferencesDefaults(allUserPrefs);
+  }
+
+  private static List<String> parseChangeTableColumns(Config cfg, @Nullable Config defaultCfg) {
+    List<String> changeTable = changeTable(cfg);
+    if (changeTable == null && defaultCfg != null) {
+      changeTable = changeTable(defaultCfg);
+    }
+    return changeTable;
+  }
+
+  private static List<MenuItem> parseMyMenus(Config cfg, @Nullable Config defaultCfg) {
+    List<MenuItem> my = my(cfg);
+    if (my.isEmpty() && defaultCfg != null) {
+      my = my(defaultCfg);
+    }
+    if (my.isEmpty()) {
+      my.add(new MenuItem("Dashboard", "#/dashboard/self", null));
+      my.add(new MenuItem("Draft Comments", "#/q/has:draft", null));
+      my.add(new MenuItem("Edits", "#/q/has:edit", null));
+      my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
+      my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
+      my.add(new MenuItem("Groups", "#/settings/#Groups", null));
+    }
+    return my;
+  }
+
+  private static GeneralPreferencesInfo updateGeneralPreferencesDefaults(
+      GeneralPreferencesInfo input) {
+    GeneralPreferencesInfo result = GeneralPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      logger.atSevere().withCause(e).log("Failed to apply default general preferences");
+      return GeneralPreferencesInfo.defaults();
+    }
+    return result;
+  }
+
+  private static DiffPreferencesInfo updateDiffPreferencesDefaults(DiffPreferencesInfo input) {
+    DiffPreferencesInfo result = DiffPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      logger.atSevere().withCause(e).log("Failed to apply default diff preferences");
+      return DiffPreferencesInfo.defaults();
+    }
+    return result;
+  }
+
+  private static EditPreferencesInfo updateEditPreferencesDefaults(EditPreferencesInfo input) {
+    EditPreferencesInfo result = EditPreferencesInfo.defaults();
+    try {
+      for (Field field : input.getClass().getDeclaredFields()) {
+        if (skipField(field)) {
+          continue;
+        }
+        Object newVal = field.get(input);
+        if (newVal != null) {
+          field.set(result, newVal);
+        }
+      }
+    } catch (IllegalAccessException e) {
+      logger.atSevere().withCause(e).log("Failed to apply default edit preferences");
+      return EditPreferencesInfo.defaults();
+    }
+    return result;
+  }
+
+  private static List<String> changeTable(Config cfg) {
+    return Lists.newArrayList(cfg.getStringList(CHANGE_TABLE, null, CHANGE_TABLE_COLUMN));
+  }
+
+  private static List<MenuItem> my(Config cfg) {
+    List<MenuItem> my = new ArrayList<>();
+    for (String subsection : cfg.getSubsections(UserConfigSections.MY)) {
+      String url = my(cfg, subsection, KEY_URL, "#/");
+      String target = my(cfg, subsection, KEY_TARGET, url.startsWith("#") ? null : "_blank");
+      my.add(new MenuItem(subsection, url, target, my(cfg, subsection, KEY_ID, null)));
+    }
+    return my;
+  }
+
+  private static String my(Config cfg, String subsection, String key, String defaultValue) {
+    String val = cfg.getString(UserConfigSections.MY, subsection, key);
+    return !Strings.isNullOrEmpty(val) ? val : defaultValue;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/SitePaths.java b/java/com/google/gerrit/server/config/SitePaths.java
index ee95c6f..5e268da 100644
--- a/java/com/google/gerrit/server/config/SitePaths.java
+++ b/java/com/google/gerrit/server/config/SitePaths.java
@@ -30,6 +30,7 @@
   public static final String HEADER_FILENAME = "GerritSiteHeader.html";
   public static final String FOOTER_FILENAME = "GerritSiteFooter.html";
   public static final String THEME_FILENAME = "gerrit-theme.html";
+  public static final String THEME_JS_FILENAME = "gerrit-theme.js";
 
   public final Path site_path;
   public final Path bin_dir;
@@ -69,6 +70,7 @@
   public final Path site_header;
   public final Path site_footer;
   public final Path site_theme; // For PolyGerrit UI only.
+  public final Path site_theme_js; // For PolyGerrit UI only.
   public final Path site_gitweb;
 
   /** {@code true} if {@link #site_path} has not been initialized. */
@@ -119,6 +121,7 @@
 
     // For PolyGerrit UI.
     site_theme = static_dir.resolve(THEME_FILENAME);
+    site_theme_js = static_dir.resolve(THEME_JS_FILENAME);
 
     boolean isNew;
     try (DirectoryStream<Path> files = Files.newDirectoryStream(site_path)) {
diff --git a/java/com/google/gerrit/server/config/SysExecutorModule.java b/java/com/google/gerrit/server/config/SysExecutorModule.java
index e7f4540..ea45b12 100644
--- a/java/com/google/gerrit/server/config/SysExecutorModule.java
+++ b/java/com/google/gerrit/server/config/SysExecutorModule.java
@@ -14,7 +14,11 @@
 
 package com.google.gerrit.server.config;
 
+import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
+
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.AbstractModule;
@@ -24,7 +28,7 @@
 import org.eclipse.jgit.lib.Config;
 
 /**
- * Module providing the {@link ReceiveCommitsExecutor}.
+ * Module providing different executors.
  *
  * <p>This module is intended to be installed at the top level when creating a {@code sysInjector}
  * in {@code Daemon} or similar, not nested in another module. This ensures the module can be
@@ -37,7 +41,7 @@
   @Provides
   @Singleton
   @ReceiveCommitsExecutor
-  public ExecutorService createReceiveCommitsExecutor(
+  public ExecutorService provideReceiveCommitsExecutor(
       @GerritServerConfig Config config, WorkQueue queues) {
     int poolSize =
         config.getInt(
@@ -48,11 +52,11 @@
   @Provides
   @Singleton
   @SendEmailExecutor
-  public ExecutorService createSendEmailExecutor(
+  public ExecutorService provideSendEmailExecutor(
       @GerritServerConfig Config config, WorkQueue queues) {
     int poolSize = config.getInt("sendemail", null, "threadPoolSize", 1);
     if (poolSize == 0) {
-      return MoreExecutors.newDirectExecutorService();
+      return newDirectExecutorService();
     }
     return queues.createQueue(poolSize, "SendEmail", true);
   }
@@ -60,11 +64,24 @@
   @Provides
   @Singleton
   @FanOutExecutor
-  public ExecutorService createFanOutExecutor(@GerritServerConfig Config config, WorkQueue queues) {
+  public ExecutorService provideFanOutExecutor(
+      @GerritServerConfig Config config, WorkQueue queues) {
     int poolSize = config.getInt("execution", null, "fanOutThreadPoolSize", 25);
     if (poolSize == 0) {
-      return MoreExecutors.newDirectExecutorService();
+      return newDirectExecutorService();
     }
     return queues.createQueue(poolSize, "FanOut");
   }
+
+  @Provides
+  @Singleton
+  @CacheRefreshExecutor
+  public ListeningExecutorService provideCacheRefreshExecutor(
+      @GerritServerConfig Config config, WorkQueue queues) {
+    int poolSize = config.getInt("cache", null, "refreshThreadPoolSize", 2);
+    if (poolSize == 0) {
+      return newDirectExecutorService();
+    }
+    return MoreExecutors.listeningDecorator(queues.createQueue(poolSize, "CacheRefresh"));
+  }
 }
diff --git a/java/com/google/gerrit/server/config/UrlFormatter.java b/java/com/google/gerrit/server/config/UrlFormatter.java
index 6b2510e..d3f90e5 100644
--- a/java/com/google/gerrit/server/config/UrlFormatter.java
+++ b/java/com/google/gerrit/server/config/UrlFormatter.java
@@ -47,6 +47,16 @@
     return getWebUrl().map(url -> url + "c/" + project.get() + "/+/" + id.get());
   }
 
+  /** Returns the URL for viewing the comment tab view of a change. */
+  default Optional<String> getCommentsTabView(Change change) {
+    return getChangeViewUrl(change.getProject(), change.getId()).map(url -> url + "?tab=comments");
+  }
+
+  /** Returns the URL for viewing the findings tab view of a change. */
+  default Optional<String> getFindingsTabView(Change change) {
+    return getChangeViewUrl(change.getProject(), change.getId()).map(url -> url + "?tab=findings");
+  }
+
   /** Returns the URL for viewing a file in a given patch set of a change. */
   default Optional<String> getPatchFileView(Change change, int patchsetId, String filename) {
     return getChangeViewUrl(change.getProject(), change.getId())
diff --git a/java/com/google/gerrit/server/data/BUILD b/java/com/google/gerrit/server/data/BUILD
new file mode 100644
index 0000000..c3dc672
--- /dev/null
+++ b/java/com/google/gerrit/server/data/BUILD
@@ -0,0 +1,15 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "data",
+    srcs = glob(
+        ["*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/org/apache/commons/net",
+        "//lib:gson",
+    ],
+)
diff --git a/java/com/google/gerrit/server/data/SubmitLabelAttribute.java b/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
index fec8f7f..a3890c7 100644
--- a/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.data;
 
 /**
- * Represents a {@link com.google.gerrit.common.data.SubmitRecord.Label} that does not depend on
- * Gerrit internal classes, to be serialized.
+ * Represents a {@link com.google.gerrit.entities.SubmitRecord.Label} that does not depend on Gerrit
+ * internal classes, to be serialized.
  */
 public class SubmitLabelAttribute {
   public String label;
diff --git a/java/com/google/gerrit/server/data/SubmitRecordAttribute.java b/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
index 2c3d401..e6c308e 100644
--- a/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.server.data;
 
+import com.google.gerrit.entities.SubmitRecord;
 import java.util.List;
 
 /**
- * Represents a {@link com.google.gerrit.common.data.SubmitRecord} that does not depend on Gerrit
- * internal classes, to be serialized.
+ * Represents a {@link SubmitRecord} that does not depend on Gerrit internal classes, to be
+ * serialized.
  */
 public class SubmitRecordAttribute {
   public String status;
diff --git a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
index 2364ec4..ed4ea8a 100644
--- a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.data;
 
+import com.google.gerrit.entities.SubmitRequirement;
+
 /**
- * Represents a {@link com.google.gerrit.common.data.SubmitRequirement} that does not depend on
- * Gerrit internal classes, to be serialized
+ * Represents a {@link SubmitRequirement} that does not depend on Gerrit internal classes, to be
+ * serialized
  */
 public class SubmitRequirementAttribute {
   public String type;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index 2eb46f1..2d5e708 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -21,7 +21,6 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.vladsch.flexmark.Extension;
 import com.vladsch.flexmark.ast.Block;
 import com.vladsch.flexmark.ast.Heading;
 import com.vladsch.flexmark.ast.Node;
@@ -36,7 +35,6 @@
 import java.io.UnsupportedEncodingException;
 import java.net.URL;
 import java.nio.charset.Charset;
-import java.util.ArrayList;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.apache.commons.lang.StringEscapeUtils;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -95,11 +93,6 @@
                 options, MarkdownFormatterHeader.HeadingExtension.create())
             .toMutable();
 
-    ArrayList<Extension> extensions = new ArrayList<>();
-    for (Extension extension : optionsExt.get(com.vladsch.flexmark.parser.Parser.EXTENSIONS)) {
-      extensions.add(extension);
-    }
-
     return optionsExt;
   }
 
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index f0da560..0c3c4fb 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -20,17 +20,17 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.UserIdentity;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -380,8 +380,8 @@
   }
 
   public void addPatchSetComments(
-      PatchSetAttribute patchSetAttribute, Collection<Comment> comments) {
-    for (Comment comment : comments) {
+      PatchSetAttribute patchSetAttribute, Collection<HumanComment> comments) {
+    for (HumanComment comment : comments) {
       if (comment.key.patchSetId == patchSetAttribute.number) {
         if (patchSetAttribute.comments == null) {
           patchSetAttribute.comments = new ArrayList<>();
@@ -547,7 +547,7 @@
     return a;
   }
 
-  public PatchSetCommentAttribute asPatchSetLineAttribute(Comment c) {
+  public PatchSetCommentAttribute asPatchSetLineAttribute(HumanComment c) {
     PatchSetCommentAttribute a = new PatchSetCommentAttribute();
     a.reviewer = asAccountAttribute(c.author.getId());
     a.file = c.key.filename;
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index f286eef..1f90187 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -20,11 +20,11 @@
 import com.google.common.base.Suppliers;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
diff --git a/java/com/google/gerrit/server/git/BranchOrderSection.java b/java/com/google/gerrit/server/git/BranchOrderSection.java
deleted file mode 100644
index 0266655..0000000
--- a/java/com/google/gerrit/server/git/BranchOrderSection.java
+++ /dev/null
@@ -1,57 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.RefNames;
-import java.util.List;
-
-/**
- * An ordering of branches by stability.
- *
- * <p>The REST API supports automatically checking if changes on development branches can be merged
- * into stable branches. This is configured by the {@code branchOrder.branch} project setting. This
- * class represents the ordered list of branches, by increasing stability.
- */
-public class BranchOrderSection {
-
-  /**
-   * Branch names ordered from least to the most stable.
-   *
-   * <p>Typically the order will be like: master, stable-M.N, stable-M.N-1, ...
-   */
-  private final ImmutableList<String> order;
-
-  public BranchOrderSection(String[] order) {
-    if (order.length == 0) {
-      this.order = ImmutableList.of();
-    } else {
-      ImmutableList.Builder<String> builder = ImmutableList.builder();
-      for (String b : order) {
-        builder.add(RefNames.fullName(b));
-      }
-      this.order = builder.build();
-    }
-  }
-
-  public String[] getMoreStable(String branch) {
-    int i = order.indexOf(RefNames.fullName(branch));
-    if (0 <= i) {
-      List<String> r = order.subList(i + 1, order.size());
-      return r.toArray(new String[r.size()]);
-    }
-    return new String[] {};
-  }
-}
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index df53133..0f46199 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -86,6 +87,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final ChangeReverted changeReverted;
   private final BatchUpdate.Factory updateFactory;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   CommitUtil(
@@ -98,7 +100,8 @@
       RevertedSender.Factory revertedSenderFactory,
       ChangeMessagesUtil cmUtil,
       ChangeReverted changeReverted,
-      BatchUpdate.Factory updateFactory) {
+      BatchUpdate.Factory updateFactory,
+      MessageIdGenerator messageIdGenerator) {
     this.repoManager = repoManager;
     this.serverIdent = serverIdent;
     this.seq = seq;
@@ -109,6 +112,7 @@
     this.cmUtil = cmUtil;
     this.changeReverted = changeReverted;
     this.updateFactory = updateFactory;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   public static CommitInfo toCommitInfo(RevCommit commit) throws IOException {
@@ -305,10 +309,12 @@
     public void postUpdate(Context ctx) throws Exception {
       changeReverted.fire(change, ins.getChange(), ctx.getWhen());
       try {
-        RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), change.getId());
-        cm.setFrom(ctx.getAccountId());
-        cm.setNotify(ctx.getNotify(change.getId()));
-        cm.send();
+        RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
+        emailSender.setFrom(ctx.getAccountId());
+        emailSender.setNotify(ctx.getNotify(change.getId()));
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+        emailSender.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
             "Cannot send email for revert change %s", change.getId());
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index dccb97a..7518a14 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -31,12 +31,12 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.exceptions.InvalidMergeStrategyException;
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index b272cba..40e2730 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
 import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -70,6 +71,7 @@
   private final PatchSetUtil psUtil;
   private final ExecutorService sendEmailExecutor;
   private final ChangeMerged changeMerged;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final PatchSet.Id psId;
   private final SubmissionId submissionId;
@@ -90,6 +92,7 @@
       PatchSetUtil psUtil,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ChangeMerged changeMerged,
+      MessageIdGenerator messageIdGenerator,
       @Assisted RequestScopePropagator requestScopePropagator,
       @Assisted PatchSet.Id psId,
       @Assisted SubmissionId submissionId,
@@ -101,6 +104,7 @@
     this.psUtil = psUtil;
     this.sendEmailExecutor = sendEmailExecutor;
     this.changeMerged = changeMerged;
+    this.messageIdGenerator = messageIdGenerator;
     this.requestScopePropagator = requestScopePropagator;
     this.submissionId = submissionId;
     this.psId = psId;
@@ -185,11 +189,13 @@
                   @Override
                   public void run() {
                     try {
-                      MergedSender cm =
+                      MergedSender emailSender =
                           mergedSenderFactory.create(ctx.getProject(), psId.changeId());
-                      cm.setFrom(ctx.getAccountId());
-                      cm.setPatchSet(patchSet, info);
-                      cm.send();
+                      emailSender.setFrom(ctx.getAccountId());
+                      emailSender.setPatchSet(patchSet, info);
+                      emailSender.setMessageId(
+                          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+                      emailSender.send();
                     } catch (Exception e) {
                       logger.atSevere().withCause(e).log(
                           "Cannot send email for submitted patch set %s", psId);
diff --git a/java/com/google/gerrit/server/git/NotifyConfig.java b/java/com/google/gerrit/server/git/NotifyConfig.java
deleted file mode 100644
index 429f15a..0000000
--- a/java/com/google/gerrit/server/git/NotifyConfig.java
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import java.util.EnumSet;
-import java.util.HashSet;
-import java.util.Set;
-
-public class NotifyConfig implements Comparable<NotifyConfig> {
-  public enum Header {
-    TO,
-    CC,
-    BCC
-  }
-
-  private String name;
-  private EnumSet<NotifyType> types = EnumSet.of(NotifyType.ALL);
-  private String filter;
-
-  private Header header;
-  private Set<GroupReference> groups = new HashSet<>();
-  private Set<Address> addresses = new HashSet<>();
-
-  public String getName() {
-    return name;
-  }
-
-  public void setName(String name) {
-    this.name = name;
-  }
-
-  public boolean isNotify(NotifyType type) {
-    return types.contains(type) || types.contains(NotifyType.ALL);
-  }
-
-  public Set<NotifyType> getNotify() {
-    return types;
-  }
-
-  public void setTypes(Set<NotifyType> newTypes) {
-    types = EnumSet.copyOf(newTypes);
-  }
-
-  public String getFilter() {
-    return filter;
-  }
-
-  public void setFilter(String filter) {
-    if ("*".equals(filter)) {
-      this.filter = null;
-    } else {
-      this.filter = Strings.emptyToNull(filter);
-    }
-  }
-
-  public Header getHeader() {
-    return header;
-  }
-
-  public void setHeader(Header hdr) {
-    header = hdr;
-  }
-
-  public Set<GroupReference> getGroups() {
-    return groups;
-  }
-
-  public Set<Address> getAddresses() {
-    return addresses;
-  }
-
-  public void addEmail(GroupReference group) {
-    groups.add(group);
-  }
-
-  public void addEmail(Address address) {
-    addresses.add(address);
-  }
-
-  @Override
-  public int compareTo(NotifyConfig o) {
-    return name.compareTo(o.name);
-  }
-
-  @Override
-  public int hashCode() {
-    return name.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object obj) {
-    if (obj instanceof NotifyConfig) {
-      return compareTo((NotifyConfig) obj) == 0;
-    }
-    return false;
-  }
-
-  @Override
-  public String toString() {
-    return MoreObjects.toStringHelper(this)
-        .add("name", name)
-        .add("addresses", addresses)
-        .add("groups", groups)
-        .add("header", header)
-        .add("types", types)
-        .add("filter", filter)
-        .toString();
-  }
-}
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 196fc61..fed6541 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -165,7 +165,7 @@
         List<CachedChange> result = new ArrayList<>(cds.size());
         for (ChangeData cd : cds) {
           result.add(
-              new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.getReviewers()));
+              new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.reviewers()));
         }
         return Collections.unmodifiableList(result);
       }
diff --git a/java/com/google/gerrit/server/git/ValidationError.java b/java/com/google/gerrit/server/git/ValidationError.java
index 28d5171..3606c42 100644
--- a/java/com/google/gerrit/server/git/ValidationError.java
+++ b/java/com/google/gerrit/server/git/ValidationError.java
@@ -14,51 +14,26 @@
 
 package com.google.gerrit.server.git;
 
-import java.util.Objects;
+import com.google.auto.value.AutoValue;
 
 /** Indicates a problem with Git based data. */
-public class ValidationError {
-  private final String message;
+@AutoValue
+public abstract class ValidationError {
+  public abstract String getMessage();
 
-  public ValidationError(String file, String message) {
-    this(file + ": " + message);
+  public static ValidationError create(String file, String message) {
+    return create(file + ": " + message);
   }
 
-  public ValidationError(String file, int line, String message) {
-    this(file + ":" + line + ": " + message);
+  public static ValidationError create(String file, int line, String message) {
+    return create(file + ":" + line + ": " + message);
   }
 
-  public ValidationError(String message) {
-    this.message = message;
-  }
-
-  public String getMessage() {
-    return message;
-  }
-
-  @Override
-  public String toString() {
-    return "ValidationError[" + message + "]";
+  public static ValidationError create(String message) {
+    return new AutoValue_ValidationError(message);
   }
 
   public interface Sink {
     void error(ValidationError error);
   }
-
-  @Override
-  public boolean equals(Object o) {
-    if (o == this) {
-      return true;
-    }
-    if (o instanceof ValidationError) {
-      ValidationError that = (ValidationError) o;
-      return Objects.equals(this.message, that.message);
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hashCode(message);
-  }
 }
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index f2a0ff1..4b08040 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -606,9 +606,12 @@
     @Override
     public void run() {
       if (running.compareAndSet(false, true)) {
+        String oldThreadName = Thread.currentThread().getName();
         try {
+          Thread.currentThread().setName(oldThreadName + "[" + task.toString() + "]");
           task.run();
         } finally {
+          Thread.currentThread().setName(oldThreadName);
           if (isPeriodic()) {
             running.set(false);
           } else {
@@ -681,5 +684,10 @@
     public boolean hasCustomizedPrint() {
       return runnable.hasCustomizedPrint();
     }
+
+    @Override
+    public String toString() {
+      return runnable.toString();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/git/meta/TabFile.java b/java/com/google/gerrit/server/git/meta/TabFile.java
index c9a8e77..80570a5 100644
--- a/java/com/google/gerrit/server/git/meta/TabFile.java
+++ b/java/com/google/gerrit/server/git/meta/TabFile.java
@@ -59,7 +59,7 @@
 
       int tab = s.indexOf('\t');
       if (tab < 0) {
-        errors.error(new ValidationError(filename, lineNumber, "missing tab delimiter"));
+        errors.error(ValidationError.create(filename, lineNumber, "missing tab delimiter"));
         continue;
       }
 
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 0d762c7..2177485 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -417,6 +417,16 @@
     metrics.latencyPerPush.record(pushType, deltaNanos, NANOSECONDS);
   }
 
+  /**
+   * Sends all messages which have been collected while processing the push to the client.
+   *
+   * @see ReceiveCommits#sendMessages()
+   */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public void sendMessages() {
+    receiveCommits.sendMessages();
+  }
+
   public ReceivePack getReceivePack() {
     return receivePack;
   }
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index 766a835..b59d431 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -19,6 +19,7 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/logging",
+        "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/util/cli",
         "//lib:args4j",
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index ff28d69..09cead0 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -63,13 +63,13 @@
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.entities.Project;
@@ -161,6 +161,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.change.ReplyAttentionSetUpdates;
 import com.google.gerrit.server.submit.MergeOp;
 import com.google.gerrit.server.submit.MergeOpRepoManager;
 import com.google.gerrit.server.submit.SubmoduleOp;
@@ -345,6 +346,7 @@
   private final TagCache tagCache;
   private final ProjectConfig.Factory projectConfigFactory;
   private final SetPrivateOp.Factory setPrivateOpFactory;
+  private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
 
   // Assisted injected fields.
   private final ProjectState projectState;
@@ -424,6 +426,7 @@
       SubmoduleOp.Factory subOpFactory,
       TagCache tagCache,
       SetPrivateOp.Factory setPrivateOpFactory,
+      ReplyAttentionSetUpdates replyAttentionSetUpdates,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
       @Assisted ReceivePack rp,
@@ -471,6 +474,7 @@
     this.tagCache = tagCache;
     this.projectConfigFactory = projectConfigFactory;
     this.setPrivateOpFactory = setPrivateOpFactory;
+    this.replyAttentionSetUpdates = replyAttentionSetUpdates;
 
     // Assisted injected fields.
     this.projectState = projectState;
@@ -737,7 +741,7 @@
       Set<BranchNameKey> branches = new HashSet<>();
       for (ReceiveCommand c : cmds) {
         // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps that
-        // should happen in this loop are things that can't happen within one BatchUpdate because
+        // should happen in this loops are things that can't happen within one BatchUpdate because
         // they involve kicking off an additional BatchUpdate.
         if (c.getResult() != OK) {
           continue;
@@ -922,6 +926,28 @@
               bu.addOp(
                   replace.notes.getChangeId(),
                   publishCommentsOp.create(replace.psId, project.getNameKey()));
+              Optional<ChangeNotes> changeNotes = getChangeNotes(replace.notes.getChangeId());
+              if (!changeNotes.isPresent()) {
+                // If not present, no need to update attention set here since this is a new change.
+                continue;
+              }
+              List<HumanComment> drafts =
+                  commentsUtil.draftByChangeAuthor(changeNotes.get(), user.getAccountId());
+              if (drafts.isEmpty()) {
+                // If no comments, attention set shouldn't update since the user didn't reply.
+                continue;
+              }
+              // Reviewers can only be removed by becoming CCs.
+              Set<String> potentiallyRemovedReviewers =
+                  magicBranch.getCombinedCcs(
+                      getRecipientsFromFooters(
+                          accountResolver, replace.revCommit.getFooterLines()));
+              replyAttentionSetUpdates.processAutomaticAttentionSetRulesOnReply(
+                  bu,
+                  changeNotes.get(),
+                  isReadyForReview(changeNotes.get()),
+                  potentiallyRemovedReviewers,
+                  user.getAccountId());
             }
           }
         }
@@ -976,7 +1002,11 @@
       } catch (ResourceConflictException e) {
         addError(e.getMessage());
         reject(magicBranchCmd, "conflict");
-      } catch (BadRequestException | UnprocessableEntityException | AuthException e) {
+      } catch (BadRequestException
+          | UnprocessableEntityException
+          | AuthException
+          | ConfigInvalidException
+          | PermissionBackendException e) {
         logger.atFine().withCause(e).log("Rejecting due to client error");
         reject(magicBranchCmd, e.getMessage());
       } catch (RestApiException | IOException e) {
@@ -1002,6 +1032,11 @@
     }
   }
 
+  private boolean isReadyForReview(ChangeNotes changeNotes) {
+    return (!changeNotes.getChange().isWorkInProgress() && !magicBranch.workInProgress)
+        || magicBranch.ready;
+  }
+
   private String buildError(String error, List<String> branches) {
     StringBuilder sb = new StringBuilder();
     if (branches.size() == 1) {
@@ -1261,12 +1296,11 @@
       ProjectConfigEntry configEntry = e.getProvider().get();
       String value = pluginCfg.getString(e.getExportName());
       String oldValue =
-          projectState.getConfig().getPluginConfig(e.getPluginName()).getString(e.getExportName());
+          projectState.getPluginConfig(e.getPluginName()).getString(e.getExportName());
       if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
         oldValue =
             Arrays.stream(
                     projectState
-                        .getConfig()
                         .getPluginConfig(e.getPluginName())
                         .getStringList(e.getExportName()))
                 .collect(joining("\n"));
@@ -2030,7 +2064,7 @@
       }
 
       if (magicBranch != null && magicBranch.shouldPublishComments()) {
-        List<Comment> drafts =
+        List<HumanComment> drafts =
             commentsUtil.draftByChangeAuthor(
                 notesFactory.createChecked(change), user.getAccountId());
         ImmutableList<CommentForValidation> draftsForValidation =
@@ -3247,6 +3281,13 @@
                       ObjectInserter ins = repo.newObjectInserter();
                       ObjectReader reader = ins.newReader();
                       RevWalk rw = new RevWalk(reader)) {
+                    if (ObjectId.zeroId().equals(cmd.getOldId())) {
+                      // The user is creating a new branch. The branch can't contain any changes, so
+                      // auto-closing doesn't apply. Exiting here early to spare any further,
+                      // potentially expensive computation that loop over all commits.
+                      return null;
+                    }
+
                     bu.setRepository(repo, rw, ins);
                     // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
 
@@ -3256,9 +3297,7 @@
                     rw.reset();
                     rw.sort(RevSort.REVERSE);
                     rw.markStart(newTip);
-                    if (!ObjectId.zeroId().equals(cmd.getOldId())) {
-                      rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
-                    }
+                    rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
 
                     Map<Change.Key, ChangeNotes> byKey = null;
                     List<ReplaceRequest> replaceAndClose = new ArrayList<>();
@@ -3270,6 +3309,8 @@
                     for (RevCommit c; (c = rw.next()) != null; ) {
                       rw.parseBody(c);
 
+                      // Check if change refs point to this commit. Usually there are 0-1 change
+                      // refs pointing to this commit.
                       for (Ref ref :
                           receivePackRefCache.tipsFromObjectId(c.copy(), RefNames.REFS_CHANGES)) {
                         PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
@@ -3369,6 +3410,8 @@
         logger.atSevere().withCause(e).log("Can't insert patchset");
       } catch (UpdateException e) {
         logger.atSevere().withCause(e).log("Failed to auto-close changes");
+      } finally {
+        logger.atFine().log("Done auto-closing changes");
       }
     }
   }
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 0baecf5..cdec2cd 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -29,10 +29,10 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetInfo;
@@ -61,6 +61,7 @@
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -128,6 +129,8 @@
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
   private final ReviewerAdder reviewerAdder;
+  private final Change change;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final ProjectState projectState;
   private final BranchNameKey dest;
@@ -140,7 +143,6 @@
   private final PatchSetInfo info;
   private final MagicBranchInput magicBranch;
   private final PushCertificate pushCertificate;
-  private final Change change;
   private List<String> groups;
 
   private final Map<String, Short> approvals = new HashMap<>();
@@ -172,6 +174,7 @@
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ReviewerAdder reviewerAdder,
       Change change,
+      MessageIdGenerator messageIdGenerator,
       @Assisted ProjectState projectState,
       @Assisted BranchNameKey dest,
       @Assisted boolean checkMergedInto,
@@ -197,6 +200,8 @@
     this.projectCache = projectCache;
     this.sendEmailExecutor = sendEmailExecutor;
     this.reviewerAdder = reviewerAdder;
+    this.change = change;
+    this.messageIdGenerator = messageIdGenerator;
 
     this.projectState = projectState;
     this.dest = dest;
@@ -210,7 +215,6 @@
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
-    this.change = change;
   }
 
   @Override
@@ -344,7 +348,7 @@
     return true;
   }
 
-  private static ImmutableList<AddReviewerInput> getReviewerInputs(
+  private ImmutableList<AddReviewerInput> getReviewerInputs(
       @Nullable MagicBranchInput magicBranch,
       MailRecipients fromFooters,
       Change change,
@@ -358,13 +362,15 @@
                     change,
                     psInfo.getCommitId(),
                     psInfo.getAuthor().getAccount(),
-                    NotifyHandling.NONE)),
+                    NotifyHandling.NONE,
+                    newPatchSet.uploader())),
             Streams.stream(
                 newAddReviewerInputFromCommitIdentity(
                     change,
                     psInfo.getCommitId(),
                     psInfo.getCommitter().getAccount(),
-                    NotifyHandling.NONE)));
+                    NotifyHandling.NONE,
+                    newPatchSet.uploader())));
     if (magicBranch != null) {
       inputs =
           Streams.concat(
@@ -516,25 +522,27 @@
     @Override
     public void run() {
       try {
-        ReplacePatchSetSender cm =
+        ReplacePatchSetSender emailSender =
             replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
-        cm.setFrom(ctx.getAccount().account().id());
-        cm.setPatchSet(newPatchSet, info);
-        cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
-        cm.setNotify(ctx.getNotify(notes.getChangeId()));
-        cm.addReviewers(
+        emailSender.setFrom(ctx.getAccount().account().id());
+        emailSender.setPatchSet(newPatchSet, info);
+        emailSender.setChangeMessage(msg.getMessage(), ctx.getWhen());
+        emailSender.setNotify(ctx.getNotify(notes.getChangeId()));
+        emailSender.addReviewers(
             Streams.concat(
                     oldRecipients.getReviewers().stream(),
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
                         .map(PatchSetApproval::accountId))
                 .collect(toImmutableSet()));
-        cm.addExtraCC(
+        emailSender.addExtraCC(
             Streams.concat(
                     oldRecipients.getCcOnly().stream(),
                     reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs).stream())
                 .collect(toImmutableSet()));
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSetId));
         // TODO(dborowitz): Support byEmail
-        cm.send();
+        emailSender.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log(
             "Cannot send email for new patch set %s", newPatchSet.id());
diff --git a/java/com/google/gerrit/server/git/validators/CommentCountValidator.java b/java/com/google/gerrit/server/git/validators/CommentCountValidator.java
index 67aa3bd..a554f90 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCountValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCountValidator.java
@@ -45,7 +45,7 @@
     ChangeNotes notes =
         notesFactory.createChecked(Project.nameKey(ctx.getProject()), Change.id(ctx.getChangeId()));
     int numExistingCommentsAndChangeMessages =
-        notes.getComments().size()
+        notes.getHumanComments().size()
             + notes.getRobotComments().size()
             + notes.getChangeMessages().size();
     if (!comments.isEmpty()
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
index d9a1420..d507531 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -51,7 +51,7 @@
         notesFactory.createChecked(Project.nameKey(ctx.getProject()), Change.id(ctx.getChangeId()));
     int existingCumulativeSize =
         Stream.concat(
-                    notes.getComments().values().stream(),
+                    notes.getHumanComments().values().stream(),
                     notes.getRobotComments().values().stream())
                 .mapToInt(Comment::getApproximateSize)
                 .sum()
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 7535f51..923ba68 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -45,6 +45,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.validators.ValidationMessage.Type;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
@@ -230,7 +233,17 @@
     List<CommitValidationMessage> messages = new ArrayList<>();
     try {
       for (CommitValidationListener commitValidator : validators) {
-        messages.addAll(commitValidator.onCommitReceived(receiveEvent));
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Running CommitValidationListener",
+                Metadata.builder()
+                    .className(commitValidator.getClass().getSimpleName())
+                    .projectName(receiveEvent.getProjectNameKey().get())
+                    .branchName(receiveEvent.getBranchNameKey().branch())
+                    .commit(receiveEvent.commit.name())
+                    .build())) {
+          messages.addAll(commitValidator.onCommitReceived(receiveEvent));
+        }
       }
     } catch (CommitValidationException e) {
       logger.atFine().withCause(e).log(
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 04cbe36..4e5ce0c 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -47,6 +47,7 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.List;
+import java.util.Objects;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
@@ -228,13 +229,9 @@
 
             String value = pluginCfg.getString(e.getExportName());
             String oldValue =
-                destProject
-                    .getConfig()
-                    .getPluginConfig(e.getPluginName())
-                    .getString(e.getExportName());
+                destProject.getPluginConfig(e.getPluginName()).getString(e.getExportName());
 
-            if ((value == null ? oldValue != null : !value.equals(oldValue))
-                && !configEntry.isEditable(destProject)) {
+            if ((!Objects.equals(value, oldValue)) && !configEntry.isEditable(destProject)) {
               throw new MergeValidationException(PLUGIN_VALUE_NOT_EDITABLE);
             }
 
diff --git a/java/com/google/gerrit/server/group/GroupResolver.java b/java/com/google/gerrit/server/group/GroupResolver.java
index 1aa265b..50ec893 100644
--- a/java/com/google/gerrit/server/group/GroupResolver.java
+++ b/java/com/google/gerrit/server/group/GroupResolver.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.group;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
diff --git a/java/com/google/gerrit/server/group/GroupResource.java b/java/com/google/gerrit/server/group/GroupResource.java
index 1050314..b0e81ec 100644
--- a/java/com/google/gerrit/server/group/GroupResource.java
+++ b/java/com/google/gerrit/server/group/GroupResource.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.group;
 
-import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.account.GroupControl;
diff --git a/java/com/google/gerrit/server/group/InternalGroupDescription.java b/java/com/google/gerrit/server/group/InternalGroupDescription.java
index c70c8bf..740557a 100644
--- a/java/com/google/gerrit/server/group/InternalGroupDescription.java
+++ b/java/com/google/gerrit/server/group/InternalGroupDescription.java
@@ -19,9 +19,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import java.sql.Timestamp;
 
 public class InternalGroupDescription implements GroupDescription.Internal {
diff --git a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
index b2d9632..cae213f 100644
--- a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
+++ b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
@@ -19,8 +19,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.AllUsersName;
diff --git a/java/com/google/gerrit/server/group/SubgroupResource.java b/java/com/google/gerrit/server/group/SubgroupResource.java
index ceea2dc..21356be 100644
--- a/java/com/google/gerrit/server/group/SubgroupResource.java
+++ b/java/com/google/gerrit/server/group/SubgroupResource.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.group;
 
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.inject.TypeLiteral;
 
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index 7821a01..b5ccb18 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -21,9 +21,9 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
@@ -104,7 +104,7 @@
       reservedNamesBuilder.add(defaultName);
       String configuredName = cfg.getString("groups", uuid.get(), "name");
       GroupReference ref =
-          new GroupReference(uuid, MoreObjects.firstNonNull(configuredName, defaultName));
+          GroupReference.create(uuid, MoreObjects.firstNonNull(configuredName, defaultName));
       n.put(ref.getName().toLowerCase(Locale.US), ref);
       u.put(ref.getUUID(), ref);
     }
diff --git a/java/com/google/gerrit/server/group/db/AuditLogFormatter.java b/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
index ec4c0fc..235ca4f 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogFormatter.java
@@ -19,9 +19,9 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.GroupBackend;
diff --git a/java/com/google/gerrit/server/group/db/GroupNameNotes.java b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
index 70d7a1a..cdba81f 100644
--- a/java/com/google/gerrit/server/group/db/GroupNameNotes.java
+++ b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
@@ -28,8 +28,8 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
@@ -443,7 +443,7 @@
       throw new ConfigInvalidException(String.format("UUID for group '%s' must be defined", name));
     }
 
-    return new GroupReference(AccountGroup.uuid(uuid), name);
+    return GroupReference.create(AccountGroup.uuid(uuid), name);
   }
 
   private String getCommitMessage() {
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index 163b9c6..90a5a1f 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -17,10 +17,10 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroupByIdAudit;
 import com.google.gerrit.entities.AccountGroupMemberAudit;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.InternalGroup;
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
index 0414304..35f5dea 100644
--- a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -23,8 +23,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
diff --git a/java/com/google/gerrit/server/group/db/RenameGroupOp.java b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
index 420dd33e..843b346 100644
--- a/java/com/google/gerrit/server/group/db/RenameGroupOp.java
+++ b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
@@ -17,8 +17,9 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.DefaultQueueOp;
 import com.google.gerrit.server.git.WorkQueue;
@@ -30,6 +31,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -87,10 +89,10 @@
   public void run() {
     Iterable<Project.NameKey> names = tryingAgain ? retryOn : projectCache.all();
     for (Project.NameKey projectName : names) {
-      ProjectConfig config =
+      CachedProjectConfig config =
           projectCache.get(projectName).orElseThrow(illegalState(projectName)).getConfig();
-      GroupReference ref = config.getGroup(uuid);
-      if (ref == null || newName.equals(ref.getName())) {
+      Optional<GroupReference> ref = config.getGroup(uuid);
+      if (!ref.isPresent() || newName.equals(ref.get().getName())) {
         continue;
       }
 
@@ -125,7 +127,7 @@
         return;
       }
 
-      ref.setName(newName);
+      config.renameGroup(uuid, newName);
       md.getCommitBuilder().setAuthor(author);
       md.setMessage("Rename group " + oldName + " to " + newName + "\n");
       try {
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index 51c7ca3..8b7055e 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -18,9 +18,9 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index a7c4016..ef538cb 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -42,15 +42,16 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.Files;
 import com.google.common.primitives.Longs;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.converter.ChangeProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
@@ -59,7 +60,6 @@
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index a1c6286..7e50104 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -26,7 +26,6 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
-import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Change;
@@ -201,7 +200,8 @@
 
       // Quote everything except the '*'s, which become ".*".
       String regex =
-          Streams.stream(Splitter.on('*').split(pattern))
+          Splitter.on('*')
+              .splitToStream(pattern)
               .map(Pattern::quote)
               .collect(joining(".*", "^", "$"));
       return new AutoValue_StalenessChecker_RefStatePattern(
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index 51c7730..2d77f61 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -22,8 +22,8 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.InternalGroup;
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
index 7af34f7..c60af0d 100644
--- a/java/com/google/gerrit/server/logging/BUILD
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -3,7 +3,7 @@
 java_library(
     name = "logging",
     srcs = glob(
-        ["**/*.java"],
+        ["*.java"],
     ),
     visibility = ["//visibility:public"],
     deps = [
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 3bb4770..1a4a335 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -31,116 +31,126 @@
 /** Metadata that is provided to {@link PerformanceLogger}s as context for performance records. */
 @AutoValue
 public abstract class Metadata {
-  // The numeric ID of an account.
+  /** The numeric ID of an account. */
   public abstract Optional<Integer> accountId();
 
-  // The type of an action (ACCOUNT_UPDATE, CHANGE_UPDATE, GROUP_UPDATE, INDEX_QUERY,
-  // PLUGIN_UPDATE).
+  /**
+   * The type of an action (ACCOUNT_UPDATE, CHANGE_UPDATE, GROUP_UPDATE, INDEX_QUERY,
+   * PLUGIN_UPDATE).
+   */
   public abstract Optional<String> actionType();
 
-  // An authentication domain name.
+  /** An authentication domain name. */
   public abstract Optional<String> authDomainName();
 
-  // The name of a branch.
+  /** The name of a branch. */
   public abstract Optional<String> branchName();
 
-  // Key of an entity in a cache.
+  /** Key of an entity in a cache. */
   public abstract Optional<String> cacheKey();
 
-  // The name of a cache.
+  /** The name of a cache. */
   public abstract Optional<String> cacheName();
 
-  // The name of the implementation class.
+  /** The name of the implementation class. */
   public abstract Optional<String> className();
 
-  // The numeric ID of a change.
+  /** The numeric ID of a change. */
   public abstract Optional<Integer> changeId();
 
-  // The type of change ID which the user used to identify a change (e.g. numeric ID, triplet etc.).
+  /**
+   * The type of change ID which the user used to identify a change (e.g. numeric ID, triplet etc.).
+   */
   public abstract Optional<String> changeIdType();
 
-  // The cause of an error.
+  /** The cause of an error. */
   public abstract Optional<String> cause();
 
-  // The type of an event.
+  /** The SHA1 of a commit. */
+  public abstract Optional<String> commit();
+
+  /** The type of an event. */
   public abstract Optional<String> eventType();
 
-  // The value of the @Export annotation which was used to register a plugin extension.
+  /** The value of the @Export annotation which was used to register a plugin extension. */
   public abstract Optional<String> exportValue();
 
-  // Path of a file in a repository.
+  /** Path of a file in a repository. */
   public abstract Optional<String> filePath();
 
-  // Garbage collector name.
+  /** Garbage collector name. */
   public abstract Optional<String> garbageCollectorName();
 
-  // Git operation (CLONE, FETCH).
+  /** Git operation (CLONE, FETCH). */
   public abstract Optional<String> gitOperation();
 
-  // The numeric ID of an internal group.
+  /** The numeric ID of an internal group. */
   public abstract Optional<Integer> groupId();
 
-  // The name of a group.
+  /** The name of a group. */
   public abstract Optional<String> groupName();
 
-  // The UUID of a group.
+  /** The UUID of a group. */
   public abstract Optional<String> groupUuid();
 
-  // HTTP status response code.
+  /** HTTP status response code. */
   public abstract Optional<Integer> httpStatus();
 
-  // The name of a secondary index.
+  /** The name of a secondary index. */
   public abstract Optional<String> indexName();
 
-  // The version of a secondary index.
+  /** The version of a secondary index. */
   public abstract Optional<Integer> indexVersion();
 
-  // The name of the implementation method.
+  /** The name of the implementation method. */
   public abstract Optional<String> methodName();
 
-  // One or more resources
+  /** One or more resources */
   public abstract Optional<Boolean> multiple();
 
-  // The name of an operation that is performed.
+  /** The name of an operation that is performed. */
   public abstract Optional<String> operationName();
 
-  // Partial or full computation
+  /** Partial or full computation */
   public abstract Optional<Boolean> partial();
 
-  // Path of a metadata file in NoteDb.
+  /** If a value is still current or not */
+  public abstract Optional<Boolean> outdated();
+
+  /** Path of a metadata file in NoteDb. */
   public abstract Optional<String> noteDbFilePath();
 
-  // Name of a metadata ref in NoteDb.
+  /** Name of a metadata ref in NoteDb. */
   public abstract Optional<String> noteDbRefName();
 
-  // Type of a sequence in NoteDb (ACCOUNTS, CHANGES, GROUPS).
+  /** Type of a sequence in NoteDb (ACCOUNTS, CHANGES, GROUPS). */
   public abstract Optional<String> noteDbSequenceType();
 
-  // The ID of a patch set.
+  /** The ID of a patch set. */
   public abstract Optional<Integer> patchSetId();
 
-  // Plugin metadata that doesn't fit into any other category.
+  /** Plugin metadata that doesn't fit into any other category. */
   public abstract ImmutableList<PluginMetadata> pluginMetadata();
 
-  // The name of a plugin.
+  /** The name of a plugin. */
   public abstract Optional<String> pluginName();
 
-  // The name of a Gerrit project (aka Git repository).
+  /** The name of a Gerrit project (aka Git repository). */
   public abstract Optional<String> projectName();
 
-  // The type of a Git push to Gerrit (CREATE_REPLACE, NORMAL, AUTOCLOSE).
+  /** The type of a Git push to Gerrit (CREATE_REPLACE, NORMAL, AUTOCLOSE). */
   public abstract Optional<String> pushType();
 
-  // The number of resources that is processed.
+  /** The number of resources that is processed. */
   public abstract Optional<Integer> resourceCount();
 
-  // The name of a REST view.
+  /** The name of a REST view. */
   public abstract Optional<String> restViewName();
 
-  // The SHA1 of Git commit.
+  /** The SHA1 of Git commit. */
   public abstract Optional<String> revision();
 
-  // The username of an account.
+  /** The username of an account. */
   public abstract Optional<String> username();
 
   /**
@@ -275,6 +285,8 @@
 
     public abstract Builder cause(@Nullable String cause);
 
+    public abstract Builder commit(@Nullable String commit);
+
     public abstract Builder eventType(@Nullable String eventType);
 
     public abstract Builder exportValue(@Nullable String exportValue);
@@ -305,6 +317,8 @@
 
     public abstract Builder partial(boolean partial);
 
+    public abstract Builder outdated(boolean outdated);
+
     public abstract Builder noteDbFilePath(@Nullable String noteDbFilePath);
 
     public abstract Builder noteDbRefName(@Nullable String noteDbRefName);
diff --git a/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
index cc3db75..ff166b1 100644
--- a/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/java/com/google/gerrit/server/mail/EmailModule.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.server.mail.send.AbandonedSender;
 import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.mail.send.CreateChangeSender;
 import com.google.gerrit.server.mail.send.DeleteKeySender;
@@ -26,6 +27,7 @@
 import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
 import com.google.gerrit.server.mail.send.MergedSender;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.mail.send.RestoredSender;
 import com.google.gerrit.server.mail.send.RevertedSender;
@@ -49,5 +51,7 @@
     factory(RestoredSender.Factory.class);
     factory(RevertedSender.Factory.class);
     factory(SetAssigneeSender.Factory.class);
+    factory(AddToAttentionSetSender.Factory.class);
+    factory(RemoveFromAttentionSetSender.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 9c3dd02..bdc933f 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -57,6 +57,7 @@
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.mail.MailFilter;
 import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -115,6 +116,7 @@
   private final AccountCache accountCache;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final PluginSetContext<CommentValidator> commentValidators;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   public MailProcessor(
@@ -133,7 +135,8 @@
       CommentAdded commentAdded,
       AccountCache accountCache,
       DynamicItem<UrlFormatter> urlFormatter,
-      PluginSetContext<CommentValidator> commentValidators) {
+      PluginSetContext<CommentValidator> commentValidators,
+      MessageIdGenerator messageIdGenerator) {
     this.emails = emails;
     this.emailRejectionSender = emailRejectionSender;
     this.retryHelper = retryHelper;
@@ -150,6 +153,7 @@
     this.accountCache = accountCache;
     this.urlFormatter = urlFormatter;
     this.commentValidators = commentValidators;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   /**
@@ -220,9 +224,10 @@
 
   private void sendRejectionEmail(MailMessage message, InboundEmailRejectionSender.Error reason) {
     try {
-      InboundEmailRejectionSender em =
+      InboundEmailRejectionSender emailSender =
           emailRejectionSender.create(message.from(), message.id(), reason);
-      em.send();
+      emailSender.setMessageId(messageIdGenerator.fromMailMessage(message));
+      emailSender.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot send email to warn for an error");
     }
@@ -252,7 +257,7 @@
       // Get all comments; filter and sort them to get the original list of
       // comments from the outbound email.
       // TODO(hiesel) Also filter by original comment author.
-      Collection<Comment> comments =
+      Collection<HumanComment> comments =
           cd.publishedComments().stream()
               .filter(c -> (c.writtenOn.getTime() / 1000) == (metadata.timestamp.getTime() / 1000))
               .sorted(CommentsUtil.COMMENT_ORDER)
@@ -314,7 +319,7 @@
     private final List<MailComment> parsedComments;
     private final String tag;
     private ChangeMessage changeMessage;
-    private List<Comment> comments;
+    private List<HumanComment> comments;
     private PatchSet patchSet;
     private ChangeNotes notes;
 
@@ -344,8 +349,10 @@
         comments.add(
             persistentCommentFromMailComment(ctx, c, targetPatchSetForComment(ctx, c, patchSet)));
       }
-      commentsUtil.putComments(
-          ctx.getUpdate(ctx.getChange().currentPatchSetId()), Comment.Status.PUBLISHED, comments);
+      commentsUtil.putHumanComments(
+          ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+          HumanComment.Status.PUBLISHED,
+          comments);
 
       return true;
     }
@@ -366,7 +373,8 @@
               changeMessage,
               comments,
               patchSetComment,
-              ImmutableList.of())
+              ImmutableList.of(),
+              ctx.getRepoView())
           .sendAsync();
       // Get previous approvals from this user
       Map<String, Short> approvals = new HashMap<>();
@@ -410,7 +418,7 @@
       return current;
     }
 
-    private Comment persistentCommentFromMailComment(
+    private HumanComment persistentCommentFromMailComment(
         ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment)
         throws UnprocessableEntityException, PatchListNotAvailableException {
       String fileName;
@@ -425,8 +433,8 @@
         side = Side.REVISION;
       }
 
-      Comment comment =
-          commentsUtil.newComment(
+      HumanComment comment =
+          commentsUtil.newHumanComment(
               ctx,
               fileName,
               patchSetForComment.id(),
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedSender.java b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
index a6fb4de..3ac610d 100644
--- a/java/com/google/gerrit/server/mail/send/AbandonedSender.java
+++ b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -51,9 +51,4 @@
       appendHtml(soyHtmlTemplate("AbandonedHtml"));
     }
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 3b7b2aa..0d447ca 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.common.base.Joiner;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.inject.assistedinject.Assisted;
@@ -35,11 +35,16 @@
   private final IdentifiedUser user;
   private final AccountSshKey sshKey;
   private final List<String> gpgKeys;
+  private final MessageIdGenerator messageIdGenerator;
 
   @AssistedInject
   public AddKeySender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted AccountSshKey sshKey) {
     super(args, "addkey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.sshKey = sshKey;
     this.gpgKeys = null;
@@ -47,8 +52,12 @@
 
   @AssistedInject
   public AddKeySender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted List<String> gpgKeys) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted List<String> gpgKeys) {
     super(args, "addkey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.sshKey = null;
     this.gpgKeys = gpgKeys;
@@ -58,6 +67,7 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
+    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
     add(RecipientType.TO, Address.create(getEmail()));
   }
 
@@ -89,11 +99,6 @@
     soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
   }
 
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
-
   private String getEmail() {
     return user.getAccount().preferredEmail();
   }
diff --git a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
new file mode 100644
index 0000000..b13bcf6
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/** Let users know of a new user in the attention set. */
+public class AddToAttentionSetSender extends AttentionSetSender {
+
+  public interface Factory extends ReplyToChangeSender.Factory<AddToAttentionSetSender> {
+    @Override
+    AddToAttentionSetSender create(Project.NameKey project, Change.Id changeId);
+  }
+
+  @Inject
+  public AddToAttentionSetSender(
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, project, changeId);
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(textTemplate("AddToAttentionSet"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("AddToAttentionSetHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
new file mode 100644
index 0000000..8f898a8
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.EmailException;
+
+/** Base class for Attention Set email senders */
+public abstract class AttentionSetSender extends ReplyToChangeSender {
+  private Account.Id attentionSetUser;
+  private String reason;
+
+  public AttentionSetSender(EmailArguments args, Project.NameKey project, Change.Id changeId) {
+    super(args, "addToAttentionSet", ChangeEmail.newChangeData(args, project, changeId));
+  }
+
+  @Override
+  protected void init() throws EmailException {
+    super.init();
+
+    ccAllApprovals();
+    bccStarredBy();
+    ccExistingReviewers();
+    removeUsersThatIgnoredTheChange();
+  }
+
+  public void setAttentionSetUser(Account.Id attentionSetUser) {
+    this.attentionSetUser = attentionSetUser;
+  }
+
+  public void setReason(String reason) {
+    this.reason = reason;
+  }
+
+  @Override
+  protected void setupSoyContext() {
+    super.setupSoyContext();
+    soyContext.put("attentionSetUser", getNameFor(attentionSetUser));
+    soyContext.put("reason", reason);
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 22d332a..1e984c1 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
@@ -33,7 +34,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchList;
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 48d342e..7d5f3fa 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -22,7 +22,9 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.KeyUtil;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
@@ -33,7 +35,6 @@
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.receive.Protocol;
 import com.google.gerrit.server.patch.PatchFile;
@@ -88,6 +89,16 @@
           .orElse(null);
     }
 
+    /** @return a web link to the comment tab view of a change. */
+    public String getCommentsTabLink() {
+      return args.urlFormatter.get().getCommentsTabView(change).orElse(null);
+    }
+
+    /** @return a web link to the findings tab view of a change. */
+    public String getFindingsTabLink() {
+      return args.urlFormatter.get().getFindingsTabView(change).orElse(null);
+    }
+
     /**
      * @return A title for the group, i.e. "Commit Message", "Merge List", or "File [[filename]]".
      */
@@ -96,13 +107,15 @@
         return "Commit Message";
       } else if (Patch.MERGE_LIST.equals(filename)) {
         return "Merge List";
+      } else if (Patch.PATCHSET_LEVEL.equals(filename)) {
+        return "Patchset";
       } else {
         return "File " + filename;
       }
     }
   }
 
-  private List<Comment> inlineComments = Collections.emptyList();
+  private List<? extends Comment> inlineComments = Collections.emptyList();
   private String patchSetComment;
   private List<LabelVote> labels = Collections.emptyList();
   private final CommentsUtil commentsUtil;
@@ -124,7 +137,7 @@
     this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress");
   }
 
-  public void setComments(List<Comment> comments) {
+  public void setComments(List<? extends Comment> comments) {
     inlineComments = comments;
   }
 
@@ -232,23 +245,21 @@
   /** Get the set of accounts whose comments have been replied to in this email. */
   private HashSet<Account.Id> getReplyAccounts() {
     HashSet<Account.Id> replyAccounts = new HashSet<>();
-
     // Track visited parent UUIDs to avoid cycles.
     HashSet<String> visitedUuids = new HashSet<>();
 
     for (Comment comment : inlineComments) {
       visitedUuids.add(comment.key.uuid);
-
       // Traverse the parent relation to the top of the comment thread.
       Comment current = comment;
       while (current.parentUuid != null && !visitedUuids.contains(current.parentUuid)) {
-        Optional<Comment> optParent = getParent(current);
+        Optional<HumanComment> optParent = getParent(current);
         if (!optParent.isPresent()) {
           // There is a parent UUID, but it cannot be loaded, break from the comment thread.
           break;
         }
 
-        Comment parent = optParent.get();
+        HumanComment parent = optParent.get();
         replyAccounts.add(parent.author.getId());
         visitedUuids.add(current.parentUuid);
         current = parent;
@@ -295,14 +306,13 @@
    * @return an optional comment that will be present if the given comment has a parent, and is
    *     empty if it does not.
    */
-  private Optional<Comment> getParent(Comment child) {
+  private Optional<HumanComment> getParent(Comment child) {
     if (child.parentUuid == null) {
       return Optional.empty();
     }
-
     Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename, child.key.patchSetId);
     try {
-      return commentsUtil.getPublished(changeData.notes(), key);
+      return commentsUtil.getPublishedHumanComment(changeData.notes(), key);
     } catch (StorageException e) {
       logger.atWarning().log("Could not find the parent of this comment: %s", child);
       return Optional.empty();
@@ -379,7 +389,9 @@
 
     for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
       Map<String, Object> groupData = new HashMap<>();
-      groupData.put("link", group.getFileLink());
+      if (!group.filename.equals(Patch.PATCHSET_LEVEL)) {
+        groupData.put("link", group.getFileLink());
+      }
       groupData.put("title", group.getTitle());
       groupData.put("patchSetId", group.patchSetId);
 
@@ -407,7 +419,14 @@
         commentData.put("startLine", startLine);
 
         // Set the comment link.
-        if (comment.lineNbr == 0) {
+
+        if (comment.key.filename.equals(Patch.PATCHSET_LEVEL)) {
+          if (comment instanceof RobotComment) {
+            commentData.put("link", group.getFindingsTabLink());
+          } else {
+            commentData.put("link", group.getCommentsTabLink());
+          }
+        } else if (comment.lineNbr == 0) {
           commentData.put("link", group.getFileLink());
         } else {
           commentData.put("link", group.getCommentLink(comment.side, startLine));
@@ -427,7 +446,7 @@
         // If the comment has a quote, don't bother loading the parent message.
         if (!hasQuote(blocks)) {
           // Set parent comment info.
-          Optional<Comment> parent = getParent(comment);
+          Optional<HumanComment> parent = getParent(comment);
           if (parent.isPresent()) {
             commentData.put("parentMessage", getShortenedCommentMessage(parent.get()));
           }
@@ -553,9 +572,4 @@
     return MailProcessingUtil.rfcDateformatter.format(
         ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.of("UTC")));
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
index 1f58abb..b78dc62 100644
--- a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -17,11 +17,11 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.RefPermission;
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
index 3df7f05..46e7fd8 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.common.base.Joiner;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.inject.assistedinject.Assisted;
@@ -38,11 +38,16 @@
   private final IdentifiedUser user;
   private final AccountSshKey sshKey;
   private final List<String> gpgKeyFingerprints;
+  private final MessageIdGenerator messageIdGenerator;
 
   @AssistedInject
   public DeleteKeySender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted AccountSshKey sshKey) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted AccountSshKey sshKey) {
     super(args, "deletekey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.gpgKeyFingerprints = Collections.emptyList();
     this.sshKey = sshKey;
@@ -51,9 +56,11 @@
   @AssistedInject
   public DeleteKeySender(
       EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
       @Assisted IdentifiedUser user,
       @Assisted List<String> gpgKeyFingerprints) {
     super(args, "deletekey");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.gpgKeyFingerprints = gpgKeyFingerprints;
     this.sshKey = null;
@@ -63,6 +70,7 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
+    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
     add(RecipientType.TO, Address.create(getEmail()));
   }
 
@@ -89,11 +97,6 @@
     soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
   }
 
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
-
   private String getEmail() {
     return user.getAccount().preferredEmail();
   }
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index 4f42679..d5863a6 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
@@ -93,9 +93,4 @@
     super.setupSoyContext();
     soyContextEmailData.put("reviewerNames", getReviewerNames());
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
index 76f9b81..77efbf8 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -51,9 +51,4 @@
       appendHtml(soyHtmlTemplate("DeleteVoteHtml"));
     }
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/EmailSender.java b/java/com/google/gerrit/server/mail/send/EmailSender.java
index 9b3a1f7..711ab1b 100644
--- a/java/com/google/gerrit/server/mail/send/EmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/EmailSender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
 import java.util.Collection;
 import java.util.Map;
 
diff --git a/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java b/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
index 61fa50d..a6d4f6d 100644
--- a/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
+++ b/java/com/google/gerrit/server/mail/send/FromAddressGenerator.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 
 /** Constructs an address to send email from. */
 public interface FromAddressGenerator {
diff --git a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
index dfaabbe..ecf808d 100644
--- a/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
+++ b/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
@@ -19,7 +19,7 @@
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
index cec2bb5..c1c2f31 100644
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 
@@ -29,11 +30,16 @@
 
   private final IdentifiedUser user;
   private final String operation;
+  private final MessageIdGenerator messageIdGenerator;
 
   @AssistedInject
   public HttpPasswordUpdateSender(
-      EmailArguments args, @Assisted IdentifiedUser user, @Assisted String operation) {
+      EmailArguments args,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted IdentifiedUser user,
+      @Assisted String operation) {
     super(args, "HttpPasswordUpdate");
+    this.messageIdGenerator = messageIdGenerator;
     this.user = user;
     this.operation = operation;
   }
@@ -42,6 +48,9 @@
   protected void init() throws EmailException {
     super.init();
     setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
+    setMessageId(
+        messageIdGenerator.fromReasonAccountIdAndTimestamp(
+            "HTTP_password_change", user.getAccountId(), TimeUtil.now()));
     add(RecipientType.TO, Address.create(getEmail()));
   }
 
@@ -67,11 +76,6 @@
     soyContextEmailData.put("operation", operation);
   }
 
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
-
   private String getEmail() {
     return user.getAccount().preferredEmail();
   }
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
index 110f26a..709bf61 100644
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
@@ -16,9 +16,9 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.mail.MailHeader;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -90,9 +90,4 @@
     super.setupSoyContext();
     footers.add(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass);
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
index 92220eb..623bdc2 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
@@ -41,6 +41,8 @@
     "AbandonedHtml.soy",
     "AddKey.soy",
     "AddKeyHtml.soy",
+    "AddToAttentionSet.soy",
+    "AddToAttentionSetHtml.soy",
     "ChangeFooter.soy",
     "ChangeFooterHtml.soy",
     "ChangeSubject.soy",
@@ -69,6 +71,9 @@
     "NoReplyFooterHtml.soy",
     "Private.soy",
     "RegisterNewEmail.soy",
+    "RegisterNewEmailHtml.soy",
+    "RemoveFromAttentionSet.soy",
+    "RemoveFromAttentionSetHtml.soy",
     "ReplacePatchSet.soy",
     "ReplacePatchSetHtml.soy",
     "Restored.soy",
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index b28a4dc..928bdc3 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -16,16 +16,16 @@
 
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.Table;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -131,9 +131,4 @@
     super.setupSoyContext();
     soyContextEmailData.put("approvals", getApprovals());
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
new file mode 100644
index 0000000..3a411dc
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
@@ -0,0 +1,126 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.RepoView;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/** A generator class that creates a {@link MessageId} */
+public class MessageIdGenerator {
+  private final GitRepositoryManager repositoryManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  public MessageIdGenerator(GitRepositoryManager repositoryManager, AllUsersName allUsersName) {
+    this.repositoryManager = repositoryManager;
+    this.allUsersName = allUsersName;
+  }
+
+  /**
+   * A unique id used which is a part of the header of all emails sent through by Gerrit. All of the
+   * emails are sent via {@link OutgoingEmail#send()}.
+   */
+  @AutoValue
+  public abstract static class MessageId {
+    public abstract String id();
+  }
+
+  /**
+   * Create a {@link MessageId} as a result of a change update.
+   *
+   * @param repoView
+   * @param patchsetId
+   * @return MessageId that depends on the patchset.
+   */
+  public MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId) {
+    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
+    Optional<ObjectId> metaSha1;
+    try {
+      metaSha1 = repoView.getRef(metaRef);
+    } catch (IOException ex) {
+      throw new StorageException("unable to extract info for Message-Id", ex);
+    }
+    return metaSha1
+        .map(optional -> new AutoValue_MessageIdGenerator_MessageId(optional.getName()))
+        .orElseThrow(() -> new IllegalStateException(metaRef + " doesn't exist"));
+  }
+
+  public MessageId fromChangeUpdate(Project.NameKey project, PatchSet.Id patchsetId) {
+    String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
+    Ref ref = getRef(metaRef, project);
+    checkState(ref != null, metaRef + " must exist");
+    return new AutoValue_MessageIdGenerator_MessageId(ref.getObjectId().getName());
+  }
+
+  /**
+   * @param accountId Create a {@link MessageId} as a result of an account update.
+   * @return MessageId that depends on the account id.
+   */
+  public MessageId fromAccountUpdate(Account.Id accountId) {
+    String userRef = RefNames.refsUsers(accountId);
+    Ref ref = getRef(userRef, allUsersName);
+    checkState(ref != null, userRef + " must exist");
+    return new AutoValue_MessageIdGenerator_MessageId(ref.getObjectId().getName());
+  }
+
+  /**
+   * Create a {@link MessageId} from a mail message.
+   *
+   * @param mailMessage The message that was sent but was rejected.
+   * @return MessageId that depends on the MailMessage that was rejected.
+   */
+  public MessageId fromMailMessage(MailMessage mailMessage) {
+    return new AutoValue_MessageIdGenerator_MessageId(mailMessage.id() + "-REJECTION");
+  }
+
+  /**
+   * Create a {@link MessageId} from a reason, Account.Id, and timestamp.
+   *
+   * @param reason for performing this account update
+   * @param accountId
+   * @param timestamp
+   * @return MessageId that depends on the reason, accountId, and timestamp.
+   */
+  public MessageId fromReasonAccountIdAndTimestamp(
+      String reason, Account.Id accountId, Instant timestamp) {
+    return new AutoValue_MessageIdGenerator_MessageId(
+        reason + "-" + accountId.toString() + "-" + timestamp.toString());
+  }
+
+  private Ref getRef(String userRef, Project.NameKey project) {
+    try (Repository repository = repositoryManager.openRepository(project)) {
+      return repository.getRefDatabase().findRef(userRef);
+    } catch (IOException ex) {
+      throw new StorageException("unable to extract info for Message-Id", ex);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 83c3a94..0e97f7e 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -57,7 +57,6 @@
     super.init();
 
     String threadId = getChangeMessageThreadId();
-    setHeader("Message-ID", threadId);
     setHeader("References", threadId);
 
     switch (notify.handling()) {
@@ -103,9 +102,4 @@
     soyContext.put("ownerName", getNameFor(change.getOwner()));
     soyContextEmailData.put("reviewerNames", getReviewerNames());
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index 0fb5c6f..5ffd928 100644
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -18,13 +18,13 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.mail.MailHeader;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import java.util.HashMap;
 import java.util.Map;
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index b35bbec..1eb274b 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -22,14 +22,14 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.EmailHeader.AddressList;
 import com.google.gerrit.entities.UserIdentity;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
-import com.google.gerrit.mail.EmailHeader.AddressList;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -67,6 +67,7 @@
   private Address smtpFromAddress;
   private StringBuilder textBody;
   private StringBuilder htmlBody;
+  private MessageIdGenerator.MessageId messageId;
   protected Map<String, Object> soyContext;
   protected Map<String, Object> soyContextEmailData;
   protected List<String> footers;
@@ -88,6 +89,10 @@
     this.notify = requireNonNull(notify);
   }
 
+  public void setMessageId(MessageIdGenerator.MessageId messageId) {
+    this.messageId = messageId;
+  }
+
   /**
    * Format and enqueue the message for delivery.
    *
@@ -108,6 +113,9 @@
     }
 
     init();
+    if (messageId == null) {
+      throw new IllegalStateException("All emails must have a messageId");
+    }
     if (useHtml()) {
       appendHtml(soyHtmlTemplate("HeaderHtml"));
     }
@@ -201,31 +209,21 @@
         va.htmlBody = null;
       }
 
-      for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
-        try {
-          validator.validateOutgoingEmail(va);
-        } catch (ValidationException e) {
-          logger.atFine().log(
-              "Not sending '%s': Rejected by outgoing email validator: %s",
-              messageClass, e.getMessage());
-          return;
-        }
-      }
-
       Set<Address> intersection = Sets.intersection(va.smtpRcptTo, smtpRcptToPlaintextOnly);
       if (!intersection.isEmpty()) {
         logger.atSevere().log("Email '%s' will be sent twice to %s", messageClass, intersection);
       }
-
       if (!va.smtpRcptTo.isEmpty()) {
         // Send multipart message
+        addMessageId(va, "-HTML");
+        if (!validateEmail(va)) return;
         logger.atFine().log(
             "Sending multipart '%s' from %s to %s",
             messageClass, va.smtpFromAddress, va.smtpRcptTo);
         args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body, va.htmlBody);
       }
-
       if (!smtpRcptToPlaintextOnly.isEmpty()) {
+        addMessageId(va, "-PLAIN");
         // Send plaintext message
         Map<String, EmailHeader> shallowCopy = new HashMap<>();
         shallowCopy.putAll(headers);
@@ -238,6 +236,7 @@
           to.add(a);
           shallowCopy.put(FieldName.TO, to);
         }
+        if (!validateEmail(va)) return;
         logger.atFine().log(
             "Sending plaintext '%s' from %s to %s",
             messageClass, va.smtpFromAddress, smtpRcptToPlaintextOnly);
@@ -246,6 +245,29 @@
     }
   }
 
+  private boolean validateEmail(OutgoingEmailValidationListener.Args va) {
+    for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) {
+      try {
+        validator.validateOutgoingEmail(va);
+      } catch (ValidationException e) {
+        logger.atFine().log(
+            "Not sending '%s': Rejected by outgoing email validator: %s",
+            messageClass, e.getMessage());
+        return false;
+      }
+    }
+    return true;
+  }
+
+  // All message ids must start with < and end with >. Also, they must have @domain and no spaces.
+  private void addMessageId(OutgoingEmailValidationListener.Args va, String suffix) {
+    if (messageId != null) {
+      String message = "<" + messageId.id() + suffix + "@" + getGerritHost() + ">";
+      message = message.replaceAll("\\s", "");
+      va.headers.put(FieldName.MESSAGE_ID, new EmailHeader.String(message));
+    }
+  }
+
   /** Format the message body by calling {@link #appendText(String)}. */
   protected abstract void format() throws EmailException;
 
@@ -262,7 +284,6 @@
     headers.put(FieldName.FROM, new EmailHeader.AddressList(smtpFromAddress));
     headers.put(FieldName.TO, new EmailHeader.AddressList());
     headers.put(FieldName.CC, new EmailHeader.AddressList());
-    setHeader(FieldName.MESSAGE_ID, "");
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
     for (RecipientType recipientType : notify.accounts().keySet()) {
@@ -610,11 +631,6 @@
   }
 
   protected final boolean useHtml() {
-    return args.settings.html && supportsHtml();
-  }
-
-  /** Override this method to enable HTML in a subclass. */
-  protected boolean supportsHtml() {
-    return false;
+    return args.settings.html;
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index ef58744..0514337 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -17,20 +17,19 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
-import com.google.gerrit.server.git.NotifyConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -61,13 +60,15 @@
   }
 
   /** Returns all watchers that are relevant */
-  public final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
+  public final Watchers getWatchers(
+      NotifyConfig.NotifyType type, boolean includeWatchersFromNotifyConfig) {
     Watchers matching = new Watchers();
     Set<Account.Id> projectWatchers = new HashSet<>();
 
     for (AccountState a : args.accountQueryProvider.get().byWatchedProject(project)) {
       Account.Id accountId = a.account().id();
-      for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyType>> e : a.projectWatches().entrySet()) {
+      for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> e :
+          a.projectWatches().entrySet()) {
         if (project.equals(e.getKey().project())
             && add(matching, accountId, e.getKey(), e.getValue(), type)) {
           // We only want to prevent matching All-Projects if this filter hits
@@ -77,7 +78,8 @@
     }
 
     for (AccountState a : args.accountQueryProvider.get().byWatchedProject(args.allProjectsName)) {
-      for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyType>> e : a.projectWatches().entrySet()) {
+      for (Map.Entry<ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>> e :
+          a.projectWatches().entrySet()) {
         if (args.allProjectsName.equals(e.getKey().project())) {
           Account.Id accountId = a.account().id();
           if (!projectWatchers.contains(accountId)) {
@@ -92,7 +94,7 @@
     }
 
     for (ProjectState state : projectState.tree()) {
-      for (NotifyConfig nc : state.getConfig().getNotifyConfigs()) {
+      for (NotifyConfig nc : state.getConfig().getNotifySections().values()) {
         if (nc.isNotify(type)) {
           try {
             add(matching, state.getNameKey(), nc);
@@ -212,8 +214,8 @@
       Watchers matching,
       Account.Id accountId,
       ProjectWatchKey key,
-      Set<NotifyType> watchedTypes,
-      NotifyType type) {
+      Set<NotifyConfig.NotifyType> watchedTypes,
+      NotifyConfig.NotifyType type) {
     logger.atFine().log("Checking project watch %s of account %s", key, accountId);
 
     IdentifiedUser user = args.identifiedUserFactory.create(accountId);
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
index bb2efe6..a54a652 100644
--- a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
@@ -16,9 +16,9 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.mail.EmailTokenVerifier;
 import com.google.inject.Inject;
@@ -60,6 +60,9 @@
   @Override
   protected void format() throws EmailException {
     appendText(textTemplate("RegisterNewEmail"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("RegisterNewEmailHtml"));
+    }
   }
 
   public boolean isAllowed() {
diff --git a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
new file mode 100644
index 0000000..6762b7d
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/** Let users know of a user removed from the attention set. */
+public class RemoveFromAttentionSetSender extends AttentionSetSender {
+
+  public interface Factory extends ReplyToChangeSender.Factory<RemoveFromAttentionSetSender> {
+    @Override
+    RemoveFromAttentionSetSender create(Project.NameKey project, Change.Id changeId);
+  }
+
+  @Inject
+  public RemoveFromAttentionSetSender(
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+    super(args, project, changeId);
+  }
+
+  @Override
+  protected void formatChange() throws EmailException {
+    appendText(textTemplate("RemoveFromAttentionSet"));
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("RemoveFromAttentionSetHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 909c52a..274e664 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -16,11 +16,11 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
@@ -99,9 +99,4 @@
     super.setupSoyContext();
     soyContextEmailData.put("reviewerNames", getReviewerNames());
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/RestoredSender.java b/java/com/google/gerrit/server/mail/send/RestoredSender.java
index 2a4c556..ffe70cf 100644
--- a/java/com/google/gerrit/server/mail/send/RestoredSender.java
+++ b/java/com/google/gerrit/server/mail/send/RestoredSender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -51,9 +51,4 @@
       appendHtml(soyHtmlTemplate("RestoredHtml"));
     }
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/RevertedSender.java b/java/com/google/gerrit/server/mail/send/RevertedSender.java
index dadd0d2..c11529b 100644
--- a/java/com/google/gerrit/server/mail/send/RevertedSender.java
+++ b/java/com/google/gerrit/server/mail/send/RevertedSender.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -50,9 +50,4 @@
       appendHtml(soyHtmlTemplate("RevertedHtml"));
     }
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java b/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
index 2b1e362..29f4c69 100644
--- a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
+++ b/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
@@ -66,9 +66,4 @@
     super.setupSoyContext();
     soyContextEmailData.put("assigneeName", getNameFor(assignee));
   }
-
-  @Override
-  protected boolean supportsHtml() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 8e53558..af00b20 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -21,9 +21,9 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.Version;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.Encryption;
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 7136d2b..8e6606e 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -110,7 +110,7 @@
       ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Date when) {
     checkUserType(u);
     if (u instanceof IdentifiedUser) {
-      return noteUtil.newIdent(u.asIdentifiedUser().getAccount().id(), when, serverIdent);
+      return noteUtil.newAccountIdIdent(u.asIdentifiedUser().getAccount().id(), when, serverIdent);
     } else if (u instanceof InternalUser) {
       return serverIdent;
     }
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 4b538f3..57f6353 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -19,10 +19,10 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.auto.value.AutoValue;
-import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -37,7 +37,6 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -84,13 +83,13 @@
     FIXED
   }
 
-  private static Key key(Comment c) {
+  private static Key key(HumanComment c) {
     return new AutoValue_ChangeDraftUpdate_Key(c.getCommitId(), c.key);
   }
 
   private final AllUsersName draftsProject;
 
-  private List<Comment> put = new ArrayList<>();
+  private List<HumanComment> put = new ArrayList<>();
   private Map<Key, DeleteReason> delete = new HashMap<>();
 
   @AssistedInject
@@ -121,7 +120,7 @@
     this.draftsProject = allUsers;
   }
 
-  public void putComment(Comment c) {
+  public void putComment(HumanComment c) {
     checkState(!put.contains(c), "comment already added");
     verifyComment(c);
     put.add(c);
@@ -130,7 +129,7 @@
   /**
    * Marks a comment for deletion. Called when the comment is deleted because the user published it.
    */
-  public void markCommentPublished(Comment c) {
+  public void markCommentPublished(HumanComment c) {
     checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
     verifyComment(c);
     delete.put(key(c), DeleteReason.PUBLISHED);
@@ -139,7 +138,7 @@
   /**
    * Marks a comment for deletion. Called when the comment is deleted because the user removed it.
    */
-  public void deleteComment(Comment c) {
+  public void deleteComment(HumanComment c) {
     checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
     verifyComment(c);
     delete.put(key(c), DeleteReason.DELETED);
@@ -191,10 +190,9 @@
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, IOException {
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
-    Set<ObjectId> updatedCommits = Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
 
-    for (Comment c : put) {
+    for (HumanComment c : put) {
       if (!delete.keySet().contains(key(c))) {
         cache.get(c.getCommitId()).putComment(c);
       }
@@ -207,7 +205,6 @@
     Map<ObjectId, RevisionNoteBuilder> builders = cache.getBuilders();
     boolean touchedAnyRevs = false;
     for (Map.Entry<ObjectId, RevisionNoteBuilder> e : builders.entrySet()) {
-      updatedCommits.add(e.getKey());
       ObjectId id = e.getKey();
       byte[] data = e.getValue().build(noteUtil.getChangeNoteJson());
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
@@ -263,7 +260,7 @@
     // Even though reading from changes might not be enabled, we need to
     // parse any existing revision notes so we can merge them.
     return RevisionNoteMap.parse(
-        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, Comment.Status.DRAFT);
+        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, HumanComment.Status.DRAFT);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 86b6ed7..15f187a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -69,16 +69,38 @@
     return changeNoteJson;
   }
 
-  public PersonIdent newIdent(Account.Id accountId, Date when, PersonIdent serverIdent) {
-    return new PersonIdent(
-        getUsername(accountId), getEmailAddress(accountId), when, serverIdent.getTimeZone());
+  /**
+   * Generates a user identifier that contains the account ID, but not the user's name or email
+   * address.
+   *
+   * @return The passed in {@link StringBuilder} instance to which the identifier has been appended.
+   */
+  StringBuilder appendAccountIdIdentString(StringBuilder stringBuilder, Account.Id accountId) {
+    return stringBuilder
+        .append(getAccountIdAsUsername(accountId))
+        .append(" <")
+        .append(getAccountIdAsEmailAddress(accountId))
+        .append('>');
   }
 
-  private static String getUsername(Account.Id accountId) {
+  /**
+   * Returns a {@link PersonIdent} that contains the account ID, but not the user's name or email
+   * address.
+   */
+  public PersonIdent newAccountIdIdent(Account.Id accountId, Date when, PersonIdent serverIdent) {
+    return new PersonIdent(
+        getAccountIdAsUsername(accountId),
+        getAccountIdAsEmailAddress(accountId),
+        when,
+        serverIdent.getTimeZone());
+  }
+
+  /** Returns the string {@code "Gerrit User " + accountId}, to pseudonymize user names. */
+  public static String getAccountIdAsUsername(Account.Id accountId) {
     return "Gerrit User " + accountId.toString();
   }
 
-  private String getEmailAddress(Account.Id accountId) {
+  private String getAccountIdAsEmailAddress(Account.Id accountId) {
     return accountId.get() + "@" + serverId;
   }
 
@@ -198,21 +220,10 @@
   }
 
   String attentionSetUpdateToJson(AttentionSetUpdate attentionSetUpdate) {
-    PersonIdent personIdent =
-        new PersonIdent(
-            getUsername(attentionSetUpdate.account()),
-            getEmailAddress(attentionSetUpdate.account()));
     StringBuilder stringBuilder = new StringBuilder();
-    appendIdentString(stringBuilder, personIdent.getName(), personIdent.getEmailAddress());
+    appendAccountIdIdentString(stringBuilder, attentionSetUpdate.account());
     return gson.toJson(
         new AttentionStatusInNoteDb(
             stringBuilder.toString(), attentionSetUpdate.operation(), attentionSetUpdate.reason()));
   }
-
-  static void appendIdentString(StringBuilder stringBuilder, String name, String emailAddress) {
-    PersonIdent.appendSanitized(stringBuilder, name);
-    stringBuilder.append(" <");
-    PersonIdent.appendSanitized(stringBuilder, emailAddress);
-    stringBuilder.append('>');
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 36a61cc0..00e4765 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -38,18 +38,19 @@
 import com.google.common.collect.Sets.SetView;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
@@ -435,14 +436,14 @@
   }
 
   /** @return inline comments on each revision. */
-  public ImmutableListMultimap<ObjectId, Comment> getComments() {
+  public ImmutableListMultimap<ObjectId, HumanComment> getHumanComments() {
     return state.publishedComments();
   }
 
   public ImmutableSet<Comment.Key> getCommentKeys() {
     if (commentKeys == null) {
       ImmutableSet.Builder<Comment.Key> b = ImmutableSet.builder();
-      for (Comment c : getComments().values()) {
+      for (Comment c : getHumanComments().values()) {
         b.add(new Comment.Key(c.key));
       }
       commentKeys = b.build();
@@ -454,11 +455,11 @@
     return state.updateCount();
   }
 
-  public ImmutableListMultimap<ObjectId, Comment> getDraftComments(Account.Id author) {
+  public ImmutableListMultimap<ObjectId, HumanComment> getDraftComments(Account.Id author) {
     return getDraftComments(author, null);
   }
 
-  public ImmutableListMultimap<ObjectId, Comment> getDraftComments(
+  public ImmutableListMultimap<ObjectId, HumanComment> getDraftComments(
       Account.Id author, @Nullable Ref ref) {
     loadDraftComments(author, ref);
     // Filter out any zombie draft comments. These are drafts that are also in
@@ -502,7 +503,7 @@
     return robotCommentNotes;
   }
 
-  public boolean containsComment(Comment c) {
+  public boolean containsComment(HumanComment c) {
     if (containsCommentPublished(c)) {
       return true;
     }
@@ -511,7 +512,7 @@
   }
 
   public boolean containsCommentPublished(Comment c) {
-    for (Comment l : getComments().values()) {
+    for (Comment l : getHumanComments().values()) {
       if (c.key.equals(l.key)) {
         return true;
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index a884b70..c92d236 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -55,18 +55,18 @@
 import com.google.common.collect.Tables;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
@@ -121,7 +121,7 @@
 
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
-  private final ListMultimap<ObjectId, Comment> comments;
+  private final ListMultimap<ObjectId, HumanComment> humanComments;
   private final Map<PatchSet.Id, PatchSet.Builder> patchSets;
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
@@ -178,7 +178,7 @@
     assigneeUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
-    comments = MultimapBuilder.hashKeys().arrayListValues().build();
+    humanComments = MultimapBuilder.hashKeys().arrayListValues().build();
     patchSets = new HashMap<>();
     deletedPatchSets = new HashSet<>();
     patchSetStates = new HashMap<>();
@@ -249,7 +249,7 @@
         assigneeUpdates,
         submitRecords,
         buildAllMessages(),
-        comments,
+        humanComments,
         firstNonNull(isPrivate, false),
         firstNonNull(workInProgress, false),
         firstNonNull(hasReviewStarted, true),
@@ -735,12 +735,12 @@
     ChangeNotesCommit tipCommit = walk.parseCommit(tip);
     revisionNoteMap =
         RevisionNoteMap.parse(
-            changeNoteJson, reader, NoteMap.read(reader, tipCommit), Comment.Status.PUBLISHED);
+            changeNoteJson, reader, NoteMap.read(reader, tipCommit), HumanComment.Status.PUBLISHED);
     Map<ObjectId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
 
     for (Map.Entry<ObjectId, ChangeRevisionNote> e : rns.entrySet()) {
-      for (Comment c : e.getValue().getEntities()) {
-        comments.put(e.getKey(), c);
+      for (HumanComment c : e.getValue().getEntities()) {
+        humanComments.put(e.getKey(), c);
       }
     }
 
@@ -1055,7 +1055,7 @@
         pruneEntitiesForMissingPatchSets(allChangeMessages, ChangeMessage::getPatchSetId, missing);
     pruned +=
         pruneEntitiesForMissingPatchSets(
-            comments.values(), c -> PatchSet.id(id, c.key.patchSetId), missing);
+            humanComments.values(), c -> PatchSet.id(id, c.key.patchSetId), missing);
     pruned +=
         pruneEntitiesForMissingPatchSets(
             approvals.values(), psa -> psa.key().patchSetId(), missing);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 0f27b75..76c4678 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -33,22 +33,22 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.Table;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
@@ -123,7 +123,7 @@
       List<AssigneeStatusUpdate> assigneeUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
-      ListMultimap<ObjectId, Comment> publishedComments,
+      ListMultimap<ObjectId, HumanComment> publishedComments,
       boolean isPrivate,
       boolean workInProgress,
       boolean reviewStarted,
@@ -314,7 +314,7 @@
 
   abstract ImmutableList<ChangeMessage> changeMessages();
 
-  abstract ImmutableListMultimap<ObjectId, Comment> publishedComments();
+  abstract ImmutableListMultimap<ObjectId, HumanComment> publishedComments();
 
   abstract int updateCount();
 
@@ -427,7 +427,7 @@
 
     abstract Builder changeMessages(List<ChangeMessage> changeMessages);
 
-    abstract Builder publishedComments(ListMultimap<ObjectId, Comment> publishedComments);
+    abstract Builder publishedComments(ListMultimap<ObjectId, HumanComment> publishedComments);
 
     abstract Builder updateCount(int updateCount);
 
@@ -634,8 +634,8 @@
                       .collect(toImmutableList()))
               .publishedComments(
                   proto.getPublishedCommentList().stream()
-                      .map(r -> GSON.fromJson(r, Comment.class))
-                      .collect(toImmutableListMultimap(Comment::getCommitId, c -> c)))
+                      .map(r -> GSON.fromJson(r, HumanComment.class))
+                      .collect(toImmutableListMultimap(HumanComment::getCommitId, c -> c)))
               .updateCount(proto.getUpdateCount());
       return b.build();
     }
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index 4e52093..bf2cf07 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -16,7 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -29,13 +29,13 @@
 import org.eclipse.jgit.util.MutableInteger;
 
 /** Implements the parsing of comment data, handling JSON decoding and push certificates. */
-class ChangeRevisionNote extends RevisionNote<Comment> {
+class ChangeRevisionNote extends RevisionNote<HumanComment> {
   private final ChangeNoteJson noteJson;
-  private final Comment.Status status;
+  private final HumanComment.Status status;
   private String pushCert;
 
   ChangeRevisionNote(
-      ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, Comment.Status status) {
+      ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, HumanComment.Status status) {
     super(reader, noteId);
     this.noteJson = noteJson;
     this.status = status;
@@ -47,12 +47,13 @@
   }
 
   @Override
-  protected List<Comment> parse(byte[] raw, int offset) throws IOException, ConfigInvalidException {
+  protected List<HumanComment> parse(byte[] raw, int offset)
+      throws IOException, ConfigInvalidException {
     MutableInteger p = new MutableInteger();
     p.value = offset;
 
-    RevisionNoteData data = parseJson(noteJson, raw, p.value);
-    if (status == Comment.Status.PUBLISHED) {
+    HumanCommentsRevisionNoteData data = parseJson(noteJson, raw, p.value);
+    if (status == HumanComment.Status.PUBLISHED) {
       pushCert = data.pushCert;
     } else {
       pushCert = null;
@@ -60,11 +61,11 @@
     return data.comments;
   }
 
-  private RevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
+  private HumanCommentsRevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
       throws IOException {
     try (InputStream is = new ByteArrayInputStream(raw, offset, raw.length - offset);
         Reader r = new InputStreamReader(is, UTF_8)) {
-      return noteUtil.getGson().fromJson(r, RevisionNoteData.class);
+      return noteUtil.getGson().fromJson(r, HumanCommentsRevisionNoteData.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 63f4e5d..6e0d807 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -50,21 +50,26 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Table;
 import com.google.common.collect.TreeBasedTable;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.RobotClassifier;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.assistedinject.Assisted;
@@ -73,6 +78,7 @@
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -80,6 +86,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -114,11 +121,12 @@
   private final ChangeDraftUpdate.Factory draftUpdateFactory;
   private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
   private final DeleteCommentRewriter.Factory deleteCommentRewriterFactory;
+  private final RobotClassifier robotClassifier;
 
   private final Table<String, Account.Id, Optional<Short>> approvals;
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
   private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
-  private final List<Comment> comments = new ArrayList<>();
+  private final List<HumanComment> comments = new ArrayList<>();
 
   private String commitSubject;
   private String subject;
@@ -129,7 +137,8 @@
   private String submissionId;
   private String topic;
   private String commit;
-  private Set<AttentionSetUpdate> attentionSetUpdates;
+  private Map<Account.Id, AttentionSetUpdate> plannedAttentionSetUpdates;
+  private boolean ignoreFurtherAttentionSetUpdates;
   private Optional<Account.Id> assignee;
   private Set<String> hashtags;
   private String changeMessage;
@@ -158,6 +167,7 @@
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
       DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ProjectCache projectCache,
+      RobotClassifier robotClassifier,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user,
       @Assisted Date when,
@@ -168,6 +178,7 @@
         draftUpdateFactory,
         robotCommentUpdateFactory,
         deleteCommentRewriterFactory,
+        robotClassifier,
         notes,
         user,
         when,
@@ -191,6 +202,7 @@
       ChangeDraftUpdate.Factory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
       DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
+      RobotClassifier robotClassifier,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user,
       @Assisted Date when,
@@ -201,6 +213,7 @@
     this.draftUpdateFactory = draftUpdateFactory;
     this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
+    this.robotClassifier = robotClassifier;
     this.approvals = approvals(labelNameComparator);
   }
 
@@ -285,10 +298,10 @@
     this.psDescription = psDescription;
   }
 
-  public void putComment(Comment.Status status, Comment c) {
+  public void putComment(HumanComment.Status status, HumanComment c) {
     verifyComment(c);
     createDraftUpdateIfNull();
-    if (status == Comment.Status.DRAFT) {
+    if (status == HumanComment.Status.DRAFT) {
       draftUpdate.putComment(c);
     } else {
       comments.add(c);
@@ -302,7 +315,7 @@
     robotCommentUpdate.putComment(c);
   }
 
-  public void deleteComment(Comment c) {
+  public void deleteComment(HumanComment c) {
     verifyComment(c);
     createDraftUpdateIfNull().deleteComment(c);
   }
@@ -372,17 +385,40 @@
 
   /**
    * All updates must have a timestamp of null since we use the commit's timestamp. There also must
-   * not be multiple updates for a single user.
+   * not be multiple updates for a single user. Only the first update takes place because of the
+   * different priorities: e.g, if we want to add someone to the attention set but also want to
+   * remove someone from the attention set, we should ensure to add/remove that user based on the
+   * priority of the addition and removal. If most importantly we want to remove the user, then we
+   * must first create the removal, and the addition will not take effect.
    */
-  public void setAttentionSetUpdates(Set<AttentionSetUpdate> attentionSetUpdates) {
+  public void addToPlannedAttentionSetUpdates(Set<AttentionSetUpdate> updates) {
+    if (updates == null || updates.isEmpty() || ignoreFurtherAttentionSetUpdates) {
+      // No updates to do. Robots don't change attention set.
+      return;
+    }
     checkArgument(
-        attentionSetUpdates.stream().noneMatch(a -> a.timestamp() != null),
+        updates.stream().noneMatch(a -> a.timestamp() != null),
         "must not specify timestamp for write");
+
     checkArgument(
-        attentionSetUpdates.stream().map(AttentionSetUpdate::account).distinct().count()
-            == attentionSetUpdates.size(),
+        updates.stream().map(AttentionSetUpdate::account).distinct().count() == updates.size(),
         "must not specify multiple updates for single user");
-    this.attentionSetUpdates = attentionSetUpdates;
+
+    if (plannedAttentionSetUpdates == null) {
+      plannedAttentionSetUpdates = new HashMap<>();
+    }
+
+    Set<Account.Id> currentAccountUpdates =
+        plannedAttentionSetUpdates.values().stream()
+            .map(AttentionSetUpdate::account)
+            .collect(Collectors.toSet());
+    updates.stream()
+        .filter(u -> !currentAccountUpdates.contains(u.account()))
+        .forEach(u -> plannedAttentionSetUpdates.putIfAbsent(u.account(), u));
+  }
+
+  public void addToPlannedAttentionSetUpdates(AttentionSetUpdate update) {
+    addToPlannedAttentionSetUpdates(ImmutableSet.of(update));
   }
 
   public void setAssignee(Account.Id assignee) {
@@ -449,7 +485,7 @@
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
 
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
-    for (Comment c : comments) {
+    for (HumanComment c : comments) {
       c.tag = tag;
       cache.get(c.getCommitId()).putComment(c);
     }
@@ -486,7 +522,7 @@
     // Even though reading from changes might not be enabled, we need to
     // parse any existing revision notes so we can merge them.
     return RevisionNoteMap.parse(
-        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, Comment.Status.PUBLISHED);
+        noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, HumanComment.Status.PUBLISHED);
   }
 
   private void checkComments(
@@ -583,6 +619,12 @@
 
     if (status != null) {
       addFooter(msg, FOOTER_STATUS, status.name().toLowerCase());
+      if (status.equals(Change.Status.ABANDONED)) {
+        clearAttentionSet("Change was abandoned");
+      }
+      if (status.equals(Change.Status.MERGED)) {
+        clearAttentionSet("Change was submitted");
+      }
     }
 
     if (topic != null) {
@@ -593,16 +635,10 @@
       addFooter(msg, FOOTER_COMMIT, commit);
     }
 
-    if (attentionSetUpdates != null) {
-      for (AttentionSetUpdate attentionSetUpdate : attentionSetUpdates) {
-        addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
-      }
-    }
-
     if (assignee != null) {
       if (assignee.isPresent()) {
         addFooter(msg, FOOTER_ASSIGNEE);
-        addIdent(msg, assignee.get()).append('\n');
+        noteUtil.appendAccountIdIdentString(msg, assignee.get()).append('\n');
       } else {
         addFooter(msg, FOOTER_ASSIGNEE).append('\n');
       }
@@ -623,9 +659,11 @@
 
     for (Map.Entry<Account.Id, ReviewerStateInternal> e : reviewers.entrySet()) {
       addFooter(msg, e.getValue().getFooterKey());
-      addIdent(msg, e.getKey()).append('\n');
+      noteUtil.appendAccountIdIdentString(msg, e.getKey()).append('\n');
     }
 
+    applyReviewerUpdatesToAttentionSet();
+
     for (Map.Entry<Address, ReviewerStateInternal> e : reviewersByEmail.entrySet()) {
       addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
     }
@@ -640,7 +678,7 @@
       }
       Account.Id id = c.getColumnKey();
       if (!id.equals(getAccountId())) {
-        addIdent(msg.append(' '), id);
+        noteUtil.appendAccountIdIdentString(msg.append(' '), id);
       }
       msg.append('\n');
     }
@@ -666,7 +704,7 @@
                 .append(label.label);
             if (label.appliedBy != null) {
               msg.append(": ");
-              addIdent(msg, label.appliedBy);
+              noteUtil.appendAccountIdIdentString(msg, label.appliedBy);
             }
             msg.append('\n');
           }
@@ -677,7 +715,7 @@
 
     if (!Objects.equals(accountId, realAccountId)) {
       addFooter(msg, FOOTER_REAL_USER);
-      addIdent(msg, realAccountId).append('\n');
+      noteUtil.appendAccountIdIdentString(msg, realAccountId).append('\n');
     }
 
     if (isPrivate != null) {
@@ -686,6 +724,11 @@
 
     if (workInProgress != null) {
       addFooter(msg, FOOTER_WORK_IN_PROGRESS, workInProgress);
+      if (workInProgress) {
+        clearAttentionSet("Change was marked work in progress");
+      } else {
+        addAllReviewersToAttentionSet();
+      }
     }
 
     if (revertOf != null) {
@@ -696,6 +739,10 @@
       addFooter(msg, FOOTER_CHERRY_PICK_OF, cherryPickOf);
     }
 
+    if (plannedAttentionSetUpdates != null) {
+      updateAttentionSet(msg);
+    }
+
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage(msg.toString());
     try {
@@ -709,6 +756,108 @@
     return cb;
   }
 
+  private void clearAttentionSet(String reason) {
+    if (getNotes().getAttentionSet() == null) {
+      return;
+    }
+    AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
+        .map(
+            a ->
+                AttentionSetUpdate.createForWrite(
+                    a.account(), AttentionSetUpdate.Operation.REMOVE, reason))
+        .forEach(this::addToPlannedAttentionSetUpdates);
+  }
+
+  private void applyReviewerUpdatesToAttentionSet() {
+    if ((workInProgress != null && workInProgress == true)
+        || getNotes().getChange().isWorkInProgress()
+        || status == Change.Status.MERGED) {
+      // Attention set shouldn't change here for changes that are work in progress or are about to
+      // be submitted or when the caller is a robot.
+      return;
+    }
+    Set<Account.Id> currentReviewers =
+        getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER);
+    Set<AttentionSetUpdate> updates = new HashSet<>();
+    for (Map.Entry<Account.Id, ReviewerStateInternal> reviewer : reviewers.entrySet()) {
+      // Only add new reviewers to the attention set.
+      if (reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
+          && !currentReviewers.contains(reviewer.getKey())) {
+        updates.add(
+            AttentionSetUpdate.createForWrite(
+                reviewer.getKey(), AttentionSetUpdate.Operation.ADD, "Reviewer was added"));
+      }
+      // Treat both REMOVED and CC as "removed reviewers".
+      if (!reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
+          && currentReviewers.contains(reviewer.getKey())) {
+        updates.add(
+            AttentionSetUpdate.createForWrite(
+                reviewer.getKey(), AttentionSetUpdate.Operation.REMOVE, "Reviewer was removed"));
+      }
+    }
+    addToPlannedAttentionSetUpdates(updates);
+  }
+
+  private void addAllReviewersToAttentionSet() {
+    getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER).stream()
+        .map(
+            r ->
+                AttentionSetUpdate.createForWrite(
+                    r, AttentionSetUpdate.Operation.ADD, "Change was marked ready for review"))
+        .forEach(this::addToPlannedAttentionSetUpdates);
+  }
+
+  /**
+   * Any updates to the attention set must be done in {@link #addToPlannedAttentionSetUpdates}. This
+   * method is called after all the updates are finished to do the updates once and for real.
+   */
+  private void updateAttentionSet(StringBuilder msg) {
+    if (plannedAttentionSetUpdates == null) {
+      return;
+    }
+    Set<Account.Id> currentUsersInAttentionSet =
+        AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
+            .map(AttentionSetUpdate::account)
+            .collect(Collectors.toSet());
+    for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
+      if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
+          && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
+        // Skip users that are already in the attention set: no need to re-add them.
+        continue;
+      }
+
+      if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.REMOVE
+          && !currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
+        // Skip users that are not in the attention set: no need to remove them.
+        continue;
+      }
+
+      if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
+          && robotClassifier.isRobot(attentionSetUpdate.account())) {
+        // Skip adding robots to the attention set.
+        continue;
+      }
+
+      if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
+          && approvals.rowKeySet().contains(LabelId.legacySubmit().get())) {
+        // On submit, we sometimes can add the person who submitted the change as a reviewer, and in
+        // turn it will add that person to the attention set.
+        // This ensures we don't add users to the attention set on submit.
+        continue;
+      }
+
+      addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
+    }
+  }
+
+  /**
+   * When set, default attention set rules are ignored (E.g, adding reviewers -> adds to attention
+   * set, etc).
+   */
+  public void ignoreFurtherAttentionSetUpdates() {
+    ignoreFurtherAttentionSetUpdates = true;
+  }
+
   private void addPatchSetFooter(StringBuilder sb, int ps) {
     addFooter(sb, FOOTER_PATCH_SET).append(ps);
     if (psState != null) {
@@ -735,7 +884,7 @@
         && status == null
         && submissionId == null
         && submitRecords == null
-        && attentionSetUpdates == null
+        && plannedAttentionSetUpdates == null
         && assignee == null
         && hashtags == null
         && topic == null
@@ -799,10 +948,4 @@
   private static boolean isIllegalTopic(String topic) {
     return (topic != null && topic.contains("\""));
   }
-
-  private StringBuilder addIdent(StringBuilder sb, Account.Id accountId) {
-    PersonIdent ident = noteUtil.newIdent(accountId, when, serverIdent);
-    ChangeNoteUtil.appendIdentString(sb, ident.getName(), ident.getEmailAddress());
-    return sb;
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
index 9c8b369..d0b6247 100644
--- a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -15,14 +15,13 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.entities.Comment.Status;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.RefNames;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -94,14 +93,14 @@
 
     ObjectReader reader = revWalk.getObjectReader();
     RevCommit newTipCommit = revWalk.next(); // The first commit will not be rewritten.
-    Map<String, Comment> parentComments =
+    Map<String, HumanComment> parentComments =
         getPublishedComments(noteUtil, reader, NoteMap.read(reader, newTipCommit));
 
     boolean rewrite = false;
     RevCommit originalCommit;
     while ((originalCommit = revWalk.next()) != null) {
       NoteMap noteMap = NoteMap.read(reader, originalCommit);
-      Map<String, Comment> currComments = getPublishedComments(noteUtil, reader, noteMap);
+      Map<String, HumanComment> currComments = getPublishedComments(noteUtil, reader, noteMap);
 
       if (!rewrite && currComments.containsKey(uuid)) {
         rewrite = true;
@@ -113,8 +112,8 @@
         continue;
       }
 
-      List<Comment> putInComments = getPutInComments(parentComments, currComments);
-      List<Comment> deletedComments = getDeletedComments(parentComments, currComments);
+      List<HumanComment> putInComments = getPutInComments(parentComments, currComments);
+      List<HumanComment> deletedComments = getDeletedComments(parentComments, currComments);
       newTipCommit =
           revWalk.parseCommit(
               rewriteCommit(
@@ -130,16 +129,16 @@
    * the previous commits.
    */
   @VisibleForTesting
-  public static Map<String, Comment> getPublishedComments(
+  public static Map<String, HumanComment> getPublishedComments(
       ChangeNoteJson changeNoteJson, ObjectReader reader, NoteMap noteMap)
       throws IOException, ConfigInvalidException {
-    return RevisionNoteMap.parse(changeNoteJson, reader, noteMap, Status.PUBLISHED).revisionNotes
-        .values().stream()
+    return RevisionNoteMap.parse(changeNoteJson, reader, noteMap, HumanComment.Status.PUBLISHED)
+        .revisionNotes.values().stream()
         .flatMap(n -> n.getEntities().stream())
         .collect(toMap(c -> c.key.uuid, Function.identity()));
   }
 
-  public static Map<String, Comment> getPublishedComments(
+  public static Map<String, HumanComment> getPublishedComments(
       ChangeNoteUtil noteUtil, ObjectReader reader, NoteMap noteMap)
       throws IOException, ConfigInvalidException {
     return getPublishedComments(noteUtil.getChangeNoteJson(), reader, noteMap);
@@ -152,11 +151,12 @@
    * @param curMap the comment map of the current commit.
    * @return The comments put in by the current commit.
    */
-  private List<Comment> getPutInComments(Map<String, Comment> parMap, Map<String, Comment> curMap) {
-    List<Comment> comments = new ArrayList<>();
+  private List<HumanComment> getPutInComments(
+      Map<String, HumanComment> parMap, Map<String, HumanComment> curMap) {
+    List<HumanComment> comments = new ArrayList<>();
     for (String key : curMap.keySet()) {
       if (!parMap.containsKey(key)) {
-        Comment comment = curMap.get(key);
+        HumanComment comment = curMap.get(key);
         if (key.equals(uuid)) {
           comment.message = newMessage;
         }
@@ -173,8 +173,8 @@
    * @param curMap the comment map of the current commit.
    * @return The comments deleted by the current commit.
    */
-  private List<Comment> getDeletedComments(
-      Map<String, Comment> parMap, Map<String, Comment> curMap) {
+  private List<HumanComment> getDeletedComments(
+      Map<String, HumanComment> parMap, Map<String, HumanComment> curMap) {
     return parMap.entrySet().stream()
         .filter(c -> !curMap.containsKey(c.getKey()))
         .map(Map.Entry::getValue)
@@ -199,22 +199,22 @@
       RevCommit parentCommit,
       ObjectInserter inserter,
       ObjectReader reader,
-      List<Comment> putInComments,
-      List<Comment> deletedComments)
+      List<HumanComment> putInComments,
+      List<HumanComment> deletedComments)
       throws IOException, ConfigInvalidException {
     RevisionNoteMap<ChangeRevisionNote> revNotesMap =
         RevisionNoteMap.parse(
             noteUtil.getChangeNoteJson(),
             reader,
             NoteMap.read(reader, parentCommit),
-            Status.PUBLISHED);
+            HumanComment.Status.PUBLISHED);
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(revNotesMap);
 
-    for (Comment c : putInComments) {
+    for (HumanComment c : putInComments) {
       cache.get(c.getCommitId()).putComment(c);
     }
 
-    for (Comment c : deletedComments) {
+    for (HumanComment c : deletedComments) {
       cache.get(c.getCommitId()).deleteComment(c.key);
     }
 
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 3966396..9b403e8 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Project;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -50,7 +50,7 @@
   private final Account.Id author;
   private final Ref ref;
 
-  private ImmutableListMultimap<ObjectId, Comment> comments;
+  private ImmutableListMultimap<ObjectId, HumanComment> comments;
   private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   @AssistedInject
@@ -80,12 +80,12 @@
     return author;
   }
 
-  public ImmutableListMultimap<ObjectId, Comment> getComments() {
+  public ImmutableListMultimap<ObjectId, HumanComment> getComments() {
     return comments;
   }
 
-  public boolean containsComment(Comment c) {
-    for (Comment existing : comments.values()) {
+  public boolean containsComment(HumanComment c) {
+    for (HumanComment existing : comments.values()) {
       if (c.key.equals(existing.key)) {
         return true;
       }
@@ -120,10 +120,13 @@
     ObjectReader reader = handle.walk().getObjectReader();
     revisionNoteMap =
         RevisionNoteMap.parse(
-            args.changeNoteJson, reader, NoteMap.read(reader, tipCommit), Comment.Status.DRAFT);
-    ListMultimap<ObjectId, Comment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+            args.changeNoteJson,
+            reader,
+            NoteMap.read(reader, tipCommit),
+            HumanComment.Status.DRAFT);
+    ListMultimap<ObjectId, HumanComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
     for (ChangeRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
-      for (Comment c : rn.getEntities()) {
+      for (HumanComment c : rn.getEntities()) {
         cs.put(c.getCommitId(), c);
       }
     }
diff --git a/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java b/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
new file mode 100644
index 0000000..e570412
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.entities.HumanComment;
+import java.util.List;
+
+/**
+ * Holds the raw data of a RevisionNote.
+ *
+ * <p>It is intended for deserialization from JSON only. It is used for human comments only.
+ */
+class HumanCommentsRevisionNoteData {
+  String pushCert;
+  List<HumanComment> comments;
+}
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 2d1a04a..ca97a1a 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.logging.TraceContext.newTimer;
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
@@ -31,6 +32,8 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -307,8 +310,15 @@
       // we may have stale draft comments. Doing it in this order allows stale
       // comments to be filtered out by ChangeNotes, reflecting the fact that
       // comments can only go from DRAFT to PUBLISHED, not vice versa.
-      BatchRefUpdate result = execute(changeRepo, dryrun, pushCert);
-      execute(allUsersRepo, dryrun, null);
+      BatchRefUpdate result;
+      try (TraceContext.TraceTimer ignored =
+          newTimer("NoteDbUpdateManager#updateRepo", Metadata.empty())) {
+        result = execute(changeRepo, dryrun, pushCert);
+      }
+      try (TraceContext.TraceTimer ignored =
+          newTimer("NoteDbUpdateManager#updateAllUsersSync", Metadata.empty())) {
+        execute(allUsersRepo, dryrun, null);
+      }
       if (!dryrun) {
         // Only execute the asynchronous operation if we are not in dry-run mode: The dry run would
         // have to run synchronous to be of any value at all. For the removal of draft comments from
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteData.java b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
index c0e09ed..da15b34 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteData.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
@@ -20,7 +20,8 @@
 /**
  * Holds the raw data of a RevisionNote.
  *
- * <p>It is intended for (de)serialization to JSON only.
+ * <p>It is intended for serialization to JSON only. It is used for human comments and robot
+ * comments.
  */
 class RevisionNoteData {
   String pushCert;
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index 98c9873..5a0b67b 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -41,7 +42,7 @@
   }
 
   static RevisionNoteMap<ChangeRevisionNote> parse(
-      ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, Comment.Status status)
+      ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, HumanComment.Status status)
       throws ConfigInvalidException, IOException {
     ImmutableMap.Builder<ObjectId, ChangeRevisionNote> result = ImmutableMap.builder();
     for (Note note : noteMap) {
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
index fc4c9fd..010206c 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
@@ -26,7 +26,11 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 
-/** Like {@link RevisionNote} but for robot comments. */
+/**
+ * Holds the raw data of a RevisionNote.
+ *
+ * <p>It is intended for deserialization from JSON only. It is used for robot comments only.
+ */
 public class RobotCommentsRevisionNote extends RevisionNote<RobotComment> {
   private final ChangeNoteJson noteUtil;
 
diff --git a/java/com/google/gerrit/server/patch/DiffExecutorModule.java b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
index eb6a280..b9e644f 100644
--- a/java/com/google/gerrit/server/patch/DiffExecutorModule.java
+++ b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
@@ -31,7 +31,7 @@
   @Provides
   @Singleton
   @DiffExecutor
-  public ExecutorService createDiffExecutor() {
+  public ExecutorService provideDiffExecutor() {
     return new LoggingContextAwareExecutorService(
         Executors.newCachedThreadPool(
             new ThreadFactoryBuilder().setNameFormat("Diff-%d").setDaemon(true).build()));
diff --git a/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index c9e45ba..28f61d3 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -45,23 +45,12 @@
 public class PatchList implements Serializable {
   private static final long serialVersionUID = PatchListKey.serialVersionUID;
 
-  private static final Comparator<PatchListEntry> PATCH_CMP =
-      Comparator.comparing(PatchListEntry::getNewName, PatchList::comparePaths);
-
   @VisibleForTesting
-  static int comparePaths(String a, String b) {
-    int m1 = Patch.isMagic(a) ? (a.equals(Patch.MERGE_LIST) ? 2 : 1) : 3;
-    int m2 = Patch.isMagic(b) ? (b.equals(Patch.MERGE_LIST) ? 2 : 1) : 3;
+  static final Comparator<String> FILE_PATH_CMP =
+      Comparator.comparing(Patch::isMagic).reversed().thenComparing(Comparator.naturalOrder());
 
-    if (m1 != m2) {
-      return m1 - m2;
-    } else if (m1 < 3) {
-      return 0;
-    }
-
-    // m1 == m2 == 3: normal names.
-    return a.compareTo(b);
-  }
+  private static final Comparator<PatchListEntry> PATCH_CMP =
+      Comparator.comparing(PatchListEntry::getNewName, FILE_PATH_CMP);
 
   @Nullable private transient ObjectId oldId;
   private transient ObjectId newId;
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/PatchListLoader.java
index c3d9a1d..be0895b 100644
--- a/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -26,10 +26,12 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -41,6 +43,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Optional;
@@ -59,6 +62,7 @@
 import org.eclipse.jgit.diff.HistogramDiff;
 import org.eclipse.jgit.diff.RawText;
 import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.FileMode;
@@ -383,8 +387,20 @@
       Set<ContextAwareEdit> editsDueToRebase)
       throws IOException {
     FileHeader fileHeader = toFileHeader(key.getNewId(), diffFormatter, diffEntry);
-    long oldSize = getFileSize(objectReader, diffEntry.getOldMode(), diffEntry.getOldPath(), treeA);
-    long newSize = getFileSize(objectReader, diffEntry.getNewMode(), diffEntry.getNewPath(), treeB);
+    long oldSize =
+        getFileSize(
+            objectReader,
+            diffEntry.getOldId(),
+            diffEntry.getOldMode(),
+            diffEntry.getOldPath(),
+            treeA);
+    long newSize =
+        getFileSize(
+            objectReader,
+            diffEntry.getNewId(),
+            diffEntry.getNewMode(),
+            diffEntry.getNewPath(),
+            treeB);
     Set<Edit> contentEditsDueToRebase = getContentEdits(editsDueToRebase);
     PatchListEntry patchListEntry =
         newEntry(treeA, fileHeader, contentEditsDueToRebase, newSize, newSize - oldSize);
@@ -417,14 +433,18 @@
     return ComparisonType.againstOtherPatchSet();
   }
 
-  private static long getFileSize(ObjectReader reader, FileMode mode, String path, RevTree t)
+  private static long getFileSize(
+      ObjectReader reader, AbbreviatedObjectId abbreviatedId, FileMode mode, String path, RevTree t)
       throws IOException {
     if (!isBlob(mode)) {
       return 0;
     }
-    try (TreeWalk tw = TreeWalk.forPath(reader, path, t)) {
-      return tw != null ? reader.open(tw.getObjectId(0), OBJ_BLOB).getSize() : 0;
+    ObjectId fileId =
+        toObjectId(reader, abbreviatedId).orElseGet(() -> lookupObjectId(reader, path, t));
+    if (ObjectId.zeroId().equals(fileId)) {
+      return 0;
     }
+    return reader.getObjectSize(fileId, OBJ_BLOB);
   }
 
   private static boolean isBlob(FileMode mode) {
@@ -432,6 +452,37 @@
     return t == FileMode.TYPE_FILE || t == FileMode.TYPE_SYMLINK;
   }
 
+  private static Optional<ObjectId> toObjectId(
+      ObjectReader reader, AbbreviatedObjectId abbreviatedId) throws IOException {
+    if (abbreviatedId == null) {
+      // In theory, DiffEntry#getOldId or DiffEntry#getNewId can be null for pure renames or pure
+      // mode changes (e.g. DiffEntry#modify doesn't set the IDs). However, the method we call
+      // for diffs (DiffFormatter#scan) seems to always produce DiffEntries with set IDs, even for
+      // pure renames.
+      return Optional.empty();
+    }
+    if (abbreviatedId.isComplete()) {
+      // With the current JGit version and the method we call for diffs (DiffFormatter#scan), this
+      // is the only code path taken right now.
+      return Optional.ofNullable(abbreviatedId.toObjectId());
+    }
+    Collection<ObjectId> objectIds = reader.resolve(abbreviatedId);
+    // It seems very unlikely that an ObjectId which was just abbreviated by the diff computation
+    // now can't be resolved to exactly one ObjectId. The API allows this possibility, though.
+    return objectIds.size() == 1
+        ? Optional.of(Iterables.getOnlyElement(objectIds))
+        : Optional.empty();
+  }
+
+  private static ObjectId lookupObjectId(ObjectReader reader, String path, RevTree tree) {
+    // This variant is very expensive.
+    try (TreeWalk treeWalk = TreeWalk.forPath(reader, path, tree)) {
+      return treeWalk != null ? treeWalk.getObjectId(0) : ObjectId.zeroId();
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
   private FileHeader toFileHeader(
       ObjectId commitB, DiffFormatter diffFormatter, DiffEntry diffEntry) throws IOException {
 
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 29a89d6..30930ec 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -381,13 +381,13 @@
     }
 
     private void loadPublished(String file) {
-      for (Comment c : commentsUtil.publishedByChangeFile(notes, file)) {
+      for (HumanComment c : commentsUtil.publishedByChangeFile(notes, file)) {
         comments.include(notes.getChangeId(), c);
       }
     }
 
     private void loadDrafts(Account.Id me, String file) {
-      for (Comment c : commentsUtil.draftByChangeFileAuthor(notes, file, me)) {
+      for (HumanComment c : commentsUtil.draftByChangeFileAuthor(notes, file, me)) {
         comments.include(notes.getChangeId(), c);
       }
     }
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 143547b..0b4828b 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -19,21 +19,16 @@
 
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.util.Collection;
 import java.util.EnumSet;
 import java.util.Map;
@@ -41,39 +36,16 @@
 
 /** Access control management for a user accessing a single change. */
 class ChangeControl {
-  @Singleton
-  static class Factory {
-    private final ChangeData.Factory changeDataFactory;
-    private final ChangeNotes.Factory notesFactory;
-
-    @Inject
-    Factory(ChangeData.Factory changeDataFactory, ChangeNotes.Factory notesFactory) {
-      this.changeDataFactory = changeDataFactory;
-      this.notesFactory = notesFactory;
-    }
-
-    ChangeControl create(RefControl refControl, Project.NameKey project, Change.Id changeId) {
-      return create(refControl, notesFactory.create(project, changeId));
-    }
-
-    ChangeControl create(RefControl refControl, ChangeNotes notes) {
-      return new ChangeControl(changeDataFactory, refControl, notes);
-    }
-  }
-
-  private final ChangeData.Factory changeDataFactory;
   private final RefControl refControl;
-  private final ChangeNotes notes;
+  private final ChangeData changeData;
 
-  private ChangeControl(
-      ChangeData.Factory changeDataFactory, RefControl refControl, ChangeNotes notes) {
-    this.changeDataFactory = changeDataFactory;
+  ChangeControl(RefControl refControl, ChangeData changeData) {
     this.refControl = refControl;
-    this.notes = notes;
+    this.changeData = changeData;
   }
 
-  ForChange asForChange(@Nullable ChangeData cd) {
-    return new ForChangeImpl(cd);
+  ForChange asForChange() {
+    return new ForChangeImpl();
   }
 
   private CurrentUser getUser() {
@@ -85,7 +57,7 @@
   }
 
   private Change getChange() {
-    return notes.getChange();
+    return changeData.change();
   }
 
   /** Can this user see this change? */
@@ -224,19 +196,13 @@
   }
 
   private class ForChangeImpl extends ForChange {
-    private ChangeData cd;
     private Map<String, PermissionRange> labels;
     private String resourcePath;
 
-    ForChangeImpl(@Nullable ChangeData cd) {
-      this.cd = cd;
-    }
+    private ForChangeImpl() {}
 
     private ChangeData changeData() {
-      if (cd == null) {
-        cd = changeDataFactory.create(notes);
-      }
-      return cd;
+      return changeData;
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index cb0d48a..cf6a184 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -21,10 +21,10 @@
 
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
index f3a3c78..3f84dff 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
@@ -31,7 +31,6 @@
       // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
       factory(ProjectControl.Factory.class);
       factory(DefaultRefFilter.Factory.class);
-      bind(ChangeControl.Factory.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index 8479f02..dcaf485 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -20,7 +20,7 @@
 import com.google.common.collect.ImmutableBiMap;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.api.access.PluginProjectPermission;
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index e92ada1..37de0d1 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -448,12 +448,11 @@
     try {
       Map<Change.Id, BranchNameKey> visibleChanges = new HashMap<>();
       for (ChangeData cd : changeCache.getChangeData(project)) {
-        ChangeNotes notes = changeNotesFactory.createFromIndexedChange(cd.change());
         if (!projectState.statePermitsRead()) {
           continue;
         }
         try {
-          permissionBackendForProject.indexedChange(cd, notes).check(ChangePermission.READ);
+          permissionBackendForProject.change(cd).check(ChangePermission.READ);
           visibleChanges.put(cd.getId(), cd.change().getDest());
         } catch (AuthException e) {
           // Do nothing.
diff --git a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
index 2344781..749ca6b 100644
--- a/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -173,11 +173,6 @@
     }
 
     @Override
-    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return new FailedChange(message, cause);
-    }
-
-    @Override
     public void check(RefPermission perm) throws PermissionBackendException {
       throw new PermissionBackendException(message, cause);
     }
diff --git a/java/com/google/gerrit/server/permissions/LabelPermission.java b/java/com/google/gerrit/server/permissions/LabelPermission.java
index 7cce9c4..268570c 100644
--- a/java/com/google/gerrit/server/permissions/LabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -18,8 +18,8 @@
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.SELF;
 import static java.util.Objects.requireNonNull;
 
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.server.util.LabelVote;
 
 /** Permission representing a label. */
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 653c3b5f..eceb970 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -21,9 +21,9 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
@@ -173,15 +173,6 @@
       return ref(notes.getChange().getDest()).change(notes);
     }
 
-    /**
-     * Returns an instance scoped for the change loaded from index, and its destination ref and
-     * project. This method should only be used when database access is harmful and potentially
-     * stale data from the index is acceptable.
-     */
-    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return ref(notes.getChange().getDest()).indexedChange(cd, notes);
-    }
-
     /** Verify scoped user can {@code perm}, throwing if denied. */
     public abstract void check(GlobalOrPluginPermission perm)
         throws AuthException, PermissionBackendException;
@@ -289,15 +280,6 @@
       return ref(notes.getChange().getDest().branch()).change(notes);
     }
 
-    /**
-     * Returns an instance scoped for the change loaded from index, and its destination ref and
-     * project. This method should only be used when database access is harmful and potentially
-     * stale data from the index is acceptable.
-     */
-    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return ref(notes.getChange().getDest().branch()).indexedChange(cd, notes);
-    }
-
     /** Verify scoped user can {@code perm}, throwing if denied. */
     public abstract void check(CoreOrPluginProjectPermission perm)
         throws AuthException, PermissionBackendException;
@@ -386,12 +368,6 @@
     /** Returns an instance scoped to change. */
     public abstract ForChange change(ChangeNotes notes);
 
-    /**
-     * @return instance scoped to change loaded from index. This method should only be used when
-     *     database access is harmful and potentially stale data from the index is acceptable.
-     */
-    public abstract ForChange indexedChange(ChangeData cd, ChangeNotes notes);
-
     /** Verify scoped user can {@code perm}, throwing if denied. */
     public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
 
diff --git a/java/com/google/gerrit/server/permissions/PermissionCollection.java b/java/com/google/gerrit/server/permissions/PermissionCollection.java
index 1f0370b..ddba52b 100644
--- a/java/com/google/gerrit/server/permissions/PermissionCollection.java
+++ b/java/com/google/gerrit/server/permissions/PermissionCollection.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.gerrit.common.data.PermissionRule.Action.BLOCK;
+import static com.google.gerrit.entities.PermissionRule.Action.BLOCK;
 import static com.google.gerrit.server.project.RefPattern.containsParameters;
 import static com.google.gerrit.server.project.RefPattern.isRE;
 import static java.util.stream.Collectors.mapping;
@@ -23,11 +23,11 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 145e0b6..724017db 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -15,18 +15,18 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.common.data.AccessSection.ALL;
-import static com.google.gerrit.common.data.AccessSection.REGEX_PREFIX;
+import static com.google.gerrit.entities.AccessSection.ALL;
+import static com.google.gerrit.entities.AccessSection.REGEX_PREFIX;
 import static com.google.gerrit.entities.RefNames.REFS_TAGS;
 import static com.google.gerrit.server.util.MagicBranch.NEW_CHANGE;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -70,9 +70,9 @@
   private final PermissionBackend permissionBackend;
   private final CurrentUser user;
   private final ProjectState state;
-  private final ChangeControl.Factory changeControlFactory;
   private final PermissionCollection.Factory permissionFilter;
   private final DefaultRefFilter.Factory refFilterFactory;
+  private final ChangeData.Factory changeDataFactory;
 
   private List<SectionMatcher> allSections;
   private Map<String, RefControl> refControls;
@@ -83,17 +83,17 @@
       @GitUploadPackGroups Set<AccountGroup.UUID> uploadGroups,
       @GitReceivePackGroups Set<AccountGroup.UUID> receiveGroups,
       PermissionCollection.Factory permissionFilter,
-      ChangeControl.Factory changeControlFactory,
       PermissionBackend permissionBackend,
       DefaultRefFilter.Factory refFilterFactory,
+      ChangeData.Factory changeDataFactory,
       @Assisted CurrentUser who,
       @Assisted ProjectState ps) {
-    this.changeControlFactory = changeControlFactory;
     this.uploadGroups = uploadGroups;
     this.receiveGroups = receiveGroups;
     this.permissionFilter = permissionFilter;
     this.permissionBackend = permissionBackend;
     this.refFilterFactory = refFilterFactory;
+    this.changeDataFactory = changeDataFactory;
     user = who;
     state = ps;
   }
@@ -102,13 +102,8 @@
     return new ForProjectImpl();
   }
 
-  ChangeControl controlFor(Change change) {
-    return changeControlFactory.create(
-        controlForRef(change.getDest()), change.getProject(), change.getId());
-  }
-
-  ChangeControl controlFor(ChangeNotes notes) {
-    return changeControlFactory.create(controlForRef(notes.getChange().getDest()), notes);
+  ChangeControl controlFor(ChangeData cd) {
+    return new ChangeControl(controlForRef(cd.change().getDest()), cd);
   }
 
   RefControl controlForRef(BranchNameKey ref) {
@@ -122,7 +117,7 @@
     RefControl ctl = refControls.get(refName);
     if (ctl == null) {
       PermissionCollection relevant = permissionFilter.filter(access(), refName, user);
-      ctl = new RefControl(this, refName, relevant);
+      ctl = new RefControl(changeDataFactory, this, refName, relevant);
       refControls.put(refName, ctl);
     }
     return ctl;
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 7c5d6bd..bc802cc 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -17,11 +17,11 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -43,6 +43,7 @@
 class RefControl {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final ChangeData.Factory changeDataFactory;
   private final ProjectControl projectControl;
   private final String refName;
 
@@ -58,7 +59,12 @@
   private Boolean canForgeCommitter;
   private Boolean isVisible;
 
-  RefControl(ProjectControl projectControl, String ref, PermissionCollection relevant) {
+  RefControl(
+      ChangeData.Factory changeDataFactory,
+      ProjectControl projectControl,
+      String ref,
+      PermissionCollection relevant) {
+    this.changeDataFactory = changeDataFactory;
     this.projectControl = projectControl;
     this.refName = ref;
     this.relevant = relevant;
@@ -444,7 +450,7 @@
     @Override
     public ForChange change(ChangeData cd) {
       try {
-        return getProjectControl().controlFor(cd.notes()).asForChange(cd);
+        return getProjectControl().controlFor(cd).asForChange();
       } catch (StorageException e) {
         return FailedPermissionBackend.change("unavailable", e);
       }
@@ -459,12 +465,9 @@
           "expected change in project %s, not %s",
           project,
           change.getProject());
-      return getProjectControl().controlFor(notes).asForChange(null);
-    }
-
-    @Override
-    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
-      return getProjectControl().controlFor(notes).asForChange(cd);
+      // Having ChangeNotes means it's OK to load values from NoteDb if needed.
+      // ChangeData.Factory will allow lazyLoading
+      return getProjectControl().controlFor(changeDataFactory.create(notes)).asForChange();
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index 814a8d2..6081e9a 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -18,7 +18,7 @@
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.util.MostSpecificComparator;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/project/AccessControlModule.java b/java/com/google/gerrit/server/project/AccessControlModule.java
index 89ab8ee..ecad4e1 100644
--- a/java/com/google/gerrit/server/project/AccessControlModule.java
+++ b/java/com/google/gerrit/server/project/AccessControlModule.java
@@ -17,8 +17,8 @@
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.config.AdministrateServerGroups;
 import com.google.gerrit.server.config.AdministrateServerGroupsProvider;
diff --git a/java/com/google/gerrit/server/project/AccountsSection.java b/java/com/google/gerrit/server/project/AccountsSection.java
deleted file mode 100644
index 30bd244..0000000
--- a/java/com/google/gerrit/server/project/AccountsSection.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.PermissionRule;
-import java.util.ArrayList;
-import java.util.List;
-
-public class AccountsSection {
-  protected List<PermissionRule> sameGroupVisibility;
-
-  public ImmutableList<PermissionRule> getSameGroupVisibility() {
-    if (sameGroupVisibility == null) {
-      sameGroupVisibility = ImmutableList.of();
-    }
-    return ImmutableList.copyOf(sameGroupVisibility);
-  }
-
-  public void setSameGroupVisibility(List<PermissionRule> sameGroupVisibility) {
-    this.sameGroupVisibility = new ArrayList<>(sameGroupVisibility);
-  }
-}
diff --git a/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java b/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
deleted file mode 100644
index 35de963..0000000
--- a/java/com/google/gerrit/server/project/CommentLinkInfoImpl.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
-
-/** Info about a single commentlink section in a config. */
-public class CommentLinkInfoImpl extends CommentLinkInfo {
-  public static class Enabled extends CommentLinkInfoImpl {
-    public Enabled(String name) {
-      super(name, true);
-    }
-
-    @Override
-    boolean isOverrideOnly() {
-      return true;
-    }
-  }
-
-  public static class Disabled extends CommentLinkInfoImpl {
-    public Disabled(String name) {
-      super(name, false);
-    }
-
-    @Override
-    boolean isOverrideOnly() {
-      return true;
-    }
-  }
-
-  public CommentLinkInfoImpl(String name, String match, String link, String html, Boolean enabled) {
-    checkArgument(name != null, "invalid commentlink.name");
-    checkArgument(!Strings.isNullOrEmpty(match), "invalid commentlink.%s.match", name);
-    link = Strings.emptyToNull(link);
-    html = Strings.emptyToNull(html);
-    checkArgument(
-        (link != null && html == null) || (link == null && html != null),
-        "commentlink.%s must have either link or html",
-        name);
-    this.name = name;
-    this.match = match;
-    this.link = link;
-    this.html = html;
-    this.enabled = enabled;
-  }
-
-  private CommentLinkInfoImpl(CommentLinkInfo src, boolean enabled) {
-    this.name = src.name;
-    this.match = src.match;
-    this.link = src.link;
-    this.html = src.html;
-    this.enabled = enabled;
-  }
-
-  private CommentLinkInfoImpl(String name, boolean enabled) {
-    this.name = name;
-    this.match = null;
-    this.link = null;
-    this.html = null;
-    this.enabled = enabled;
-  }
-
-  boolean isOverrideOnly() {
-    return false;
-  }
-
-  CommentLinkInfo inherit(CommentLinkInfo src) {
-    return new CommentLinkInfoImpl(src, enabled);
-  }
-}
diff --git a/java/com/google/gerrit/server/project/CommentLinkProvider.java b/java/com/google/gerrit/server/project/CommentLinkProvider.java
index 4987d00..1b9dc37 100644
--- a/java/com/google/gerrit/server/project/CommentLinkProvider.java
+++ b/java/com/google/gerrit/server/project/CommentLinkProvider.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.StoredCommentLinkInfo;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.server.config.ConfigUpdatedEvent;
 import com.google.gerrit.server.config.ConfigUpdatedEvent.ConfigUpdateEntry;
@@ -47,12 +48,12 @@
     List<CommentLinkInfo> cls = Lists.newArrayListWithCapacity(subsections.size());
     for (String name : subsections) {
       try {
-        CommentLinkInfoImpl cl = ProjectConfig.buildCommentLink(cfg, name, true);
-        if (cl.isOverrideOnly()) {
+        StoredCommentLinkInfo cl = ProjectConfig.buildCommentLink(cfg, name, true);
+        if (cl.getOverrideOnly()) {
           logger.atWarning().log("commentlink %s empty except for \"enabled\"", name);
           continue;
         }
-        cls.add(cl);
+        cls.add(cl.toInfo());
       } catch (IllegalArgumentException e) {
         logger.atWarning().log("invalid commentlink: %s", e.getMessage());
       }
diff --git a/java/com/google/gerrit/server/project/ConfiguredMimeTypes.java b/java/com/google/gerrit/server/project/ConfiguredMimeTypes.java
deleted file mode 100644
index a6661f7..0000000
--- a/java/com/google/gerrit/server/project/ConfiguredMimeTypes.java
+++ /dev/null
@@ -1,113 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.flogger.FluentLogger;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-import java.util.regex.Pattern;
-import java.util.regex.PatternSyntaxException;
-import org.eclipse.jgit.errors.InvalidPatternException;
-import org.eclipse.jgit.fnmatch.FileNameMatcher;
-import org.eclipse.jgit.lib.Config;
-
-public class ConfiguredMimeTypes {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private static final String MIMETYPE = "mimetype";
-  private static final String KEY_PATH = "path";
-
-  private final List<TypeMatcher> matchers;
-
-  ConfiguredMimeTypes(String projectName, Config rc) {
-    Set<String> types = rc.getSubsections(MIMETYPE);
-    if (types.isEmpty()) {
-      matchers = Collections.emptyList();
-    } else {
-      matchers = new ArrayList<>();
-      for (String typeName : types) {
-        for (String path : rc.getStringList(MIMETYPE, typeName, KEY_PATH)) {
-          try {
-            add(typeName, path);
-          } catch (PatternSyntaxException | InvalidPatternException e) {
-            logger.atWarning().log(
-                "Ignoring invalid %s.%s.%s = %s in project %s: %s",
-                MIMETYPE, typeName, KEY_PATH, path, projectName, e.getMessage());
-          }
-        }
-      }
-    }
-  }
-
-  private void add(String typeName, String path)
-      throws PatternSyntaxException, InvalidPatternException {
-    if (path.startsWith("^")) {
-      matchers.add(new ReType(typeName, path));
-    } else {
-      matchers.add(new FnType(typeName, path));
-    }
-  }
-
-  public String getMimeType(String path) {
-    for (TypeMatcher m : matchers) {
-      if (m.matches(path)) {
-        return m.type;
-      }
-    }
-    return null;
-  }
-
-  private abstract static class TypeMatcher {
-    final String type;
-
-    TypeMatcher(String type) {
-      this.type = type;
-    }
-
-    abstract boolean matches(String path);
-  }
-
-  private static class FnType extends TypeMatcher {
-    private final FileNameMatcher matcher;
-
-    FnType(String type, String pattern) throws InvalidPatternException {
-      super(type);
-      this.matcher = new FileNameMatcher(pattern, null);
-    }
-
-    @Override
-    boolean matches(String input) {
-      FileNameMatcher m = new FileNameMatcher(matcher);
-      m.append(input);
-      return m.isMatch();
-    }
-  }
-
-  private static class ReType extends TypeMatcher {
-    private final Pattern re;
-
-    ReType(String type, String pattern) throws PatternSyntaxException {
-      super(type);
-      this.re = Pattern.compile(pattern);
-    }
-
-    @Override
-    boolean matches(String input) {
-      return re.matcher(input).matches();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
index c2eb79d..f054e84 100644
--- a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
+++ b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
@@ -17,12 +17,12 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static java.util.Objects.requireNonNull;
 
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -90,7 +90,7 @@
 
     IdentifiedUser iUser = user.asIdentifiedUser();
     Collection<ContributorAgreement> contributorAgreements =
-        projectCache.getAllProjects().getConfig().getContributorAgreements();
+        projectCache.getAllProjects().getConfig().getContributorAgreements().values();
     List<UUID> okGroupIds = new ArrayList<>();
     for (ContributorAgreement ca : contributorAgreements) {
       List<AccountGroup.UUID> groupIds;
diff --git a/java/com/google/gerrit/server/project/GroupList.java b/java/com/google/gerrit/server/project/GroupList.java
index ba7dc95..98dc44a 100644
--- a/java/com/google/gerrit/server/project/GroupList.java
+++ b/java/com/google/gerrit/server/project/GroupList.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.TabFile;
@@ -56,7 +57,7 @@
       }
       AccountGroup.UUID uuid = AccountGroup.uuid(row.left);
       String name = row.right;
-      GroupReference ref = new GroupReference(uuid, name);
+      GroupReference ref = GroupReference.create(uuid, name);
 
       groupsByUUID.put(uuid, ref);
     }
@@ -64,10 +65,30 @@
     return new GroupList(groupsByUUID);
   }
 
+  @Nullable
   public GroupReference byUUID(AccountGroup.UUID uuid) {
     return byUUID.get(uuid);
   }
 
+  public Map<AccountGroup.UUID, GroupReference> byUUID() {
+    return byUUID;
+  }
+
+  @Nullable
+  public GroupReference byName(String name) {
+    return byUUID.entrySet().stream()
+        .map(Map.Entry::getValue)
+        .filter(groupReference -> groupReference.getName().equals(name))
+        .findAny()
+        .orElse(null);
+  }
+
+  /**
+   * Returns the {@link GroupReference} instance that {@link GroupList} holds on to that has the
+   * same {@link com.google.gerrit.entities.AccountGroup.UUID} as the argument. Will store the
+   * argument internally, if no group with this {@link com.google.gerrit.entities.AccountGroup.UUID}
+   * was stored previously.
+   */
   public GroupReference resolve(GroupReference group) {
     if (group != null) {
       if (group.getUUID() == null || group.getUUID().get() == null) {
@@ -86,6 +107,10 @@
     return group;
   }
 
+  public void renameGroup(AccountGroup.UUID uuid, String name) {
+    byUUID.replace(uuid, GroupReference.create(uuid, name));
+  }
+
   public Collection<GroupReference> references() {
     return byUUID.values();
   }
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 0452d0b..7aa4029 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -16,8 +16,8 @@
 
 import static java.util.stream.Collectors.toMap;
 
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 
@@ -31,7 +31,7 @@
         labelType.getValues().stream().collect(toMap(LabelValue::formatValue, LabelValue::getText));
     label.defaultValue = labelType.getDefaultValue();
     label.branches = labelType.getRefPatterns() != null ? labelType.getRefPatterns() : null;
-    label.canOverride = toBoolean(labelType.canOverride());
+    label.canOverride = toBoolean(labelType.isCanOverride());
     label.copyAnyScore = toBoolean(labelType.isCopyAnyScore());
     label.copyMinScore = toBoolean(labelType.isCopyMinScore());
     label.copyMaxScore = toBoolean(labelType.isCopyMaxScore());
@@ -41,8 +41,8 @@
     label.copyAllScoresOnMergeFirstParentUpdate =
         toBoolean(labelType.isCopyAllScoresOnMergeFirstParentUpdate());
     label.copyValues = labelType.getCopyValues().isEmpty() ? null : labelType.getCopyValues();
-    label.allowPostSubmit = toBoolean(labelType.allowPostSubmit());
-    label.ignoreSelfApproval = toBoolean(labelType.ignoreSelfApproval());
+    label.allowPostSubmit = toBoolean(labelType.isAllowPostSubmit());
+    label.ignoreSelfApproval = toBoolean(labelType.isIgnoreSelfApproval());
     return label;
   }
 
diff --git a/java/com/google/gerrit/server/project/LabelResource.java b/java/com/google/gerrit/server/project/LabelResource.java
index a7a2f07..2df9ff1 100644
--- a/java/com/google/gerrit/server/project/LabelResource.java
+++ b/java/com/google/gerrit/server/project/LabelResource.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.inject.TypeLiteral;
diff --git a/java/com/google/gerrit/server/project/ProjectCacheClock.java b/java/com/google/gerrit/server/project/ProjectCacheClock.java
deleted file mode 100644
index eb451fd..0000000
--- a/java/com/google/gerrit/server/project/ProjectCacheClock.java
+++ /dev/null
@@ -1,100 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import com.google.common.util.concurrent.ThreadFactoryBuilder;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicLong;
-import org.eclipse.jgit.lib.Config;
-
-/** Ticks periodically to force refresh events for {@link ProjectCacheImpl}. */
-@Singleton
-public class ProjectCacheClock implements LifecycleListener {
-  private final Config serverConfig;
-
-  private final AtomicLong generation = new AtomicLong();
-
-  private ScheduledExecutorService executor;
-
-  @Inject
-  public ProjectCacheClock(@GerritServerConfig Config serverConfig) {
-    this.serverConfig = serverConfig;
-  }
-
-  @Override
-  public void start() {
-    long checkFrequencyMillis = checkFrequency(serverConfig);
-
-    if (checkFrequencyMillis == Long.MAX_VALUE) {
-      // Start with generation 1 (to avoid magic 0 below).
-      // Do not begin background thread, disabling the clock.
-      generation.set(1);
-    } else if (10 < checkFrequencyMillis) {
-      // Start with generation 1 (to avoid magic 0 below).
-      generation.set(1);
-      executor =
-          new LoggingContextAwareScheduledExecutorService(
-              Executors.newScheduledThreadPool(
-                  1,
-                  new ThreadFactoryBuilder()
-                      .setNameFormat("ProjectCacheClock-%d")
-                      .setDaemon(true)
-                      .setPriority(Thread.MIN_PRIORITY)
-                      .build()));
-      @SuppressWarnings("unused") // Runnable already handles errors
-      Future<?> possiblyIgnoredError =
-          executor.scheduleAtFixedRate(
-              generation::incrementAndGet,
-              checkFrequencyMillis,
-              checkFrequencyMillis,
-              TimeUnit.MILLISECONDS);
-    } else {
-      // Magic generation 0 triggers ProjectState to always
-      // check on each needsRefresh() request we make to it.
-      generation.set(0);
-    }
-  }
-
-  @Override
-  public void stop() {
-    if (executor != null) {
-      executor.shutdown();
-    }
-  }
-
-  long read() {
-    return generation.get();
-  }
-
-  private static long checkFrequency(Config serverConfig) {
-    String freq = serverConfig.getString("cache", "projects", "checkFrequency");
-    if (freq != null && ("disabled".equalsIgnoreCase(freq) || "off".equalsIgnoreCase(freq))) {
-      return Long.MAX_VALUE;
-    }
-    return TimeUnit.MILLISECONDS.convert(
-        ConfigUtil.getTimeUnit(
-            serverConfig, "cache", "projects", "checkFrequency", 5, TimeUnit.MINUTES),
-        TimeUnit.MINUTES);
-  }
-}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 9d09eeb..663c9aa 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -24,16 +24,23 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.metrics.Counter2;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
@@ -48,6 +55,7 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.time.Duration;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
@@ -55,6 +63,7 @@
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
 /** Cache of project information, including access rights. */
@@ -70,7 +79,10 @@
     return new CacheModule() {
       @Override
       protected void configure() {
-        cache(CACHE_NAME, String.class, ProjectState.class).loader(Loader.class);
+        cache(CACHE_NAME, Project.NameKey.class, ProjectState.class)
+            .loader(Loader.class)
+            .refreshAfterWrite(Duration.ofMinutes(15))
+            .expireAfterWrite(Duration.ofHours(1));
 
         cache(CACHE_LIST, ListKey.class, new TypeLiteral<ImmutableSortedSet<Project.NameKey>>() {})
             .maximumWeight(1)
@@ -84,7 +96,6 @@
               @Override
               protected void configure() {
                 listener().to(ProjectCacheWarmer.class);
-                listener().to(ProjectCacheClock.class);
               }
             });
       }
@@ -93,10 +104,9 @@
 
   private final AllProjectsName allProjectsName;
   private final AllUsersName allUsersName;
-  private final LoadingCache<String, ProjectState> byName;
+  private final LoadingCache<Project.NameKey, ProjectState> byName;
   private final LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list;
   private final Lock listLock;
-  private final ProjectCacheClock clock;
   private final Provider<ProjectIndexer> indexer;
   private final Timer0 guessRelevantGroupsLatency;
 
@@ -104,9 +114,8 @@
   ProjectCacheImpl(
       final AllProjectsName allProjectsName,
       final AllUsersName allUsersName,
-      @Named(CACHE_NAME) LoadingCache<String, ProjectState> byName,
+      @Named(CACHE_NAME) LoadingCache<Project.NameKey, ProjectState> byName,
       @Named(CACHE_LIST) LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list,
-      ProjectCacheClock clock,
       Provider<ProjectIndexer> indexer,
       MetricMaker metricMaker) {
     this.allProjectsName = allProjectsName;
@@ -114,7 +123,6 @@
     this.byName = byName;
     this.list = list;
     this.listLock = new ReentrantLock(true /* fair */);
-    this.clock = clock;
     this.indexer = indexer;
 
     this.guessRelevantGroupsLatency =
@@ -142,13 +150,8 @@
     }
 
     try {
-      ProjectState state = byName.get(projectName.get());
-      if (state != null && state.needsRefresh(clock.read())) {
-        byName.invalidate(projectName.get());
-        state = byName.get(projectName.get());
-      }
-      return Optional.of(state);
-    } catch (Exception e) {
+      return Optional.of(byName.get(projectName));
+    } catch (ExecutionException e) {
       if ((e.getCause() instanceof RepositoryNotFoundException)) {
         logger.atFine().log("Cannot find project %s", projectName.get());
         return Optional.empty();
@@ -167,7 +170,7 @@
   public void evict(Project.NameKey p) {
     if (p != null) {
       logger.atFine().log("Evict project '%s'", p.get());
-      byName.invalidate(p.get());
+      byName.invalidate(p);
     }
     indexer.get().index(p);
   }
@@ -222,7 +225,7 @@
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
     try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
       return all().stream()
-          .map(n -> byName.getIfPresent(n.get()))
+          .map(n -> byName.getIfPresent(n))
           .filter(Objects::nonNull)
           .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
           // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
@@ -245,41 +248,67 @@
     }
   }
 
-  static class Loader extends CacheLoader<String, ProjectState> {
+  @Singleton
+  static class Loader extends CacheLoader<Project.NameKey, ProjectState> {
     private final ProjectState.Factory projectStateFactory;
     private final GitRepositoryManager mgr;
-    private final ProjectCacheClock clock;
     private final ProjectConfig.Factory projectConfigFactory;
+    private final ListeningExecutorService cacheRefreshExecutor;
+    private final Counter2<String, Boolean> refreshCounter;
 
     @Inject
     Loader(
         ProjectState.Factory psf,
         GitRepositoryManager g,
-        ProjectCacheClock clock,
-        ProjectConfig.Factory projectConfigFactory) {
+        ProjectConfig.Factory projectConfigFactory,
+        @CacheRefreshExecutor ListeningExecutorService cacheRefreshExecutor,
+        MetricMaker metricMaker) {
       projectStateFactory = psf;
       mgr = g;
-      this.clock = clock;
       this.projectConfigFactory = projectConfigFactory;
+      this.cacheRefreshExecutor = cacheRefreshExecutor;
+      refreshCounter =
+          metricMaker.newCounter(
+              "caches/refresh_count",
+              new Description("count").setRate(),
+              Field.ofString("cache", Metadata.Builder::className).build(),
+              Field.ofBoolean("outdated", Metadata.Builder::outdated).build());
     }
 
     @Override
-    public ProjectState load(String projectName) throws Exception {
+    public ProjectState load(Project.NameKey key) throws Exception {
       try (TraceTimer timer =
           TraceContext.newTimer(
-              "Loading project", Metadata.builder().projectName(projectName).build())) {
-        long now = clock.read();
-        Project.NameKey key = Project.nameKey(projectName);
+              "Loading project", Metadata.builder().projectName(key.get()).build())) {
         try (Repository git = mgr.openRepository(key)) {
           ProjectConfig cfg = projectConfigFactory.create(key);
           cfg.load(key, git);
-
-          ProjectState state = projectStateFactory.create(cfg);
-          state.initLastCheck(now);
-          return state;
+          return projectStateFactory.create(cfg.getCacheable());
         }
       }
     }
+
+    @Override
+    public ListenableFuture<ProjectState> reload(Project.NameKey key, ProjectState oldState)
+        throws Exception {
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Reload project", Metadata.builder().projectName(key.get()).build())) {
+        try (Repository git = mgr.openRepository(key)) {
+          Ref configRef = git.exactRef(RefNames.REFS_CONFIG);
+          if (configRef != null
+              && configRef.getObjectId().equals(oldState.getConfig().getRevision().get())) {
+            refreshCounter.increment(CACHE_NAME, false);
+            return Futures.immediateFuture(oldState);
+          }
+        }
+
+        // Repository is not thread safe, so we have to open it on the thread that does the loading.
+        // Just invoke the loader on the other thread.
+        refreshCounter.increment(CACHE_NAME, true);
+        return cacheRefreshExecutor.submit(() -> load(key));
+      }
+    }
   }
 
   static class ListKey {
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 4ab583d..587dd15 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.server.project;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.common.data.Permission.isPermission;
+import static com.google.gerrit.entities.Permission.isPermission;
 import static com.google.gerrit.entities.Project.DEFAULT_SUBMIT_TYPE;
 import static com.google.gerrit.server.permissions.PluginPermissionsUtil.isValidPluginPermission;
 import static java.util.Objects.requireNonNull;
@@ -27,40 +28,43 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
 import com.google.common.primitives.Shorts;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountsSection;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BooleanProjectConfig;
-import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.BranchOrderSection;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.ConfiguredMimeTypes;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.NotifyConfig;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.entities.SubscribeSection;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.BranchOrderSection;
-import com.google.gerrit.server.git.NotifyConfig;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
@@ -81,6 +85,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.function.Consumer;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -91,7 +96,6 @@
 import org.eclipse.jgit.lib.StoredConfig;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.util.FS;
 
 public class ProjectConfig extends VersionedMetaData implements ValidationError.Sink {
@@ -241,7 +245,7 @@
   private Map<String, LabelType> labelSections;
   private ConfiguredMimeTypes mimeTypes;
   private Map<Project.NameKey, SubscribeSection> subscribeSections;
-  private Map<String, CommentLinkInfoImpl> commentLinkSections;
+  private Map<String, StoredCommentLinkInfo> commentLinkSections;
   private List<ValidationError> validationErrors;
   private ObjectId rulesId;
   private long maxObjectSizeLimit;
@@ -250,9 +254,34 @@
   private Set<String> sectionsWithUnknownPermissions;
   private boolean hasLegacyPermissions;
   private Map<String, List<String>> extensionPanelSections;
-  private Map<String, GroupReference> groupsByName;
 
-  public static CommentLinkInfoImpl buildCommentLink(Config cfg, String name, boolean allowRaw)
+  /** Returns an immutable, thread-safe representation of this object that can be cached. */
+  public CachedProjectConfig getCacheable() {
+    CachedProjectConfig.Builder builder =
+        CachedProjectConfig.builder()
+            .setProject(project)
+            .setAccountsSection(accountsSection)
+            .setBranchOrderSection(Optional.ofNullable(branchOrderSection))
+            .setMimeTypes(mimeTypes)
+            .setRulesId(Optional.ofNullable(rulesId))
+            .setRevision(Optional.ofNullable(getRevision()))
+            .setMaxObjectSizeLimit(maxObjectSizeLimit)
+            .setCheckReceivedObjects(checkReceivedObjects)
+            .setExtensionPanelSections(extensionPanelSections);
+    groupList.byUUID().values().forEach(g -> builder.addGroup(g));
+    accessSections.values().forEach(a -> builder.addAccessSection(a));
+    contributorAgreements.values().forEach(c -> builder.addContributorAgreement(c));
+    notifySections.values().forEach(n -> builder.addNotifySection(n));
+    subscribeSections.values().forEach(s -> builder.addSubscribeSection(s));
+    commentLinkSections.values().forEach(c -> builder.addCommentLinkSection(c));
+    labelSections.values().forEach(l -> builder.addLabelSection(l));
+    pluginConfigs
+        .entrySet()
+        .forEach(c -> builder.addPluginConfig(c.getKey(), c.getValue().toText()));
+    return builder.build();
+  }
+
+  public static StoredCommentLinkInfo buildCommentLink(Config cfg, String name, boolean allowRaw)
       throws IllegalArgumentException {
     String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
     if (match != null) {
@@ -282,15 +311,21 @@
         && !hasHtml
         && enabled != null) {
       if (enabled) {
-        return new CommentLinkInfoImpl.Enabled(name);
+        return StoredCommentLinkInfo.enabled(name);
       }
-      return new CommentLinkInfoImpl.Disabled(name);
+      return StoredCommentLinkInfo.disabled(name);
     }
-    return new CommentLinkInfoImpl(name, match, link, html, enabled);
+    return StoredCommentLinkInfo.builder(name)
+        .setMatch(match)
+        .setLink(link)
+        .setHtml(html)
+        .setEnabled(enabled)
+        .setOverrideOnly(false)
+        .build();
   }
 
-  public void addCommentLinkSection(CommentLinkInfoImpl commentLink) {
-    commentLinkSections.put(commentLink.name, commentLink);
+  public void addCommentLinkSection(StoredCommentLinkInfo commentLink) {
+    commentLinkSections.put(commentLink.getName(), commentLink);
   }
 
   public void removeCommentLinkSection(String name) {
@@ -325,29 +360,35 @@
     return project;
   }
 
+  public void setProject(Project.Builder project) {
+    this.project = project.build();
+  }
+
+  public void updateProject(Consumer<Project.Builder> update) {
+    Project.Builder builder = project.toBuilder();
+    update.accept(builder);
+    project = builder.build();
+  }
+
   public AccountsSection getAccountsSection() {
     return accountsSection;
   }
 
-  public Map<String, List<String>> getExtensionPanelSections() {
-    return extensionPanelSections;
+  public void setAccountsSection(AccountsSection accountsSection) {
+    this.accountsSection = accountsSection;
   }
 
   public AccessSection getAccessSection(String name) {
-    return getAccessSection(name, false);
+    return accessSections.get(name);
   }
 
-  public AccessSection getAccessSection(String name, boolean create) {
-    AccessSection as = accessSections.get(name);
-    if (as == null && create) {
-      as = new AccessSection(name);
-      accessSections.put(name, as);
-    }
-    return as;
-  }
-
-  public ImmutableSet<String> getAccessSectionNames() {
-    return ImmutableSet.copyOf(accessSections.keySet());
+  public void upsertAccessSection(String name, Consumer<AccessSection.Builder> update) {
+    AccessSection.Builder accessSectionBuilder =
+        accessSections.containsKey(name)
+            ? accessSections.get(name).toBuilder()
+            : AccessSection.builder(name);
+    update.accept(accessSectionBuilder);
+    accessSections.put(name, accessSectionBuilder.build());
   }
 
   public Collection<AccessSection> getAccessSections() {
@@ -358,30 +399,25 @@
     return branchOrderSection;
   }
 
+  public void setBranchOrderSection(BranchOrderSection branchOrderSection) {
+    this.branchOrderSection = branchOrderSection;
+  }
+
   public Map<Project.NameKey, SubscribeSection> getSubscribeSections() {
     return subscribeSections;
   }
 
-  public Collection<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
-    Collection<SubscribeSection> ret = new ArrayList<>();
-    for (SubscribeSection s : subscribeSections.values()) {
-      if (s.appliesTo(branch)) {
-        ret.add(s);
-      }
-    }
-    return ret;
-  }
-
   public void addSubscribeSection(SubscribeSection s) {
-    subscribeSections.put(s.getProject(), s);
+    subscribeSections.put(s.project(), s);
   }
 
   public void remove(AccessSection section) {
     if (section != null) {
       String name = section.getName();
       if (sectionsWithUnknownPermissions.contains(name)) {
-        AccessSection a = accessSections.get(name);
-        a.setPermissions(new ArrayList<>());
+        AccessSection.Builder a = accessSections.get(name).toBuilder();
+        a.modifyPermissions(List::clear);
+        accessSections.put(name, a.build());
       } else {
         accessSections.remove(name);
       }
@@ -392,8 +428,9 @@
     if (permission == null) {
       remove(section);
     } else if (section != null) {
-      AccessSection a = accessSections.get(section.getName());
-      a.remove(permission);
+      AccessSection a =
+          accessSections.get(section.getName()).toBuilder().remove(permission.toBuilder()).build();
+      accessSections.put(section.getName(), a);
       if (a.getPermissions().isEmpty()) {
         remove(a);
       }
@@ -412,37 +449,23 @@
       if (p == null) {
         return;
       }
-      p.remove(rule);
-      if (p.getRules().isEmpty()) {
-        a.remove(permission);
+      AccessSection.Builder accessSectionBuilder = a.toBuilder();
+      Permission.Builder permissionBuilder =
+          accessSectionBuilder.upsertPermission(permission.getName());
+      permissionBuilder.remove(rule);
+      if (permissionBuilder.build().getRules().isEmpty()) {
+        accessSectionBuilder.remove(permissionBuilder);
       }
+      a = accessSectionBuilder.build();
+      accessSections.put(section.getName(), a);
       if (a.getPermissions().isEmpty()) {
         remove(a);
       }
     }
   }
 
-  public void replace(AccessSection section) {
-    for (Permission permission : section.getPermissions()) {
-      for (PermissionRule rule : permission.getRules()) {
-        rule.setGroup(resolve(rule.getGroup()));
-      }
-    }
-
-    accessSections.put(section.getName(), section);
-  }
-
   public ContributorAgreement getContributorAgreement(String name) {
-    return getContributorAgreement(name, false);
-  }
-
-  public ContributorAgreement getContributorAgreement(String name, boolean create) {
-    ContributorAgreement ca = contributorAgreements.get(name);
-    if (ca == null && create) {
-      ca = new ContributorAgreement(name);
-      contributorAgreements.put(name, ca);
-    }
-    return ca;
+    return contributorAgreements.get(name);
   }
 
   public Collection<ContributorAgreement> getContributorAgreements() {
@@ -456,12 +479,15 @@
   }
 
   public void replace(ContributorAgreement section) {
-    section.setAutoVerify(resolve(section.getAutoVerify()));
+    ContributorAgreement.Builder ca = section.toBuilder();
+    ca.setAutoVerify(resolve(section.getAutoVerify()));
+    ImmutableList.Builder<PermissionRule> newRules = ImmutableList.builder();
     for (PermissionRule rule : section.getAccepted()) {
-      rule.setGroup(resolve(rule.getGroup()));
+      newRules.add(rule.toBuilder().setGroup(resolve(rule.getGroup())).build());
     }
+    ca.setAccepted(newRules.build());
 
-    contributorAgreements.put(section.getName(), section);
+    contributorAgreements.put(section.getName(), ca.build());
   }
 
   public Collection<NotifyConfig> getNotifyConfigs() {
@@ -476,7 +502,27 @@
     return labelSections;
   }
 
-  public Collection<CommentLinkInfoImpl> getCommentLinkSections() {
+  /** Adds or replaces the given {@link LabelType} in this config. */
+  public void upsertLabelType(LabelType labelType) {
+    labelSections.put(labelType.getName(), labelType);
+  }
+
+  /** Allows a mutation of an existing {@link LabelType}. */
+  public void updateLabelType(String name, Consumer<LabelType.Builder> update) {
+    LabelType labelType = labelSections.get(name);
+    checkState(labelType != null, "labelType must not be null");
+    LabelType.Builder builder = labelSections.get(name).toBuilder();
+    update.accept(builder);
+    upsertLabelType(builder.build());
+  }
+
+  /** Adds or replaces the given {@link ContributorAgreement} in this config. */
+  public void upsertContributorAgreement(ContributorAgreement ca) {
+    contributorAgreements.remove(ca.getName());
+    contributorAgreements.put(ca.getName(), ca);
+  }
+
+  public Collection<StoredCommentLinkInfo> getCommentLinkSections() {
     return commentLinkSections.values();
   }
 
@@ -485,13 +531,11 @@
   }
 
   public GroupReference resolve(GroupReference group) {
-    GroupReference groupRef = groupList.resolve(group);
-    if (groupRef != null
-        && groupRef.getUUID() != null
-        && !groupsByName.containsKey(groupRef.getName())) {
-      groupsByName.put(groupRef.getName(), groupRef);
-    }
-    return groupRef;
+    return groupList.resolve(group);
+  }
+
+  public void renameGroup(AccountGroup.UUID uuid, String newName) {
+    groupList.renameGroup(uuid, newName);
   }
 
   /** @return the group reference, if the group is used by at least one rule. */
@@ -504,12 +548,7 @@
    *     at least one rule or plugin value.
    */
   public GroupReference getGroup(String groupName) {
-    return groupsByName.get(groupName);
-  }
-
-  /** @return set of all groups used by this configuration. */
-  public Set<AccountGroup.UUID> getAllGroupUUIDs() {
-    return groupList.uuids();
+    return groupList.byName(groupName);
   }
 
   /**
@@ -541,7 +580,7 @@
       GroupDescription.Basic g = groupBackend.get(ref.getUUID());
       if (g != null && !g.getName().equals(ref.getName())) {
         dirty = true;
-        ref.setName(g.getName());
+        groupList.renameGroup(ref.getUUID(), g.getName());
       }
     }
     return dirty;
@@ -570,17 +609,11 @@
       baseConfig.load();
     }
     readGroupList();
-    groupsByName = mapGroupReferences();
 
     rulesId = getObjectId("rules.pl");
     Config rc = readConfig(PROJECT_CONFIG, baseConfig);
-    project = new Project(projectName);
-
-    Project p = project;
-    p.setDescription(rc.getString(PROJECT, null, KEY_DESCRIPTION));
-    if (p.getDescription() == null) {
-      p.setDescription("");
-    }
+    Project.Builder p = Project.builder(projectName);
+    p.setDescription(Strings.nullToEmpty(rc.getString(PROJECT, null, KEY_DESCRIPTION)));
     if (revision != null) {
       p.setConfigRefState(revision.toObjectId().name());
     }
@@ -588,9 +621,9 @@
     if (rc.getStringList(ACCESS, null, KEY_INHERIT_FROM).length > 1) {
       // The config must not contain more than one parent to inherit from
       // as there is no guarantee which of the parents would be used then.
-      error(new ValidationError(PROJECT_CONFIG, "Cannot inherit from multiple projects"));
+      error(ValidationError.create(PROJECT_CONFIG, "Cannot inherit from multiple projects"));
     }
-    p.setParentName(rc.getString(ACCESS, null, KEY_INHERIT_FROM));
+    p.setParent(rc.getString(ACCESS, null, KEY_INHERIT_FROM));
 
     for (BooleanProjectConfig config : BooleanProjectConfig.values()) {
       p.setBooleanConfig(
@@ -610,6 +643,7 @@
 
     p.setDefaultDashboard(rc.getString(DASHBOARD, null, KEY_DEFAULT));
     p.setLocalDefaultDashboard(rc.getString(DASHBOARD, null, KEY_LOCAL_DEFAULT));
+    this.project = p.build();
 
     loadAccountsSection(rc);
     loadContributorAgreements(rc);
@@ -619,16 +653,16 @@
     loadLabelSections(rc);
     loadCommentLinkSections(rc);
     loadSubscribeSections(rc);
-    mimeTypes = new ConfiguredMimeTypes(projectName.get(), rc);
+    mimeTypes = ConfiguredMimeTypes.create(projectName.get(), rc);
     loadPluginSections(rc);
     loadReceiveSection(rc);
     loadExtensionPanelSections(rc);
   }
 
   private void loadAccountsSection(Config rc) {
-    accountsSection = new AccountsSection();
-    accountsSection.setSameGroupVisibility(
-        loadPermissionRules(rc, ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY, groupsByName, false));
+    accountsSection =
+        AccountsSection.create(
+            loadPermissionRules(rc, ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY, false));
   }
 
   private void loadExtensionPanelSections(Config rc) {
@@ -638,7 +672,7 @@
       String lower = name.toLowerCase();
       if (lowerNames.containsKey(lower)) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 String.format(
                     "Extension Panels \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
@@ -653,23 +687,21 @@
   private void loadContributorAgreements(Config rc) {
     contributorAgreements = new HashMap<>();
     for (String name : rc.getSubsections(CONTRIBUTOR_AGREEMENT)) {
-      ContributorAgreement ca = getContributorAgreement(name, true);
+      ContributorAgreement.Builder ca = ContributorAgreement.builder(name);
       ca.setDescription(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_DESCRIPTION));
       ca.setAgreementUrl(rc.getString(CONTRIBUTOR_AGREEMENT, name, KEY_AGREEMENT_URL));
-      ca.setAccepted(
-          loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_ACCEPTED, groupsByName, false));
+      ca.setAccepted(loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_ACCEPTED, false));
       ca.setExcludeProjectsRegexes(
           loadPatterns(rc, CONTRIBUTOR_AGREEMENT, name, KEY_EXCLUDE_PROJECTS));
       ca.setMatchProjectsRegexes(loadPatterns(rc, CONTRIBUTOR_AGREEMENT, name, KEY_MATCH_PROJECTS));
 
       List<PermissionRule> rules =
-          loadPermissionRules(
-              rc, CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY, groupsByName, false);
+          loadPermissionRules(rc, CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY, false);
       if (rules.isEmpty()) {
         ca.setAutoVerify(null);
       } else if (rules.size() > 1) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 "Invalid rule in "
                     + CONTRIBUTOR_AGREEMENT
@@ -680,7 +712,7 @@
                     + ": at most one group may be set"));
       } else if (rules.get(0).getAction() != Action.ALLOW) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 "Invalid rule in "
                     + CONTRIBUTOR_AGREEMENT
@@ -692,6 +724,7 @@
       } else {
         ca.setAutoVerify(rules.get(0).getGroup());
       }
+      contributorAgreements.put(name, ca.build());
     }
   }
 
@@ -716,45 +749,44 @@
   private void loadNotifySections(Config rc) {
     notifySections = new HashMap<>();
     for (String sectionName : rc.getSubsections(NOTIFY)) {
-      NotifyConfig n = new NotifyConfig();
+      NotifyConfig.Builder n = NotifyConfig.builder();
       n.setName(sectionName);
       n.setFilter(rc.getString(NOTIFY, sectionName, KEY_FILTER));
 
       EnumSet<NotifyType> types = EnumSet.noneOf(NotifyType.class);
       types.addAll(ConfigUtil.getEnumList(rc, NOTIFY, sectionName, KEY_TYPE, NotifyType.ALL));
-      n.setTypes(types);
+      n.setNotify(types);
       n.setHeader(rc.getEnum(NOTIFY, sectionName, KEY_HEADER, NotifyConfig.Header.BCC));
 
       for (String dst : rc.getStringList(NOTIFY, sectionName, KEY_EMAIL)) {
         String groupName = GroupReference.extractGroupName(dst);
         if (groupName != null) {
-          GroupReference ref = groupsByName.get(groupName);
+          GroupReference ref = groupList.byName(groupName);
           if (ref == null) {
-            ref = new GroupReference(groupName);
-            groupsByName.put(ref.getName(), ref);
+            ref = groupList.resolve(GroupReference.create(groupName));
           }
           if (ref.getUUID() != null) {
-            n.addEmail(ref);
+            n.addGroup(ref);
           } else {
             error(
-                new ValidationError(
+                ValidationError.create(
                     PROJECT_CONFIG,
                     "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
           }
         } else if (dst.startsWith("user ")) {
-          error(new ValidationError(PROJECT_CONFIG, dst + " not supported"));
+          error(ValidationError.create(PROJECT_CONFIG, dst + " not supported"));
         } else {
           try {
-            n.addEmail(Address.parse(dst));
+            n.addAddress(Address.parse(dst));
           } catch (IllegalArgumentException err) {
             error(
-                new ValidationError(
+                ValidationError.create(
                     PROJECT_CONFIG,
                     "notify section \"" + sectionName + "\" has invalid email \"" + dst + "\""));
           }
         }
       }
-      notifySections.put(sectionName, n);
+      notifySections.put(sectionName, n.build());
     }
   }
 
@@ -763,45 +795,41 @@
     sectionsWithUnknownPermissions = new HashSet<>();
     for (String refName : rc.getSubsections(ACCESS)) {
       if (AccessSection.isValidRefSectionName(refName) && isValidRegex(refName)) {
-        AccessSection as = getAccessSection(refName, true);
+        upsertAccessSection(
+            refName,
+            as -> {
+              for (String varName : rc.getStringList(ACCESS, refName, KEY_GROUP_PERMISSIONS)) {
+                for (String n : Splitter.on(EXCLUSIVE_PERMISSIONS_SPLIT_PATTERN).split(varName)) {
+                  n = convertLegacyPermission(n);
+                  if (isCoreOrPluginPermission(n)) {
+                    as.upsertPermission(n).setExclusiveGroup(true);
+                  }
+                }
+              }
 
-        for (String varName : rc.getStringList(ACCESS, refName, KEY_GROUP_PERMISSIONS)) {
-          for (String n : Splitter.on(EXCLUSIVE_PERMISSIONS_SPLIT_PATTERN).split(varName)) {
-            n = convertLegacyPermission(n);
-            if (isCoreOrPluginPermission(n)) {
-              as.getPermission(n, true).setExclusiveGroup(true);
-            }
-          }
-        }
-
-        for (String varName : rc.getNames(ACCESS, refName)) {
-          String convertedName = convertLegacyPermission(varName);
-          if (isCoreOrPluginPermission(convertedName)) {
-            Permission perm = as.getPermission(convertedName, true);
-            loadPermissionRules(
-                rc,
-                ACCESS,
-                refName,
-                varName,
-                groupsByName,
-                perm,
-                Permission.hasRange(convertedName));
-          } else {
-            sectionsWithUnknownPermissions.add(as.getName());
-          }
-        }
+              for (String varName : rc.getNames(ACCESS, refName)) {
+                String convertedName = convertLegacyPermission(varName);
+                if (isCoreOrPluginPermission(convertedName)) {
+                  Permission.Builder perm = as.upsertPermission(convertedName);
+                  loadPermissionRules(
+                      rc, ACCESS, refName, varName, perm, Permission.hasRange(convertedName));
+                } else {
+                  sectionsWithUnknownPermissions.add(as.getName());
+                }
+              }
+            });
       }
     }
 
-    AccessSection capability = null;
+    AccessSection.Builder capability = null;
     for (String varName : rc.getNames(CAPABILITY)) {
       if (capability == null) {
-        capability = new AccessSection(AccessSection.GLOBAL_CAPABILITIES);
-        accessSections.put(AccessSection.GLOBAL_CAPABILITIES, capability);
+        capability = AccessSection.builder(AccessSection.GLOBAL_CAPABILITIES);
+        accessSections.put(AccessSection.GLOBAL_CAPABILITIES, capability.build());
       }
-      Permission perm = capability.getPermission(varName, true);
-      loadPermissionRules(
-          rc, CAPABILITY, null, varName, groupsByName, perm, GlobalCapability.hasRange(varName));
+      Permission.Builder perm = capability.upsertPermission(varName);
+      loadPermissionRules(rc, CAPABILITY, null, varName, perm, GlobalCapability.hasRange(varName));
+      accessSections.put(AccessSection.GLOBAL_CAPABILITIES, capability.build());
     }
   }
 
@@ -815,7 +843,7 @@
     try {
       RefPattern.validateRegExp(refPattern);
     } catch (InvalidNameException e) {
-      error(new ValidationError(PROJECT_CONFIG, "Invalid ref name: " + e.getMessage()));
+      error(ValidationError.create(PROJECT_CONFIG, "Invalid ref name: " + e.getMessage()));
       return false;
     }
     return true;
@@ -823,7 +851,14 @@
 
   private void loadBranchOrderSection(Config rc) {
     if (rc.getSections().contains(BRANCH_ORDER)) {
-      branchOrderSection = new BranchOrderSection(rc.getStringList(BRANCH_ORDER, null, BRANCH));
+      branchOrderSection =
+          BranchOrderSection.create(Arrays.asList(rc.getStringList(BRANCH_ORDER, null, BRANCH)));
+    }
+  }
+
+  private void saveBranchOrderSection(Config rc) {
+    if (branchOrderSection != null) {
+      rc.setStringList(BRANCH_ORDER, null, BRANCH, branchOrderSection.order());
     }
   }
 
@@ -836,7 +871,9 @@
         // to fail fast if any of the patterns are invalid.
         patterns.add(Pattern.compile(patternString).pattern());
       } catch (PatternSyntaxException e) {
-        error(new ValidationError(PROJECT_CONFIG, "Invalid regular expression: " + e.getMessage()));
+        error(
+            ValidationError.create(
+                PROJECT_CONFIG, "Invalid regular expression: " + e.getMessage()));
         continue;
       }
     }
@@ -844,15 +881,10 @@
   }
 
   private ImmutableList<PermissionRule> loadPermissionRules(
-      Config rc,
-      String section,
-      String subsection,
-      String varName,
-      Map<String, GroupReference> groupsByName,
-      boolean useRange) {
-    Permission perm = new Permission(varName);
-    loadPermissionRules(rc, section, subsection, varName, groupsByName, perm, useRange);
-    return ImmutableList.copyOf(perm.getRules());
+      Config rc, String section, String subsection, String varName, boolean useRange) {
+    Permission.Builder perm = Permission.builder(varName);
+    loadPermissionRules(rc, section, subsection, varName, perm, useRange);
+    return ImmutableList.copyOf(perm.build().getRules());
   }
 
   private void loadPermissionRules(
@@ -860,8 +892,7 @@
       String section,
       String subsection,
       String varName,
-      Map<String, GroupReference> groupsByName,
-      Permission perm,
+      Permission.Builder perm,
       boolean useRange) {
     for (String ruleString : rc.getStringList(section, subsection, varName)) {
       PermissionRule rule;
@@ -869,7 +900,7 @@
         rule = PermissionRule.fromString(ruleString, useRange);
       } catch (IllegalArgumentException notRule) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 "Invalid rule in "
                     + section
@@ -881,21 +912,19 @@
         continue;
       }
 
-      GroupReference ref = groupsByName.get(rule.getGroup().getName());
+      GroupReference ref = groupList.byName(rule.getGroup().getName());
       if (ref == null) {
         // The group wasn't mentioned in the groups table, so there is
         // no valid UUID for it. Pool the reference anyway so at least
         // all rules in the same file share the same GroupReference.
         //
-        ref = rule.getGroup();
-        groupsByName.put(ref.getName(), ref);
+        ref = groupList.resolve(rule.getGroup());
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG, "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
       }
 
-      rule.setGroup(ref);
-      perm.add(rule);
+      perm.add(rule.toBuilder().setGroup(ref));
     }
   }
 
@@ -907,7 +936,7 @@
       throw new IllegalArgumentException("empty value");
     }
     String valueText = parts.size() > 1 ? parts.get(1) : "";
-    return new LabelValue(Shorts.checkedCast(PermissionRule.parseInt(parts.get(0))), valueText);
+    return LabelValue.create(Shorts.checkedCast(PermissionRule.parseInt(parts.get(0))), valueText);
   }
 
   private void loadLabelSections(Config rc) {
@@ -917,7 +946,7 @@
       String lower = name.toLowerCase();
       if (lowerNames.containsKey(lower)) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 String.format("Label \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
       }
@@ -932,13 +961,13 @@
             values.add(labelValue);
           } else {
             error(
-                new ValidationError(
+                ValidationError.create(
                     PROJECT_CONFIG,
                     String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_VALUE, value, name)));
           }
         } catch (IllegalArgumentException notValue) {
           error(
-              new ValidationError(
+              ValidationError.create(
                   PROJECT_CONFIG,
                   String.format(
                       "Invalid %s \"%s\" for label \"%s\": %s",
@@ -946,11 +975,11 @@
         }
       }
 
-      LabelType label;
+      LabelType.Builder label;
       try {
-        label = new LabelType(name, values);
+        label = LabelType.builder(name, values);
       } catch (IllegalArgumentException badName) {
-        error(new ValidationError(PROJECT_CONFIG, String.format("Invalid label \"%s\"", name)));
+        error(ValidationError.create(PROJECT_CONFIG, String.format("Invalid label \"%s\"", name)));
         continue;
       }
 
@@ -961,7 +990,7 @@
               : Optional.of(LabelFunction.MAX_WITH_BLOCK);
       if (!function.isPresent()) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 String.format(
                     "Invalid %s for label \"%s\". Valid names are: %s",
@@ -975,7 +1004,7 @@
           label.setDefaultValue(dv);
         } else {
           error(
-              new ValidationError(
+              ValidationError.create(
                   PROJECT_CONFIG,
                   String.format(
                       "Invalid %s \"%s\" for label \"%s\"", KEY_DEFAULT_VALUE, dv, name)));
@@ -1021,14 +1050,14 @@
           short copyValue = Shorts.checkedCast(PermissionRule.parseInt(value));
           if (!copyValues.add(copyValue)) {
             error(
-                new ValidationError(
+                ValidationError.create(
                     PROJECT_CONFIG,
                     String.format(
                         "Duplicate %s \"%s\" for label \"%s\"", KEY_COPY_VALUE, value, name)));
           }
         } catch (IllegalArgumentException notValue) {
           error(
-              new ValidationError(
+              ValidationError.create(
                   PROJECT_CONFIG,
                   String.format(
                       "Invalid %s \"%s\" for label \"%s\": %s",
@@ -1038,8 +1067,9 @@
       label.setCopyValues(copyValues);
       label.setCanOverride(
           rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE, LabelType.DEF_CAN_OVERRIDE));
-      label.setRefPatterns(getStringListOrNull(rc, LABEL, name, KEY_BRANCH));
-      labelSections.put(name, label);
+      List<String> refPatterns = getStringListOrNull(rc, LABEL, name, KEY_BRANCH);
+      label.setRefPatterns(refPatterns == null ? null : ImmutableList.copyOf(refPatterns));
+      labelSections.put(name, label.build());
     }
   }
 
@@ -1066,14 +1096,14 @@
         commentLinkSections.put(name, buildCommentLink(rc, name, false));
       } catch (PatternSyntaxException e) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 String.format(
                     "Invalid pattern \"%s\" in commentlink.%s.match: %s",
                     rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
       } catch (IllegalArgumentException e) {
         error(
-            new ValidationError(
+            ValidationError.create(
                 PROJECT_CONFIG,
                 String.format(
                     "Error in pattern \"%s\" in commentlink.%s.match: %s",
@@ -1088,7 +1118,7 @@
     try {
       for (String projectName : subsections) {
         Project.NameKey p = Project.nameKey(projectName);
-        SubscribeSection ss = new SubscribeSection(p);
+        SubscribeSection.Builder ss = SubscribeSection.builder(p);
         for (String s :
             rc.getStringList(SUBSCRIBE_SECTION, projectName, SUBSCRIBE_MULTI_MATCH_REFS)) {
           ss.addMultiMatchRefSpec(s);
@@ -1096,7 +1126,7 @@
         for (String s : rc.getStringList(SUBSCRIBE_SECTION, projectName, SUBSCRIBE_MATCH_REFS)) {
           ss.addMatchingRefSpec(s);
         }
-        subscribeSections.put(p, ss);
+        subscribeSections.put(p, ss.build());
       }
     } catch (IllegalArgumentException e) {
       throw new ConfigInvalidException(e.getMessage());
@@ -1117,10 +1147,10 @@
         String value = rc.getString(PLUGIN, plugin, name);
         String groupName = GroupReference.extractGroupName(value);
         if (groupName != null) {
-          GroupReference ref = groupsByName.get(groupName);
+          GroupReference ref = groupList.byName(groupName);
           if (ref == null) {
             error(
-                new ValidationError(
+                ValidationError.create(
                     PROJECT_CONFIG, "group \"" + groupName + "\" not in " + GroupList.FILE_NAME));
           }
           rc.setString(PLUGIN, plugin, name, value);
@@ -1131,29 +1161,25 @@
     }
   }
 
-  public PluginConfig getPluginConfig(String pluginName) {
+  public void updatePluginConfig(
+      String pluginName, Consumer<PluginConfig.Update> pluginConfigUpdate) {
     Config pluginConfig = pluginConfigs.get(pluginName);
     if (pluginConfig == null) {
       pluginConfig = new Config();
       pluginConfigs.put(pluginName, pluginConfig);
     }
-    return new PluginConfig(pluginName, pluginConfig, this);
+    pluginConfigUpdate.accept(new PluginConfig.Update(pluginName, pluginConfig, Optional.of(this)));
+  }
+
+  public PluginConfig getPluginConfig(String pluginName) {
+    Config pluginConfig = pluginConfigs.getOrDefault(pluginName, new Config());
+    return PluginConfig.create(pluginName, pluginConfig, getCacheable());
   }
 
   private void readGroupList() throws IOException {
     groupList = GroupList.parse(projectName, readUTF8(GroupList.FILE_NAME), this);
   }
 
-  private Map<String, GroupReference> mapGroupReferences() {
-    Collection<GroupReference> references = groupList.references();
-    Map<String, GroupReference> result = new HashMap<>(references.size());
-    for (GroupReference ref : references) {
-      result.put(ref.getName(), ref);
-    }
-
-    return result;
-  }
-
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     if (commit.getMessage() == null || "".equals(commit.getMessage())) {
@@ -1187,7 +1213,7 @@
         KEY_MAX_OBJECT_SIZE_LIMIT,
         validMaxObjectSizeLimit(p.getMaxObjectSizeLimit()));
 
-    set(rc, SUBMIT, null, KEY_ACTION, p.getConfiguredSubmitType(), DEFAULT_SUBMIT_TYPE);
+    set(rc, SUBMIT, null, KEY_ACTION, p.getSubmitType(), DEFAULT_SUBMIT_TYPE);
 
     set(rc, PROJECT, null, KEY_STATE, p.getState(), DEFAULT_STATE_VALUE);
 
@@ -1204,6 +1230,7 @@
     saveLabelSections(rc);
     saveCommentLinkSections(rc);
     saveSubscribeSections(rc);
+    saveBranchOrderSection(rc);
 
     saveConfig(PROJECT_CONFIG, rc);
     saveGroupList();
@@ -1252,16 +1279,16 @@
   private void saveCommentLinkSections(Config rc) {
     unsetSection(rc, COMMENTLINK);
     if (commentLinkSections != null) {
-      for (CommentLinkInfoImpl cm : commentLinkSections.values()) {
-        rc.setString(COMMENTLINK, cm.name, KEY_MATCH, cm.match);
-        if (!Strings.isNullOrEmpty(cm.html)) {
-          rc.setString(COMMENTLINK, cm.name, KEY_HTML, cm.html);
+      for (StoredCommentLinkInfo cm : commentLinkSections.values()) {
+        rc.setString(COMMENTLINK, cm.getName(), KEY_MATCH, cm.getMatch());
+        if (!Strings.isNullOrEmpty(cm.getHtml())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_HTML, cm.getHtml());
         }
-        if (!Strings.isNullOrEmpty(cm.link)) {
-          rc.setString(COMMENTLINK, cm.name, KEY_LINK, cm.link);
+        if (!Strings.isNullOrEmpty(cm.getLink())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_LINK, cm.getLink());
         }
-        if (cm.enabled != null && !cm.enabled) {
-          rc.setBoolean(COMMENTLINK, cm.name, KEY_ENABLED, cm.enabled);
+        if (cm.getEnabled() != null && !cm.getEnabled()) {
+          rc.setBoolean(COMMENTLINK, cm.getName(), KEY_ENABLED, cm.getEnabled());
         }
       }
     }
@@ -1277,7 +1304,7 @@
         if (ca.getAutoVerify().getUUID() != null) {
           keepGroups.add(ca.getAutoVerify().getUUID());
         }
-        String autoVerify = new PermissionRule(ca.getAutoVerify()).asString(false);
+        String autoVerify = PermissionRule.create(ca.getAutoVerify()).asString(false);
         set(rc, CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_AUTO_VERIFY, autoVerify);
       } else {
         rc.unset(CONTRIBUTOR_AGREEMENT, ca.getName(), KEY_AUTO_VERIFY);
@@ -1310,7 +1337,7 @@
           .forEach(keepGroups::add);
       List<String> email =
           nc.getGroups().stream()
-              .map(gr -> new PermissionRule(gr).asString(false))
+              .map(gr -> PermissionRule.create(gr).asString(false))
               .sorted()
               .collect(toList());
 
@@ -1324,7 +1351,7 @@
         rc.setStringList(NOTIFY, nc.getName(), KEY_EMAIL, email);
       }
 
-      if (nc.getNotify().equals(EnumSet.of(NotifyType.ALL))) {
+      if (nc.getNotify().equals(Sets.immutableEnumSet(NotifyType.ALL))) {
         rc.unset(NOTIFY, nc.getName(), KEY_TYPE);
       } else {
         List<String> types = new ArrayList<>(4);
@@ -1456,14 +1483,14 @@
           LABEL,
           name,
           KEY_ALLOW_POST_SUBMIT,
-          label.allowPostSubmit(),
+          label.isAllowPostSubmit(),
           LabelType.DEF_ALLOW_POST_SUBMIT);
       setBooleanConfigKey(
           rc,
           LABEL,
           name,
           KEY_IGNORE_SELF_APPROVAL,
-          label.ignoreSelfApproval(),
+          label.isIgnoreSelfApproval(),
           LabelType.DEF_IGNORE_SELF_APPROVAL);
       setBooleanConfigKey(
           rc,
@@ -1520,7 +1547,7 @@
           KEY_COPY_VALUE,
           label.getCopyValues().stream().map(LabelValue::formatValue).collect(toList()));
       setBooleanConfigKey(
-          rc, LABEL, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
+          rc, LABEL, name, KEY_CAN_OVERRIDE, label.isCanOverride(), LabelType.DEF_CAN_OVERRIDE);
       List<String> values = new ArrayList<>(label.getValues().size());
       for (LabelValue value : label.getValues()) {
         values.add(value.format().trim());
@@ -1558,7 +1585,7 @@
         String value = pluginConfig.getString(PLUGIN, plugin, name);
         String groupName = GroupReference.extractGroupName(value);
         if (groupName != null) {
-          GroupReference ref = groupsByName.get(groupName);
+          GroupReference ref = groupList.byName(groupName);
           if (ref != null && ref.getUUID() != null) {
             keepGroups.add(ref.getUUID());
             pluginConfig.setString(PLUGIN, plugin, name, "group " + ref.getName());
@@ -1578,14 +1605,14 @@
     for (Project.NameKey p : subscribeSections.keySet()) {
       SubscribeSection s = subscribeSections.get(p);
       List<String> matchings = new ArrayList<>();
-      for (RefSpec r : s.getMatchingRefSpecs()) {
-        matchings.add(r.toString());
+      for (String r : s.matchingRefSpecsAsString()) {
+        matchings.add(r);
       }
       rc.setStringList(SUBSCRIBE_SECTION, p.get(), SUBSCRIBE_MATCH_REFS, matchings);
 
       List<String> multimatchs = new ArrayList<>();
-      for (RefSpec r : s.getMultiMatchRefSpecs()) {
-        multimatchs.add(r.toString());
+      for (String r : s.multiMatchRefSpecsAsString()) {
+        multimatchs.add(r);
       }
       rc.setStringList(SUBSCRIBE_SECTION, p.get(), SUBSCRIBE_MULTI_MATCH_REFS, multimatchs);
     }
@@ -1603,7 +1630,7 @@
     try {
       return rc.getEnum(section, subsection, name, defaultValue);
     } catch (IllegalArgumentException err) {
-      error(new ValidationError(PROJECT_CONFIG, err.getMessage()));
+      error(ValidationError.create(PROJECT_CONFIG, err.getMessage()));
       return defaultValue;
     }
   }
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index cc10f27..c382f04 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -17,14 +17,15 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
@@ -150,36 +151,45 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.create(args.getProject())) {
       ProjectConfig config = projectConfigFactory.read(md);
 
-      Project newProject = config.getProject();
-      newProject.setDescription(args.projectDescription);
-      newProject.setSubmitType(
-          MoreObjects.firstNonNull(
-              args.submitType, repositoryCfg.getDefaultSubmitType(args.getProject())));
-      newProject.setBooleanConfig(
-          BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, args.contributorAgreements);
-      newProject.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, args.signedOffBy);
-      newProject.setBooleanConfig(BooleanProjectConfig.USE_CONTENT_MERGE, args.contentMerge);
-      newProject.setBooleanConfig(
-          BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
-          args.newChangeForAllNotInTarget);
-      newProject.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, args.changeIdRequired);
-      newProject.setBooleanConfig(BooleanProjectConfig.REJECT_EMPTY_COMMIT, args.rejectEmptyCommit);
-      newProject.setMaxObjectSizeLimit(args.maxObjectSizeLimit);
-      newProject.setBooleanConfig(BooleanProjectConfig.ENABLE_SIGNED_PUSH, args.enableSignedPush);
-      newProject.setBooleanConfig(BooleanProjectConfig.REQUIRE_SIGNED_PUSH, args.requireSignedPush);
-      if (args.newParent != null) {
-        newProject.setParentName(args.newParent);
-      }
+      config.updateProject(
+          newProject -> {
+            newProject.setDescription(Strings.nullToEmpty(args.projectDescription));
+            newProject.setSubmitType(
+                MoreObjects.firstNonNull(
+                    args.submitType, repositoryCfg.getDefaultSubmitType(args.getProject())));
+            newProject.setBooleanConfig(
+                BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, args.contributorAgreements);
+            newProject.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, args.signedOffBy);
+            newProject.setBooleanConfig(BooleanProjectConfig.USE_CONTENT_MERGE, args.contentMerge);
+            newProject.setBooleanConfig(
+                BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+                args.newChangeForAllNotInTarget);
+            newProject.setBooleanConfig(
+                BooleanProjectConfig.REQUIRE_CHANGE_ID, args.changeIdRequired);
+            newProject.setBooleanConfig(
+                BooleanProjectConfig.REJECT_EMPTY_COMMIT, args.rejectEmptyCommit);
+            newProject.setMaxObjectSizeLimit(args.maxObjectSizeLimit);
+            newProject.setBooleanConfig(
+                BooleanProjectConfig.ENABLE_SIGNED_PUSH, args.enableSignedPush);
+            newProject.setBooleanConfig(
+                BooleanProjectConfig.REQUIRE_SIGNED_PUSH, args.requireSignedPush);
+            if (args.newParent != null) {
+              newProject.setParent(args.newParent);
+            }
+          });
 
       if (!args.ownerIds.isEmpty()) {
-        AccessSection all = config.getAccessSection(AccessSection.ALL, true);
-        for (AccountGroup.UUID ownerId : args.ownerIds) {
-          GroupDescription.Basic g = groupBackend.get(ownerId);
-          if (g != null) {
-            GroupReference group = config.resolve(GroupReference.forGroup(g));
-            all.getPermission(Permission.OWNER, true).add(new PermissionRule(group));
-          }
-        }
+        config.upsertAccessSection(
+            AccessSection.ALL,
+            all -> {
+              for (AccountGroup.UUID ownerId : args.ownerIds) {
+                GroupDescription.Basic g = groupBackend.get(ownerId);
+                if (g != null) {
+                  GroupReference group = config.resolve(GroupReference.forGroup(g));
+                  all.upsertPermission(Permission.OWNER).add(PermissionRule.builder(group));
+                }
+              }
+            });
       }
 
       md.setMessage("Created project\n");
diff --git a/java/com/google/gerrit/server/project/ProjectJson.java b/java/com/google/gerrit/server/project/ProjectJson.java
index f00df53..4eda1cc 100644
--- a/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/java/com/google/gerrit/server/project/ProjectJson.java
@@ -19,8 +19,8 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.LabelTypeInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index e52f344..599bf00f3 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -14,25 +14,27 @@
 
 package com.google.gerrit.server.project;
 
-import static com.google.gerrit.common.data.PermissionRule.Action.ALLOW;
+import static com.google.gerrit.entities.PermissionRule.Action.ALLOW;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.BranchOrderSection;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.entities.SubscribeSection;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -40,7 +42,7 @@
 import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.BranchOrderSection;
+import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -55,11 +57,10 @@
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
 /**
@@ -70,7 +71,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    ProjectState create(ProjectConfig config);
+    ProjectState create(CachedProjectConfig config);
   }
 
   private final boolean isAllProjects;
@@ -80,15 +81,12 @@
   private final GitRepositoryManager gitMgr;
   private final List<CommentLinkInfo> commentLinks;
 
-  private final ProjectConfig config;
+  private final CachedProjectConfig cachedConfig;
   private final Map<String, ProjectLevelConfig> configs;
   private final Set<AccountGroup.UUID> localOwners;
   private final long globalMaxObjectSizeLimit;
   private final boolean inheritProjectMaxObjectSizeLimit;
 
-  /** Last system time the configuration's revision was examined. */
-  private volatile long lastCheckGeneration;
-
   /** Local access sections, wrapped in SectionMatchers for faster evaluation. */
   private volatile List<SectionMatcher> localAccessSections;
 
@@ -104,18 +102,21 @@
       List<CommentLinkInfo> commentLinks,
       CapabilityCollection.Factory limitsFactory,
       TransferConfig transferConfig,
-      @Assisted ProjectConfig config) {
+      @Assisted CachedProjectConfig cachedProjectConfig) {
     this.projectCache = projectCache;
-    this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
-    this.isAllUsers = config.getProject().getNameKey().equals(allUsersName);
+    this.isAllProjects = cachedProjectConfig.getProject().getNameKey().equals(allProjectsName);
+    this.isAllUsers = cachedProjectConfig.getProject().getNameKey().equals(allUsersName);
     this.allProjectsName = allProjectsName;
     this.gitMgr = gitMgr;
     this.commentLinks = commentLinks;
-    this.config = config;
+    this.cachedConfig = cachedProjectConfig;
     this.configs = new HashMap<>();
     this.capabilities =
         isAllProjects
-            ? limitsFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
+            ? limitsFactory.create(
+                cachedProjectConfig
+                    .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+                    .orElse(null))
             : null;
     this.globalMaxObjectSizeLimit = transferConfig.getMaxObjectSizeLimit();
     this.inheritProjectMaxObjectSizeLimit = transferConfig.inheritProjectMaxObjectSizeLimit();
@@ -124,9 +125,9 @@
       localOwners = Collections.emptySet();
     } else {
       HashSet<AccountGroup.UUID> groups = new HashSet<>();
-      AccessSection all = config.getAccessSection(AccessSection.ALL);
-      if (all != null) {
-        Permission owner = all.getPermission(Permission.OWNER);
+      Optional<AccessSection> all = cachedProjectConfig.getAccessSection(AccessSection.ALL);
+      if (all.isPresent()) {
+        Permission owner = all.get().getPermission(Permission.OWNER);
         if (owner != null) {
           for (PermissionRule rule : owner.getRules()) {
             GroupReference ref = rule.getGroup();
@@ -140,33 +141,6 @@
     }
   }
 
-  void initLastCheck(long generation) {
-    lastCheckGeneration = generation;
-  }
-
-  boolean needsRefresh(long generation) {
-    if (generation <= 0) {
-      return isRevisionOutOfDate();
-    }
-    if (lastCheckGeneration != generation) {
-      lastCheckGeneration = generation;
-      return isRevisionOutOfDate();
-    }
-    return false;
-  }
-
-  private boolean isRevisionOutOfDate() {
-    try (Repository git = gitMgr.openRepository(getNameKey())) {
-      Ref ref = git.getRefDatabase().exactRef(RefNames.REFS_CONFIG);
-      if (ref == null || ref.getObjectId() == null) {
-        return true;
-      }
-      return !ref.getObjectId().equals(config.getRevision());
-    } catch (IOException gone) {
-      return true;
-    }
-  }
-
   /**
    * @return cached computation of all global capabilities. This should only be invoked on the state
    *     from {@link ProjectCache#getAllProjects()}. Null on any other project.
@@ -181,19 +155,19 @@
    */
   public boolean hasPrologRules() {
     // We check if this project has a rules.pl file
-    if (getConfig().getRulesId() != null) {
+    if (getConfig().getRulesId().isPresent()) {
       return true;
     }
 
     // If not, we check the parents.
     return parents().stream()
         .map(ProjectState::getConfig)
-        .map(ProjectConfig::getRulesId)
-        .anyMatch(Objects::nonNull);
+        .map(CachedProjectConfig::getRulesId)
+        .anyMatch(Optional::isPresent);
   }
 
   public Project getProject() {
-    return config.getProject();
+    return cachedConfig.getProject();
   }
 
   public Project.NameKey getNameKey() {
@@ -204,8 +178,8 @@
     return getNameKey().get();
   }
 
-  public ProjectConfig getConfig() {
-    return config;
+  public CachedProjectConfig getConfig() {
+    return cachedConfig;
   }
 
   public ProjectLevelConfig getConfig(String fileName) {
@@ -215,7 +189,7 @@
 
     ProjectLevelConfig cfg = new ProjectLevelConfig(fileName, this);
     try (Repository git = gitMgr.openRepository(getNameKey())) {
-      cfg.load(getNameKey(), git, config.getRevision());
+      cfg.load(getNameKey(), git, cachedConfig.getRevision().get());
     } catch (IOException | ConfigInvalidException e) {
       logger.atWarning().withCause(e).log("Failed to load %s for %s", fileName, getName());
     }
@@ -225,7 +199,7 @@
   }
 
   public long getMaxObjectSizeLimit() {
-    return config.getMaxObjectSizeLimit();
+    return cachedConfig.getMaxObjectSizeLimit();
   }
 
   public boolean statePermitsRead() {
@@ -274,19 +248,21 @@
   public EffectiveMaxObjectSizeLimit getEffectiveMaxObjectSizeLimit() {
     EffectiveMaxObjectSizeLimit result = new EffectiveMaxObjectSizeLimit();
 
-    result.value = config.getMaxObjectSizeLimit();
+    result.value = cachedConfig.getMaxObjectSizeLimit();
 
     if (inheritProjectMaxObjectSizeLimit) {
       for (ProjectState parent : parents()) {
-        long parentValue = parent.config.getMaxObjectSizeLimit();
+        long parentValue = parent.cachedConfig.getMaxObjectSizeLimit();
         if (parentValue > 0 && result.value > 0) {
           if (parentValue < result.value) {
             result.value = parentValue;
-            result.summary = String.format(OVERRIDDEN_BY_PARENT, parent.config.getName());
+            result.summary =
+                String.format(OVERRIDDEN_BY_PARENT, parent.cachedConfig.getProject().getNameKey());
           }
         } else if (parentValue > 0) {
           result.value = parentValue;
-          result.summary = String.format(INHERITED_FROM_PARENT, parent.config.getName());
+          result.summary =
+              String.format(INHERITED_FROM_PARENT, parent.cachedConfig.getProject().getNameKey());
         }
       }
     }
@@ -308,18 +284,20 @@
   List<SectionMatcher> getLocalAccessSections() {
     List<SectionMatcher> sm = localAccessSections;
     if (sm == null) {
-      Collection<AccessSection> fromConfig = config.getAccessSections();
+      Collection<AccessSection> fromConfig = cachedConfig.getAccessSections().values();
       sm = new ArrayList<>(fromConfig.size());
       for (AccessSection section : fromConfig) {
         if (isAllProjects) {
-          List<Permission> copy = Lists.newArrayListWithCapacity(section.getPermissions().size());
+          List<Permission.Builder> copy = new ArrayList<>();
           for (Permission p : section.getPermissions()) {
             if (Permission.canBeOnAllProjects(section.getName(), p.getName())) {
-              copy.add(p);
+              copy.add(p.toBuilder());
             }
           }
-          section = new AccessSection(section.getName());
-          section.setPermissions(copy);
+          section =
+              AccessSection.builder(section.getName())
+                  .modifyPermissions(permissions -> permissions.addAll(copy))
+                  .build();
         }
 
         SectionMatcher matcher = SectionMatcher.wrap(getNameKey(), section);
@@ -434,7 +412,7 @@
       for (LabelType type : s.getConfig().getLabelSections().values()) {
         String lower = type.getName().toLowerCase();
         LabelType old = types.get(lower);
-        if (old == null || old.canOverride()) {
+        if (old == null || old.isCanOverride()) {
           types.put(lower, type);
         }
       }
@@ -489,30 +467,52 @@
       cls.put(cl.name.toLowerCase(), cl);
     }
     for (ProjectState s : treeInOrder()) {
-      for (CommentLinkInfoImpl cl : s.getConfig().getCommentLinkSections()) {
-        String name = cl.name.toLowerCase();
-        if (cl.isOverrideOnly()) {
+      for (StoredCommentLinkInfo cl : s.getConfig().getCommentLinkSections().values()) {
+        String name = cl.getName().toLowerCase();
+        if (cl.getOverrideOnly()) {
           CommentLinkInfo parent = cls.get(name);
           if (parent == null) {
             continue; // Ignore invalid overrides.
           }
-          cls.put(name, cl.inherit(parent));
+          cls.put(name, StoredCommentLinkInfo.fromInfo(parent, cl.getEnabled()).toInfo());
         } else {
-          cls.put(name, cl);
+          cls.put(name, cl.toInfo());
         }
       }
     }
     return ImmutableList.copyOf(cls.values());
   }
 
-  public BranchOrderSection getBranchOrderSection() {
+  /**
+   * Returns the {@link PluginConfig} that got parsed from the {@code plugins} section of {@code
+   * project.config}. The returned instance is a defensive copy of the cached value. Returns an
+   * empty config in case we find no config for the given plugin name. This is useful when calling
+   * {@code PluginConfig#withInheritance(ProjectState.Factory)}
+   */
+  public PluginConfig getPluginConfig(String pluginName) {
+    if (getConfig().getPluginConfigs().containsKey(pluginName)) {
+      Config config = new Config();
+      try {
+        config.fromText(getConfig().getPluginConfigs().get(pluginName));
+      } catch (ConfigInvalidException e) {
+        // This is OK to propagate as IllegalStateException because it's a programmer error.
+        // The config was converted to a String using Config#toText. So #fromText must not
+        // throw a ConfigInvalidException
+        throw new IllegalStateException("invalid plugin config for " + pluginName, e);
+      }
+      return PluginConfig.create(pluginName, config, getConfig());
+    }
+    return PluginConfig.create(pluginName, new Config(), getConfig());
+  }
+
+  public Optional<BranchOrderSection> getBranchOrderSection() {
     for (ProjectState s : tree()) {
-      BranchOrderSection section = s.getConfig().getBranchOrderSection();
-      if (section != null) {
+      Optional<BranchOrderSection> section = s.getConfig().getBranchOrderSection();
+      if (section.isPresent()) {
         return section;
       }
     }
-    return null;
+    return Optional.empty();
   }
 
   public Collection<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
@@ -533,7 +533,7 @@
 
   public SubmitType getSubmitType() {
     for (ProjectState s : tree()) {
-      SubmitType t = s.getProject().getConfiguredSubmitType();
+      SubmitType t = s.getProject().getSubmitType();
       if (t != SubmitType.INHERIT) {
         return t;
       }
diff --git a/java/com/google/gerrit/server/project/RefPattern.java b/java/com/google/gerrit/server/project/RefPattern.java
index c52914b..5bac950 100644
--- a/java/com/google/gerrit/server/project/RefPattern.java
+++ b/java/com/google/gerrit/server/project/RefPattern.java
@@ -19,8 +19,8 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.exceptions.InvalidNameException;
 import dk.brics.automaton.RegExp;
 import java.util.Map;
diff --git a/java/com/google/gerrit/server/project/RefValidationHelper.java b/java/com/google/gerrit/server/project/RefValidationHelper.java
index 9b297f9..1912660 100644
--- a/java/com/google/gerrit/server/project/RefValidationHelper.java
+++ b/java/com/google/gerrit/server/project/RefValidationHelper.java
@@ -43,7 +43,7 @@
       throws ResourceConflictException {
     RefOperationValidators refValidators =
         refValidatorsFactory.create(
-            new Project(Project.nameKey(projectName)),
+            Project.builder(Project.nameKey(projectName)).build(),
             user,
             RefOperationValidators.getCommand(update, operationType));
     try {
diff --git a/java/com/google/gerrit/server/project/SectionMatcher.java b/java/com/google/gerrit/server/project/SectionMatcher.java
index 6de8eec..763957e 100644
--- a/java/com/google/gerrit/server/project/SectionMatcher.java
+++ b/java/com/google/gerrit/server/project/SectionMatcher.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index cf3819d..0e50bb0 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -18,9 +18,9 @@
 
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
diff --git a/java/com/google/gerrit/server/project/testing/BUILD b/java/com/google/gerrit/server/project/testing/BUILD
index 988a89f..3112b5a 100644
--- a/java/com/google/gerrit/server/project/testing/BUILD
+++ b/java/com/google/gerrit/server/project/testing/BUILD
@@ -5,5 +5,8 @@
     testonly = True,
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
-    deps = ["//java/com/google/gerrit/common:server"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
+    ],
 )
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
index 6c2ddde..157c746 100644
--- a/java/com/google/gerrit/server/project/testing/TestLabels.java
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.project.testing;
 
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import java.util.Arrays;
 
 public class TestLabels {
@@ -35,18 +35,23 @@
   }
 
   public static LabelType patchSetLock() {
-    LabelType label =
-        label("Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
+    LabelType.Builder label =
+        labelBuilder(
+            "Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
     label.setFunction(LabelFunction.PATCH_SET_LOCK);
-    return label;
+    return label.build();
   }
 
   public static LabelValue value(int value, String text) {
-    return new LabelValue((short) value, text);
+    return LabelValue.create((short) value, text);
   }
 
   public static LabelType label(String name, LabelValue... values) {
-    return new LabelType(name, Arrays.asList(values));
+    return labelBuilder(name, values).build();
+  }
+
+  public static LabelType.Builder labelBuilder(String name, LabelValue... values) {
+    return LabelType.builder(name, Arrays.asList(values));
   }
 
   private TestLabels() {}
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 69f1a4e..7f7df8c 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -33,19 +33,20 @@
 import com.google.common.collect.Maps;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -277,7 +278,7 @@
   private List<PatchSetApproval> currentApprovals;
   private List<String> currentFiles;
   private Optional<DiffSummary> diffSummary;
-  private Collection<Comment> publishedComments;
+  private Collection<HumanComment> publishedComments;
   private Collection<RobotComment> robotComments;
   private CurrentUser visibleTo;
   private List<ChangeMessage> messages;
@@ -675,7 +676,9 @@
   public ReviewerSet reviewers() {
     if (reviewers == null) {
       if (!lazyLoad) {
-        return ReviewerSet.empty();
+        // We are not allowed to load values from NoteDb. Reviewers were not populated with values
+        // from the index. However, we need these values for permission checks.
+        throw new IllegalStateException("reviewers not populated");
       }
       reviewers = approvalsUtil.getReviewers(notes(), approvals().values());
     }
@@ -686,10 +689,6 @@
     this.reviewers = reviewers;
   }
 
-  public ReviewerSet getReviewers() {
-    return reviewers;
-  }
-
   public ReviewerByEmailSet reviewersByEmail() {
     if (reviewersByEmail == null) {
       if (!lazyLoad) {
@@ -762,12 +761,12 @@
     return reviewerUpdates;
   }
 
-  public Collection<Comment> publishedComments() {
+  public Collection<HumanComment> publishedComments() {
     if (publishedComments == null) {
       if (!lazyLoad) {
         return Collections.emptyList();
       }
-      publishedComments = commentsUtil.publishedByChange(notes());
+      publishedComments = commentsUtil.publishedHumanCommentsByChange(notes());
     }
     return publishedComments;
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 40a3a07..c6bcd60 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -73,7 +73,6 @@
       return false;
     }
 
-    ChangeNotes notes = notesFactory.createFromIndexedChange(change);
     Optional<ProjectState> projectState = projectCache.get(cd.project());
     if (!projectState.isPresent()) {
       logger.atFine().log("Filter out change %s of non-existing project %s", cd, cd.project());
@@ -92,7 +91,7 @@
                     .filter(u -> u instanceof SingleGroupUser || u instanceof InternalUser)
                     .orElseGet(anonymousUserProvider::get));
     try {
-      withUser.indexedChange(cd, notes).check(ChangePermission.READ);
+      withUser.change(cd).check(ChangePermission.READ);
     } catch (PermissionBackendException e) {
       Throwable cause = e.getCause();
       if (cause instanceof RepositoryNotFoundException) {
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 886d0ee..c9ae126 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -28,15 +28,16 @@
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -48,7 +49,6 @@
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryRequiresAuthException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
diff --git a/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/java/com/google/gerrit/server/query/change/CommentByPredicate.java
index bd7981c..b8cf100 100644
--- a/java/com/google/gerrit/server/query/change/CommentByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/CommentByPredicate.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.Objects;
 
@@ -39,7 +39,7 @@
         return true;
       }
     }
-    for (Comment c : cd.publishedComments()) {
+    for (HumanComment c : cd.publishedComments()) {
       if (Objects.equals(c.author.getId(), id)) {
         return true;
       }
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 6eb6871d..16f85b1 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -20,11 +20,11 @@
 import static java.util.concurrent.TimeUnit.MINUTES;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.index.query.Predicate;
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 569d7cb..4b6c964 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.IdentifiedUser;
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index e875499..02e8434 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -18,7 +18,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
diff --git a/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
index 070f800..62fe9e8 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
@@ -16,8 +16,8 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 
diff --git a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
index bc65422..4ca684a 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -16,8 +16,8 @@
 
 import static java.util.stream.Collectors.toList;
 
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.Set;
diff --git a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
index c507f1c..2018fbc 100644
--- a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.index.change.ChangeField;
 
 public class SubmittablePredicate extends ChangeIndexPredicate {
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
index 4e60db5..fbc8d0e 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
@@ -19,9 +19,9 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index 907dd18..015b235 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -21,9 +21,9 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.InvalidSshKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index fee5eab..6ee4539 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -80,6 +81,7 @@
   private final RegisterNewEmailSender.Factory registerNewEmailFactory;
   private final PutPreferred putPreferred;
   private final OutgoingEmailValidator validator;
+  private final MessageIdGenerator messageIdGenerator;
   private final boolean isDevMode;
 
   @Inject
@@ -91,7 +93,8 @@
       AccountManager accountManager,
       RegisterNewEmailSender.Factory registerNewEmailFactory,
       PutPreferred putPreferred,
-      OutgoingEmailValidator validator) {
+      OutgoingEmailValidator validator,
+      MessageIdGenerator messageIdGenerator) {
     this.self = self;
     this.realm = realm;
     this.permissionBackend = permissionBackend;
@@ -100,6 +103,7 @@
     this.putPreferred = putPreferred;
     this.validator = validator;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   @Override
@@ -157,11 +161,12 @@
       }
     } else {
       try {
-        RegisterNewEmailSender sender = registerNewEmailFactory.create(email);
-        if (!sender.isAllowed()) {
+        RegisterNewEmailSender emailSender = registerNewEmailFactory.create(email);
+        if (!emailSender.isAllowed()) {
           throw new MethodNotAllowedException("Not allowed to add email address " + email);
         }
-        sender.send();
+        emailSender.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+        emailSender.send();
         info.pendingConfirmation = true;
       } catch (EmailException | RuntimeException e) {
         logger.atSevere().withCause(e).log("Cannot send email verification message to %s", email);
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index 4b505c6..f29c6e6 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -22,7 +22,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
@@ -48,7 +48,7 @@
 import com.google.gerrit.server.query.change.HasDraftByPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.CommentJson;
-import com.google.gerrit.server.restapi.change.CommentJson.CommentFormatter;
+import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdate.Factory;
 import com.google.gerrit.server.update.BatchUpdateListener;
@@ -123,7 +123,8 @@
       throw new AuthException("Cannot delete drafts of other user");
     }
 
-    CommentFormatter commentFormatter = commentJsonProvider.get().newCommentFormatter();
+    HumanCommentFormatter humanCommentFormatter =
+        commentJsonProvider.get().newHumanCommentFormatter();
     Account.Id accountId = rsrc.getUser().getAccountId();
     Timestamp now = TimeUtil.nowTs();
     Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
@@ -137,7 +138,7 @@
       BatchUpdate update =
           updates.computeIfAbsent(
               cd.project(), p -> batchUpdateFactory.create(p, rsrc.getUser(), now));
-      Op op = new Op(commentFormatter, accountId);
+      Op op = new Op(humanCommentFormatter, accountId);
       update.addOp(cd.getId(), op);
       ops.add(op);
     }
@@ -165,12 +166,12 @@
   }
 
   private class Op implements BatchUpdateOp {
-    private final CommentFormatter commentFormatter;
+    private final HumanCommentFormatter humanCommentFormatter;
     private final Account.Id accountId;
     private DeletedDraftCommentInfo result;
 
-    Op(CommentFormatter commentFormatter, Account.Id accountId) {
-      this.commentFormatter = commentFormatter;
+    Op(HumanCommentFormatter humanCommentFormatter, Account.Id accountId) {
+      this.humanCommentFormatter = humanCommentFormatter;
       this.accountId = accountId;
     }
 
@@ -179,12 +180,12 @@
         throws PatchListNotAvailableException, PermissionBackendException {
       ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
       boolean dirty = false;
-      for (Comment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
+      for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
         dirty = true;
         PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
         setCommentCommitId(c, patchListCache, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
-        commentsUtil.deleteComments(ctx.getUpdate(psId), Collections.singleton(c));
-        comments.add(commentFormatter.format(c));
+        commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
+        comments.add(humanCommentFormatter.format(c));
       }
       if (dirty) {
         result = new DeletedDraftCommentInfo();
diff --git a/java/com/google/gerrit/server/restapi/account/GetAgreements.java b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
index aeaeb1c..db6ad48 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAgreements.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.restapi.account;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.extensions.common.AgreementInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -96,7 +96,7 @@
 
     List<AgreementInfo> results = new ArrayList<>();
     Collection<ContributorAgreement> cas =
-        projectCache.getAllProjects().getConfig().getContributorAgreements();
+        projectCache.getAllProjects().getConfig().getContributorAgreements().values();
     for (ContributorAgreement ca : cas) {
       List<AccountGroup.UUID> groupIds = new ArrayList<>();
       for (PermissionRule rule : ca.getAccepted()) {
diff --git a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
index f3d9557..6ab2c44 100644
--- a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
@@ -21,7 +21,7 @@
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.entities.PermissionRange;
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
diff --git a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
index beb5e8f..8d65aac 100644
--- a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -29,7 +30,6 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index b2859e6..c80bf57 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -24,7 +25,6 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.ProjectWatches;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
diff --git a/java/com/google/gerrit/server/restapi/account/PutAgreement.java b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
index 42504a0..47c223c 100644
--- a/java/com/google/gerrit/server/restapi/account/PutAgreement.java
+++ b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
@@ -16,8 +16,8 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.ContributorAgreement;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.api.accounts.AgreementInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -82,7 +82,7 @@
 
     String agreementName = Strings.nullToEmpty(input.name);
     ContributorAgreement ca =
-        projectCache.getAllProjects().getConfig().getContributorAgreement(agreementName);
+        projectCache.getAllProjects().getConfig().getContributorAgreements().get(agreementName);
     if (ca == null) {
       throw new UnprocessableEntityException("contributor agreement not found");
     }
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index 5176fe9..4aa0812 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.base.Strings;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -24,12 +24,15 @@
 import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.RobotClassifier;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
 import com.google.gerrit.server.change.AttentionSetEntryResource;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -38,12 +41,14 @@
 @Singleton
 public class AddToAttentionSet
     implements RestCollectionModifyView<
-        ChangeResource, AttentionSetEntryResource, AddToAttentionSetInput> {
+        ChangeResource, AttentionSetEntryResource, AttentionSetInput> {
   private final BatchUpdate.Factory updateFactory;
   private final AccountResolver accountResolver;
   private final AddToAttentionSetOp.Factory opFactory;
   private final AccountLoader.Factory accountLoaderFactory;
   private final PermissionBackend permissionBackend;
+  private final NotifyResolver notifyResolver;
+  private final RobotClassifier robotClassifier;
 
   @Inject
   AddToAttentionSet(
@@ -51,27 +56,29 @@
       AccountResolver accountResolver,
       AddToAttentionSetOp.Factory opFactory,
       AccountLoader.Factory accountLoaderFactory,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      NotifyResolver notifyResolver,
+      RobotClassifier robotClassifier) {
     this.updateFactory = updateFactory;
     this.accountResolver = accountResolver;
     this.opFactory = opFactory;
     this.accountLoaderFactory = accountLoaderFactory;
     this.permissionBackend = permissionBackend;
+    this.notifyResolver = notifyResolver;
+    this.robotClassifier = robotClassifier;
   }
 
   @Override
-  public Response<AccountInfo> apply(ChangeResource changeResource, AddToAttentionSetInput input)
+  public Response<AccountInfo> apply(ChangeResource changeResource, AttentionSetInput input)
       throws Exception {
-    input.user = Strings.nullToEmpty(input.user).trim();
-    if (input.user.isEmpty()) {
-      throw new BadRequestException("missing field: user");
-    }
-    input.reason = Strings.nullToEmpty(input.reason).trim();
-    if (input.reason.isEmpty()) {
-      throw new BadRequestException("missing field: reason");
-    }
+    AttentionSetUtil.validateInput(input);
 
     Account.Id attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
+    if (robotClassifier.isRobot(attentionUserId)) {
+      throw new BadRequestException(
+          String.format(
+              "%s is a robot, and robots can't be added to the attention set.", input.user));
+    }
     try {
       permissionBackend
           .absentUser(attentionUserId)
@@ -84,8 +91,11 @@
     try (BatchUpdate bu =
         updateFactory.create(
             changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
-      AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason);
+      AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason, true);
       bu.addOp(changeResource.getId(), op);
+      NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
+      NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
+      bu.setNotify(notifyResult);
       bu.execute();
       return Response.ok(accountLoaderFactory.create(true).fillOne(attentionUserId));
     }
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 03898b1..20c4b48 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
@@ -63,8 +64,8 @@
     return this;
   }
 
-  public CommentFormatter newCommentFormatter() {
-    return new CommentFormatter();
+  public HumanCommentFormatter newHumanCommentFormatter() {
+    return new HumanCommentFormatter();
   }
 
   public RobotCommentFormatter newRobotCommentFormatter() {
@@ -146,6 +147,7 @@
       if (loader != null) {
         r.author = loader.get(c.author.getId());
       }
+      r.commitId = c.getCommitId().getName();
     }
 
     protected Range toRange(Comment.Range commentRange) {
@@ -161,15 +163,15 @@
     }
   }
 
-  public class CommentFormatter extends BaseCommentFormatter<Comment, CommentInfo> {
+  public class HumanCommentFormatter extends BaseCommentFormatter<HumanComment, CommentInfo> {
     @Override
-    protected CommentInfo toInfo(Comment c, AccountLoader loader) {
+    protected CommentInfo toInfo(HumanComment c, AccountLoader loader) {
       CommentInfo ci = new CommentInfo();
       fillCommentInfo(c, ci, loader);
       return ci;
     }
 
-    private CommentFormatter() {}
+    private HumanCommentFormatter() {}
   }
 
   class RobotCommentFormatter extends BaseCommentFormatter<RobotComment, RobotCommentInfo> {
diff --git a/java/com/google/gerrit/server/restapi/change/Comments.java b/java/com/google/gerrit/server/restapi/change/Comments.java
index 078c239..00566f3 100644
--- a/java/com/google/gerrit/server/restapi/change/Comments.java
+++ b/java/com/google/gerrit/server/restapi/change/Comments.java
@@ -14,28 +14,28 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.HumanCommentResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-public class Comments implements ChildCollection<RevisionResource, CommentResource> {
-  private final DynamicMap<RestView<CommentResource>> views;
+public class Comments implements ChildCollection<RevisionResource, HumanCommentResource> {
+  private final DynamicMap<RestView<HumanCommentResource>> views;
   private final ListRevisionComments list;
   private final CommentsUtil commentsUtil;
 
   @Inject
   Comments(
-      DynamicMap<RestView<CommentResource>> views,
+      DynamicMap<RestView<HumanCommentResource>> views,
       ListRevisionComments list,
       CommentsUtil commentsUtil) {
     this.views = views;
@@ -44,7 +44,7 @@
   }
 
   @Override
-  public DynamicMap<RestView<CommentResource>> views() {
+  public DynamicMap<RestView<HumanCommentResource>> views() {
     return views;
   }
 
@@ -54,13 +54,14 @@
   }
 
   @Override
-  public CommentResource parse(RevisionResource rev, IdString id) throws ResourceNotFoundException {
+  public HumanCommentResource parse(RevisionResource rev, IdString id)
+      throws ResourceNotFoundException {
     String uuid = id.get();
     ChangeNotes notes = rev.getNotes();
 
-    for (Comment c : commentsUtil.publishedByPatchSet(notes, rev.getPatchSet().id())) {
+    for (HumanComment c : commentsUtil.publishedByPatchSet(notes, rev.getPatchSet().id())) {
       if (uuid.equals(c.key.uuid)) {
-        return new CommentResource(rev, c);
+        return new HumanCommentResource(rev, c);
       }
     }
     throw new ResourceNotFoundException(id);
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 9e792d0..52887e0 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -176,16 +176,28 @@
   public Response<ChangeInfo> apply(TopLevelResource parent, ChangeInput input)
       throws IOException, InvalidChangeOperationException, RestApiException, UpdateException,
           PermissionBackendException, ConfigInvalidException {
+    if (Strings.isNullOrEmpty(input.project)) {
+      throw new BadRequestException("project must be non-empty");
+    }
+
+    return execute(updateFactory, input, projectsCollection.parse(input.project));
+  }
+
+  /** Creates the changes in the given project. This is public for reuse in the project API. */
+  public Response<ChangeInfo> execute(
+      BatchUpdate.Factory updateFactory, ChangeInput input, ProjectResource projectResource)
+      throws IOException, RestApiException, UpdateException, PermissionBackendException,
+          ConfigInvalidException {
     if (!user.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-    IdentifiedUser me = user.get().asIdentifiedUser();
-    checkAndSanitizeChangeInput(input, me);
 
-    ProjectResource projectResource = projectsCollection.parse(input.project);
     ProjectState projectState = projectResource.getProjectState();
     projectState.checkStatePermitsWrite();
 
+    IdentifiedUser me = user.get().asIdentifiedUser();
+    checkAndSanitizeChangeInput(input, me);
+
     Project.NameKey project = projectResource.getNameKey();
     contributorAgreements.check(project, user.get());
 
@@ -207,10 +219,6 @@
    */
   private void checkAndSanitizeChangeInput(ChangeInput input, IdentifiedUser me)
       throws RestApiException, PermissionBackendException, IOException {
-    if (Strings.isNullOrEmpty(input.project)) {
-      throw new BadRequestException("project must be non-empty");
-    }
-
     if (Strings.isNullOrEmpty(input.branch)) {
       throw new BadRequestException("branch must be non-empty");
     }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index 5b7245d..d99d7014 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -73,6 +74,9 @@
       throw new BadRequestException("path must be non-empty");
     } else if (in.message == null || in.message.trim().isEmpty()) {
       throw new BadRequestException("message must be non-empty");
+    } else if (in.path.equals(PATCHSET_LEVEL)
+        && (in.side != null || in.range != null || in.line != null)) {
+      throw new BadRequestException("patchset-level comments can't have side, range, or line");
     } else if (in.line != null && in.line < 0) {
       throw new BadRequestException("line must be >= 0");
     } else if (in.line != null && in.range != null && in.line != in.range.endLine) {
@@ -85,7 +89,7 @@
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
       return Response.created(
-          commentJson.get().setFillAccounts(false).newCommentFormatter().format(op.comment));
+          commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
     }
   }
 
@@ -93,7 +97,7 @@
     private final PatchSet.Id psId;
     private final DraftInput in;
 
-    private Comment comment;
+    private HumanComment comment;
 
     private Op(PatchSet.Id psId, DraftInput in) {
       this.psId = psId;
@@ -111,15 +115,15 @@
       String parentUuid = Url.decode(in.inReplyTo);
 
       comment =
-          commentsUtil.newComment(
+          commentsUtil.newHumanComment(
               ctx, in.path, ps.id(), in.side(), in.message.trim(), in.unresolved, parentUuid);
       comment.setLineNbrAndRange(in.line, in.range);
       comment.tag = in.tag;
 
       setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
 
-      commentsUtil.putComments(
-          ctx.getUpdate(psId), Comment.Status.DRAFT, Collections.singleton(comment));
+      commentsUtil.putHumanComments(
+          ctx.getUpdate(psId), HumanComment.Status.DRAFT, Collections.singleton(comment));
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index f915728..8580229 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -26,7 +26,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.HumanCommentResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -45,7 +45,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class DeleteComment implements RestModifyView<CommentResource, DeleteCommentInput> {
+public class DeleteComment implements RestModifyView<HumanCommentResource, DeleteCommentInput> {
 
   private final Provider<CurrentUser> userProvider;
   private final PermissionBackend permissionBackend;
@@ -71,7 +71,7 @@
   }
 
   @Override
-  public Response<CommentInfo> apply(CommentResource rsrc, DeleteCommentInput input)
+  public Response<CommentInfo> apply(HumanCommentResource rsrc, DeleteCommentInput input)
       throws RestApiException, IOException, ConfigInvalidException, PermissionBackendException,
           UpdateException {
     CurrentUser user = userProvider.get();
@@ -90,15 +90,15 @@
 
     ChangeNotes updatedNotes =
         notesFactory.createChecked(rsrc.getRevisionResource().getChange().getId());
-    List<Comment> changeComments = commentsUtil.publishedByChange(updatedNotes);
-    Optional<Comment> updatedComment =
+    List<HumanComment> changeComments = commentsUtil.publishedHumanCommentsByChange(updatedNotes);
+    Optional<HumanComment> updatedComment =
         changeComments.stream().filter(c -> c.key.equals(rsrc.getComment().key)).findFirst();
     if (!updatedComment.isPresent()) {
       // This should not happen as this endpoint should not remove the whole comment.
       throw new ResourceNotFoundException("comment not found: " + rsrc.getComment().key);
     }
 
-    return Response.ok(commentJson.get().newCommentFormatter().format(updatedComment.get()));
+    return Response.ok(commentJson.get().newHumanCommentFormatter().format(updatedComment.get()));
   }
 
   private static String getCommentNewMessage(String name, String reason) {
@@ -110,10 +110,10 @@
   }
 
   private class DeleteCommentOp implements BatchUpdateOp {
-    private final CommentResource rsrc;
+    private final HumanCommentResource rsrc;
     private final String newMessage;
 
-    DeleteCommentOp(CommentResource rsrc, String newMessage) {
+    DeleteCommentOp(HumanCommentResource rsrc, String newMessage) {
       this.rsrc = rsrc;
       this.newMessage = newMessage;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index 89fc3b7..71fd4d2 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -80,7 +81,7 @@
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws ResourceNotFoundException, PatchListNotAvailableException {
-      Optional<Comment> maybeComment =
+      Optional<HumanComment> maybeComment =
           commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
         return false; // Nothing to do.
@@ -90,9 +91,9 @@
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
-      Comment c = maybeComment.get();
+      HumanComment c = maybeComment.get();
       setCommentCommitId(c, patchListCache, ctx.getChange(), ps);
-      commentsUtil.deleteComments(ctx.getUpdate(psId), Collections.singleton(c));
+      commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 4c39763..4b813df 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -19,10 +19,10 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
@@ -44,6 +44,7 @@
 import com.google.gerrit.server.change.VoteResource;
 import com.google.gerrit.server.extensions.events.VoteDeleted;
 import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -77,6 +78,7 @@
   private final NotifyResolver notifyResolver;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   DeleteVote(
@@ -89,7 +91,8 @@
       DeleteVoteSender.Factory deleteVoteSenderFactory,
       NotifyResolver notifyResolver,
       RemoveReviewerControl removeReviewerControl,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      MessageIdGenerator messageIdGenerator) {
     this.updateFactory = updateFactory;
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
@@ -100,6 +103,7 @@
     this.notifyResolver = notifyResolver;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   @Override
@@ -225,11 +229,14 @@
       try {
         NotifyResolver.Result notify = ctx.getNotify(change.getId());
         if (notify.shouldNotify()) {
-          ReplyToChangeSender cm = deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
-          cm.setFrom(user.getAccountId());
-          cm.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
-          cm.setNotify(notify);
-          cm.send();
+          ReplyToChangeSender emailSender =
+              deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
+          emailSender.setFrom(user.getAccountId());
+          emailSender.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+          emailSender.setNotify(notify);
+          emailSender.setMessageId(
+              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+          emailSender.send();
         }
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
diff --git a/java/com/google/gerrit/server/restapi/change/DraftComments.java b/java/com/google/gerrit/server/restapi/change/DraftComments.java
index bab1ac9..ab5b9f4 100644
--- a/java/com/google/gerrit/server/restapi/change/DraftComments.java
+++ b/java/com/google/gerrit/server/restapi/change/DraftComments.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -64,7 +64,7 @@
       throws ResourceNotFoundException, AuthException {
     checkIdentifiedUser();
     String uuid = id.get();
-    for (Comment c :
+    for (HumanComment c :
         commentsUtil.draftByPatchSetAuthor(
             rev.getPatchSet().id(), rev.getAccountId(), rev.getNotes())) {
       if (uuid.equals(c.key.uuid)) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java b/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java
index 08a963b..6822d91 100644
--- a/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java
@@ -18,7 +18,7 @@
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.extensions.common.AttentionSetEntry;
+import com.google.gerrit.extensions.common.AttentionSetInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.account.AccountLoader;
@@ -41,15 +41,15 @@
   }
 
   @Override
-  public Response<Set<AttentionSetEntry>> apply(ChangeResource changeResource)
+  public Response<Set<AttentionSetInfo>> apply(ChangeResource changeResource)
       throws PermissionBackendException {
     AccountLoader accountLoader = accountLoaderFactory.create(true);
-    ImmutableSet<AttentionSetEntry> response =
+    ImmutableSet<AttentionSetInfo> response =
         // This filtering should match ChangeJson.
         additionsOnly(changeResource.getNotes().getAttentionSet()).stream()
             .map(
                 a ->
-                    new AttentionSetEntry(
+                    new AttentionSetInfo(
                         accountLoader.get(a.account()), Timestamp.from(a.timestamp()), a.reason()))
             .collect(toImmutableSet());
     accountLoader.fill();
diff --git a/java/com/google/gerrit/server/restapi/change/GetComment.java b/java/com/google/gerrit/server/restapi/change/GetComment.java
index 5103325..24085df 100644
--- a/java/com/google/gerrit/server/restapi/change/GetComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetComment.java
@@ -17,14 +17,14 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.HumanCommentResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 @Singleton
-public class GetComment implements RestReadView<CommentResource> {
+public class GetComment implements RestReadView<HumanCommentResource> {
 
   private final Provider<CommentJson> commentJson;
 
@@ -34,7 +34,7 @@
   }
 
   @Override
-  public Response<CommentInfo> apply(CommentResource rsrc) throws PermissionBackendException {
-    return Response.ok(commentJson.get().newCommentFormatter().format(rsrc.getComment()));
+  public Response<CommentInfo> apply(HumanCommentResource rsrc) throws PermissionBackendException {
+    return Response.ok(commentJson.get().newHumanCommentFormatter().format(rsrc.getComment()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDraftComment.java b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
index 797dc9e..ba07b47 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDraftComment.java
@@ -35,6 +35,6 @@
 
   @Override
   public Response<CommentInfo> apply(DraftCommentResource rsrc) throws PermissionBackendException {
-    return Response.ok(commentJson.get().newCommentFormatter().format(rsrc.getComment()));
+    return Response.ok(commentJson.get().newHumanCommentFormatter().format(rsrc.getComment()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index e544509..b842f55 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.change.CommentJson.CommentFormatter;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -64,30 +63,30 @@
     return getAsList(listComments(rsrc), rsrc);
   }
 
-  private Iterable<Comment> listComments(ChangeResource rsrc) {
+  private Iterable<HumanComment> listComments(ChangeResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    return commentsUtil.publishedByChange(cd.notes());
+    return commentsUtil.publishedHumanCommentsByChange(cd.notes());
   }
 
-  private ImmutableList<CommentInfo> getAsList(Iterable<Comment> comments, ChangeResource rsrc)
+  private ImmutableList<CommentInfo> getAsList(Iterable<HumanComment> comments, ChangeResource rsrc)
       throws PermissionBackendException {
     ImmutableList<CommentInfo> commentInfos = getCommentFormatter().formatAsList(comments);
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
-    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, true);
     return commentInfos;
   }
 
-  private Map<String, List<CommentInfo>> getAsMap(Iterable<Comment> comments, ChangeResource rsrc)
-      throws PermissionBackendException {
+  private Map<String, List<CommentInfo>> getAsMap(
+      Iterable<HumanComment> comments, ChangeResource rsrc) throws PermissionBackendException {
     Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter().format(comments);
     List<CommentInfo> commentInfos =
         commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
-    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, true);
     return commentInfosMap;
   }
 
-  private CommentFormatter getCommentFormatter() {
-    return commentJson.get().setFillAccounts(true).setFillPatchSet(true).newCommentFormatter();
+  private CommentJson.HumanCommentFormatter getCommentFormatter() {
+    return commentJson.get().setFillAccounts(true).setFillPatchSet(true).newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index 24e1d40..3841dc1 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -23,7 +23,7 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.restapi.change.CommentJson.CommentFormatter;
+import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -46,7 +46,7 @@
     this.commentsUtil = commentsUtil;
   }
 
-  private Iterable<Comment> listComments(ChangeResource rsrc) {
+  private Iterable<HumanComment> listComments(ChangeResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     return commentsUtil.draftByChangeAuthor(cd.notes(), rsrc.getUser().getAccountId());
   }
@@ -68,7 +68,11 @@
     return getCommentFormatter().formatAsList(listComments(rsrc));
   }
 
-  private CommentFormatter getCommentFormatter() {
-    return commentJson.get().setFillAccounts(false).setFillPatchSet(true).newCommentFormatter();
+  private HumanCommentFormatter getCommentFormatter() {
+    return commentJson
+        .get()
+        .setFillAccounts(false)
+        .setFillPatchSet(true)
+        .newHumanCommentFormatter();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
index 0ed7d60..d841183 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeRobotComments.java
@@ -63,7 +63,7 @@
     List<RobotCommentInfo> commentInfos =
         robotCommentsMap.values().stream().flatMap(List::stream).collect(toList());
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
-    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, false);
     return Response.ok(robotCommentsMap);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListReviewers.java b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
index 25ef480..3d07d43 100644
--- a/java/com/google/gerrit/server/restapi/change/ListReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerJson;
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
index de05d2a..88309ed 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -35,7 +35,7 @@
   }
 
   @Override
-  protected Iterable<Comment> listComments(RevisionResource rsrc) {
+  protected Iterable<HumanComment> listComments(RevisionResource rsrc) {
     ChangeNotes notes = rsrc.getNotes();
     return commentsUtil.publishedByPatchSet(notes, rsrc.getPatchSet().id());
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
index 199a752..a5fbd92 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -39,7 +39,7 @@
     this.commentsUtil = commentsUtil;
   }
 
-  protected Iterable<Comment> listComments(RevisionResource rsrc) {
+  protected Iterable<HumanComment> listComments(RevisionResource rsrc) {
     return commentsUtil.draftByPatchSetAuthor(
         rsrc.getPatchSet().id(), rsrc.getAccountId(), rsrc.getNotes());
   }
@@ -55,7 +55,7 @@
         commentJson
             .get()
             .setFillAccounts(includeAuthorInfo())
-            .newCommentFormatter()
+            .newHumanCommentFormatter()
             .format(listComments(rsrc)));
   }
 
@@ -64,7 +64,7 @@
     return commentJson
         .get()
         .setFillAccounts(includeAuthorInfo())
-        .newCommentFormatter()
+        .newHumanCommentFormatter()
         .formatAsList(listComments(rsrc));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
index 73b1f59..b44f637 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
diff --git a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
index 742eaca..25f4005 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRobotComments.java
@@ -64,7 +64,7 @@
       Iterable<RobotComment> comments, RevisionResource rsrc) throws PermissionBackendException {
     ImmutableList<RobotCommentInfo> commentInfos = getCommentFormatter().formatAsList(comments);
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
-    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, false);
     return commentInfos;
   }
 
@@ -74,7 +74,7 @@
     List<RobotCommentInfo> commentInfos =
         commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
     List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
-    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages);
+    CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, false);
     return commentInfosMap;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index b84b5e3..7683ab7 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -16,9 +16,10 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
-import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.BranchOrderSection;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -29,7 +30,6 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.index.change.ChangeIndexer;
@@ -41,8 +41,10 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -115,11 +117,12 @@
 
       if (otherBranches) {
         result.mergeableInto = new ArrayList<>();
-        BranchOrderSection branchOrder = projectState.getBranchOrderSection();
-        if (branchOrder != null) {
+        Optional<BranchOrderSection> branchOrder = projectState.getBranchOrderSection();
+        if (branchOrder.isPresent()) {
           int prefixLen = Constants.R_HEADS.length();
-          String[] names = branchOrder.getMoreStable(ref.getName());
-          Map<String, Ref> refs = git.getRefDatabase().exactRef(names);
+          List<String> names = branchOrder.get().getMoreStable(ref.getName());
+          Map<String, Ref> refs =
+              git.getRefDatabase().exactRef(names.toArray(new String[names.size()]));
           for (String n : names) {
             Ref other = refs.get(n);
             if (other == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 387d0a8..466ea3c 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -18,10 +18,10 @@
 import static com.google.gerrit.server.change.ChangeEditResource.CHANGE_EDIT_KIND;
 import static com.google.gerrit.server.change.ChangeMessageResource.CHANGE_MESSAGE_KIND;
 import static com.google.gerrit.server.change.ChangeResource.CHANGE_KIND;
-import static com.google.gerrit.server.change.CommentResource.COMMENT_KIND;
 import static com.google.gerrit.server.change.DraftCommentResource.DRAFT_COMMENT_KIND;
 import static com.google.gerrit.server.change.FileResource.FILE_KIND;
 import static com.google.gerrit.server.change.FixResource.FIX_KIND;
+import static com.google.gerrit.server.change.HumanCommentResource.COMMENT_KIND;
 import static com.google.gerrit.server.change.ReviewerResource.REVIEWER_KIND;
 import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
 import static com.google.gerrit.server.change.RobotCommentResource.ROBOT_COMMENT_KIND;
@@ -49,6 +49,7 @@
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Reviewed.DeleteReviewed;
 import com.google.gerrit.server.restapi.change.Reviewed.PutReviewed;
+import com.google.gerrit.server.util.AttentionSetEmail;
 
 public class Module extends RestApiModule {
   @Override
@@ -217,5 +218,6 @@
     factory(SetTopicOp.Factory.class);
     factory(AddToAttentionSetOp.Factory.class);
     factory(RemoveFromAttentionSetOp.Factory.class);
+    factory(AttentionSetEmail.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index c109cbf..577174f 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -23,10 +23,10 @@
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 7008bb9..3b986ea 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
@@ -40,19 +41,21 @@
 import com.google.common.hash.HashCode;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
-import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -79,7 +82,6 @@
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidationFailure;
 import com.google.gerrit.extensions.validators.CommentValidator;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
@@ -176,6 +178,7 @@
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
   private final PluginSetContext<CommentValidator> commentValidators;
+  private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final boolean strictLabels;
 
   @Inject
@@ -199,7 +202,8 @@
       WorkInProgressOp.Factory workInProgressOpFactory,
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
-      PluginSetContext<CommentValidator> commentValidators) {
+      PluginSetContext<CommentValidator> commentValidators,
+      ReplyAttentionSetUpdates replyAttentionSetUpdates) {
     this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
@@ -219,6 +223,7 @@
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.commentValidators = commentValidators;
+    this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
   }
 
@@ -377,6 +382,9 @@
       NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
       bu.setNotify(notify);
 
+      // Adjust the attention set based on the input
+      replyAttentionSetUpdates.updateAttentionSet(
+          bu, revision.getNotes(), input, reviewerResults, revision.getAccountId());
       bu.execute();
 
       // Re-read change to take into account results of the update.
@@ -606,6 +614,7 @@
         ensureLineIsNonNegative(comment.line, path);
         ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
         ensureRangeIsValid(path, comment.range);
+        ensureValidPatchsetLevelComment(path, comment);
       }
     }
   }
@@ -644,6 +653,14 @@
     }
   }
 
+  private static <T extends CommentInput> void ensureValidPatchsetLevelComment(
+      String path, T comment) throws BadRequestException {
+    if (path.equals(PATCHSET_LEVEL)
+        && (comment.side != null || comment.range != null || comment.line != null)) {
+      throw new BadRequestException("Patchset-level comments can't have side, range, or line");
+    }
+  }
+
   private void checkRobotComments(
       RevisionResource revision, Map<String, List<RobotCommentInput>> in)
       throws BadRequestException, PatchListNotAvailableException {
@@ -703,7 +720,7 @@
     ensureReplacementsArePresent(commentPath, fixReplacementInfos);
 
     for (FixReplacementInfo fixReplacementInfo : fixReplacementInfos) {
-      ensureReplacementPathIsSet(commentPath, fixReplacementInfo.path);
+      ensureReplacementPathIsSetAndNotPatchsetLevel(commentPath, fixReplacementInfo.path);
       ensureRangeIsSet(commentPath, fixReplacementInfo.range);
       ensureRangeIsValid(commentPath, fixReplacementInfo.range);
       ensureReplacementStringIsSet(commentPath, fixReplacementInfo.replacement);
@@ -727,14 +744,20 @@
     }
   }
 
-  private static void ensureReplacementPathIsSet(String commentPath, String replacementPath)
-      throws BadRequestException {
+  private static void ensureReplacementPathIsSetAndNotPatchsetLevel(
+      String commentPath, String replacementPath) throws BadRequestException {
     if (replacementPath == null) {
       throw new BadRequestException(
           String.format(
               "A file path must be given for the replacement of the robot comment on %s",
               commentPath));
     }
+    if (replacementPath.equals(PATCHSET_LEVEL)) {
+      throw new BadRequestException(
+          String.format(
+              "A file path must not be %s for the replacement of the robot comment on %s",
+              PATCHSET_LEVEL, commentPath));
+    }
   }
 
   private static void ensureRangeIsSet(String commentPath, Range range) throws BadRequestException {
@@ -795,8 +818,8 @@
   }
 
   /**
-   * Used to compare existing {@link Comment}-s with {@link CommentInput} comments by copying only
-   * the fields to compare.
+   * Used to compare existing {@link HumanComment}-s with {@link CommentInput} comments by copying
+   * only the fields to compare.
    */
   @AutoValue
   abstract static class CommentSetEntry {
@@ -888,9 +911,23 @@
       }
       NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
       if (notify.shouldNotify()) {
-        email
-            .create(notify, notes, ps, user, message, comments, in.message, labelDelta)
-            .sendAsync();
+        try {
+          email
+              .create(
+                  notify,
+                  notes,
+                  ps,
+                  user,
+                  message,
+                  comments,
+                  in.message,
+                  labelDelta,
+                  ctx.getRepoView())
+              .sendAsync();
+        } catch (IOException ex) {
+          throw new StorageException(
+              String.format("Repository %s not found", ctx.getProject().get()), ex);
+        }
       }
       commentAdded.fire(
           notes.getChange(),
@@ -912,7 +949,7 @@
 
       // HashMap instead of Collections.emptyMap() avoids warning about remove() on immutable
       // object.
-      Map<String, Comment> drafts = new HashMap<>();
+      Map<String, HumanComment> drafts = new HashMap<>();
       // If there are inputComments we need the deduplication loop below, so we have to read (and
       // publish) drafts here.
       if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
@@ -924,7 +961,7 @@
       }
 
       // This will be populated with Comment-s created from inputComments.
-      List<Comment> toPublish = new ArrayList<>();
+      List<HumanComment> toPublish = new ArrayList<>();
 
       Set<CommentSetEntry> existingComments =
           in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
@@ -935,11 +972,11 @@
       for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
         String path = entry.getKey();
         for (CommentInput inputComment : entry.getValue()) {
-          Comment comment = drafts.remove(Url.decode(inputComment.id));
+          HumanComment comment = drafts.remove(Url.decode(inputComment.id));
           if (comment == null) {
             String parent = Url.decode(inputComment.inReplyTo);
             comment =
-                commentsUtil.newComment(
+                commentsUtil.newHumanComment(
                     ctx,
                     path,
                     psId,
@@ -984,7 +1021,7 @@
           break;
       }
       ChangeUpdate changeUpdate = ctx.getUpdate(psId);
-      commentsUtil.putComments(changeUpdate, Comment.Status.PUBLISHED, toPublish);
+      commentsUtil.putHumanComments(changeUpdate, HumanComment.Status.PUBLISHED, toPublish);
       comments.addAll(toPublish);
       return !toPublish.isEmpty();
     }
@@ -1104,7 +1141,7 @@
     }
 
     private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
-      return commentsUtil.publishedByChange(ctx.getNotes()).stream()
+      return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
           .map(CommentSetEntry::create)
           .collect(toSet());
     }
@@ -1115,7 +1152,7 @@
           .collect(toSet());
     }
 
-    private Map<String, Comment> changeDrafts(ChangeContext ctx) {
+    private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
       return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
           .collect(
               Collectors.toMap(
@@ -1126,7 +1163,7 @@
                   }));
     }
 
-    private Map<String, Comment> patchSetDrafts(ChangeContext ctx) {
+    private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
       return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
           .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
     }
@@ -1255,8 +1292,6 @@
         return false;
       }
 
-      forceCallerAsReviewer(projectState, ctx, current, ups, del);
-
       return !del.isEmpty() || !ups.isEmpty();
     }
 
@@ -1285,7 +1320,7 @@
       for (PatchSetApproval psa : del) {
         LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
         String normName = lt.getName();
-        if (!lt.allowPostSubmit()) {
+        if (!lt.isAllowPostSubmit()) {
           disallowed.add(normName);
         }
         Short prev = previous.get(normName);
@@ -1297,7 +1332,7 @@
       for (PatchSetApproval psa : ups) {
         LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
         String normName = lt.getName();
-        if (!lt.allowPostSubmit()) {
+        if (!lt.isAllowPostSubmit()) {
           disallowed.add(normName);
         }
         Short prev = previous.get(normName);
@@ -1327,41 +1362,6 @@
       }
     }
 
-    private void forceCallerAsReviewer(
-        ProjectState projectState,
-        ChangeContext ctx,
-        Map<String, PatchSetApproval> current,
-        List<PatchSetApproval> ups,
-        List<PatchSetApproval> del) {
-      if (current.isEmpty() && ups.isEmpty()) {
-        // TODO Find another way to link reviewers to changes.
-        if (del.isEmpty()) {
-          // If no existing label is being set to 0, hack in the caller
-          // as a reviewer by picking the first server-wide LabelType.
-          List<LabelType> labelTypes = projectState.getLabelTypes(ctx.getNotes()).getLabelTypes();
-          if (labelTypes.isEmpty()) {
-            logger.atWarning().log(
-                "no label type found for project %s, change %s",
-                projectState.getName(), ctx.getChange().getChangeId());
-            return;
-          }
-
-          LabelId labelId = labelTypes.get(0).getLabelId();
-          ups.add(
-              ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen())
-                  .tag(Optional.ofNullable(in.tag))
-                  .granted(ctx.getWhen())
-                  .build());
-        } else {
-          // Pick a random label that is about to be deleted and keep it.
-          Iterator<PatchSetApproval> i = del.iterator();
-          ups.add(i.next().toBuilder().value(0).granted(ctx.getWhen()).build());
-          i.remove();
-        }
-      }
-      ctx.getUpdate(ctx.getChange().currentPatchSetId()).putReviewer(user.getAccountId(), REVIEWER);
-    }
-
     private Map<String, PatchSetApproval> scanLabels(
         ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del)
         throws IOException {
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index 63cd7a3..f327f16 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
 
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -79,6 +81,9 @@
       throw new BadRequestException("id must match URL");
     } else if (in.line != null && in.line < 0) {
       throw new BadRequestException("line must be >= 0");
+    } else if (in.path.equals(PATCHSET_LEVEL)
+        && (in.side != null || in.range != null || in.line != null)) {
+      throw new BadRequestException("patchset-level comments can't have side, range, or line");
     } else if (in.line != null && in.range != null && in.line != in.range.endLine) {
       throw new BadRequestException("range endLine must be on the same line as the comment");
     }
@@ -89,7 +94,7 @@
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
       return Response.ok(
-          commentJson.get().setFillAccounts(false).newCommentFormatter().format(op.comment));
+          commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
     }
   }
 
@@ -97,7 +102,7 @@
     private final Comment.Key key;
     private final DraftInput in;
 
-    private Comment comment;
+    private HumanComment comment;
 
     private Op(Comment.Key key, DraftInput in) {
       this.key = key;
@@ -107,15 +112,15 @@
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws ResourceNotFoundException, PatchListNotAvailableException {
-      Optional<Comment> maybeComment =
+      Optional<HumanComment> maybeComment =
           commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
         // Disappeared out from under us. Can't easily fall back to insert,
         // because the input might be missing required fields. Just give up.
         throw new ResourceNotFoundException("comment not found: " + key);
       }
-      Comment origComment = maybeComment.get();
-      comment = new Comment(origComment);
+      HumanComment origComment = maybeComment.get();
+      comment = new HumanComment(origComment);
       // Copy constructor preserved old real author; replace with current real
       // user.
       ctx.getUser().updateRealAccountId(comment::setRealAuthor);
@@ -131,17 +136,19 @@
         // Updating the path alters the primary key, which isn't possible.
         // Delete then recreate the comment instead of an update.
 
-        commentsUtil.deleteComments(update, Collections.singleton(origComment));
+        commentsUtil.deleteHumanComments(update, Collections.singleton(origComment));
         comment.key.filename = in.path;
       }
       setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
-      commentsUtil.putComments(
-          update, Comment.Status.DRAFT, Collections.singleton(update(comment, in, ctx.getWhen())));
+      commentsUtil.putHumanComments(
+          update,
+          HumanComment.Status.DRAFT,
+          Collections.singleton(update(comment, in, ctx.getWhen())));
       return true;
     }
   }
 
-  private static Comment update(Comment e, DraftInput in, Timestamp when) {
+  private static HumanComment update(HumanComment e, DraftInput in, Timestamp when) {
     if (in.side != null) {
       e.side = in.side();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
index ccf375a..c4dd04e 100644
--- a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -15,13 +15,17 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.AttentionSetEntryResource;
 import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -33,20 +37,27 @@
 
 /** Removes a single user from the attention set. */
 public class RemoveFromAttentionSet
-    implements RestModifyView<AttentionSetEntryResource, RemoveFromAttentionSetInput> {
+    implements RestModifyView<AttentionSetEntryResource, AttentionSetInput> {
   private final BatchUpdate.Factory updateFactory;
   private final RemoveFromAttentionSetOp.Factory opFactory;
+  private final AccountResolver accountResolver;
+  private final NotifyResolver notifyResolver;
 
   @Inject
   RemoveFromAttentionSet(
-      BatchUpdate.Factory updateFactory, RemoveFromAttentionSetOp.Factory opFactory) {
+      BatchUpdate.Factory updateFactory,
+      RemoveFromAttentionSetOp.Factory opFactory,
+      AccountResolver accountResolver,
+      NotifyResolver notifyResolver) {
     this.updateFactory = updateFactory;
     this.opFactory = opFactory;
+    this.accountResolver = accountResolver;
+    this.notifyResolver = notifyResolver;
   }
 
   @Override
   public Response<Object> apply(
-      AttentionSetEntryResource attentionResource, RemoveFromAttentionSetInput input)
+      AttentionSetEntryResource attentionResource, AttentionSetInput input)
       throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException,
           UpdateException {
     if (input == null) {
@@ -56,13 +67,30 @@
     if (input.reason.isEmpty()) {
       throw new BadRequestException("missing field: reason");
     }
+    input.user = Strings.nullToEmpty(input.user).trim();
+    if (!input.user.isEmpty()) {
+      Account.Id attentionUserId = null;
+      try {
+        attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
+      } catch (AccountResolver.UnresolvableAccountException ex) {
+        throw new BadRequestException(
+            "The user specified in the input body couldn't be found.", ex);
+      }
+      if (attentionUserId.get() != attentionResource.getAccountId().get()) {
+        throw new BadRequestException(
+            "The field \"user\" must be empty, or must match the user specified in the URL.");
+      }
+    }
     ChangeResource changeResource = attentionResource.getChangeResource();
     try (BatchUpdate bu =
         updateFactory.create(
             changeResource.getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
       RemoveFromAttentionSetOp op =
-          opFactory.create(attentionResource.getAccountId(), input.reason);
+          opFactory.create(attentionResource.getAccountId(), input.reason, true);
       bu.addOp(changeResource.getId(), op);
+      NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
+      NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
+      bu.setNotify(notifyResult);
       bu.execute();
     }
     return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
new file mode 100644
index 0000000..1a6e3b7
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -0,0 +1,325 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.RobotClassifier;
+import com.google.gerrit.server.change.AddToAttentionSetOp;
+import com.google.gerrit.server.change.AttentionSetUnchangedOp;
+import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
+import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.AttentionSetUtil;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * This class is used to update the attention set when performing a review or replying on a change.
+ */
+public class ReplyAttentionSetUpdates {
+
+  private final PermissionBackend permissionBackend;
+  private final AddToAttentionSetOp.Factory addToAttentionSetOpFactory;
+  private final RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory;
+  private final ApprovalsUtil approvalsUtil;
+  private final AccountResolver accountResolver;
+  private final RobotClassifier robotClassifier;
+
+  @Inject
+  ReplyAttentionSetUpdates(
+      PermissionBackend permissionBackend,
+      AddToAttentionSetOp.Factory addToAttentionSetOpFactory,
+      RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory,
+      ApprovalsUtil approvalsUtil,
+      AccountResolver accountResolver,
+      RobotClassifier robotClassifier) {
+    this.permissionBackend = permissionBackend;
+    this.addToAttentionSetOpFactory = addToAttentionSetOpFactory;
+    this.removeFromAttentionSetOpFactory = removeFromAttentionSetOpFactory;
+    this.approvalsUtil = approvalsUtil;
+    this.accountResolver = accountResolver;
+    this.robotClassifier = robotClassifier;
+  }
+
+  /** Adjusts the attention set but only based on the automatic rules. */
+  public void processAutomaticAttentionSetRulesOnReply(
+      BatchUpdate bu,
+      ChangeNotes changeNotes,
+      boolean readyForReview,
+      Set<String> potentiallyRemovedReviewers,
+      Account.Id currentUser)
+      throws IOException, ConfigInvalidException, PermissionBackendException,
+          UnprocessableEntityException {
+    if (robotClassifier.isRobot(currentUser)) {
+      return;
+    }
+    Set<Account.Id> potentiallyRemovedReviewerIds = new HashSet<>();
+    for (String reviewer : potentiallyRemovedReviewers) {
+      potentiallyRemovedReviewerIds.add(getAccountId(changeNotes, reviewer));
+    }
+    processRules(
+        bu,
+        changeNotes,
+        readyForReview,
+        getUpdatedReviewers(changeNotes, potentiallyRemovedReviewerIds),
+        currentUser);
+  }
+
+  /**
+   * Adjusts the attention set by adding and removing users. If the same user should be added and
+   * removed or added/removed twice, the user will only be added/removed once, based on first
+   * addition/removal.
+   */
+  public void updateAttentionSet(
+      BatchUpdate bu,
+      ChangeNotes changeNotes,
+      ReviewInput input,
+      List<ReviewerAdder.ReviewerAddition> reviewerResults,
+      Account.Id currentUser)
+      throws BadRequestException, IOException, PermissionBackendException,
+          UnprocessableEntityException, ConfigInvalidException {
+    processManualUpdates(bu, changeNotes, input);
+    if (input.ignoreAutomaticAttentionSetRules) {
+
+      // If we ignore automatic attention set rules it means we need to pass this information to
+      // ChangeUpdate. Also, we should stop all other attention set updates that are part of
+      // this method and happen in PostReview.
+      bu.addOp(changeNotes.getChangeId(), new AttentionSetUnchangedOp());
+      return;
+    }
+    if (robotClassifier.isRobot(currentUser)) {
+      botsWithNegativeLabelsAddOwnerAndUploader(bu, changeNotes, input);
+      return;
+    }
+    // Gets a set of all the CCs in this change. Updated reviewers will be defined as reviewers who
+    // didn't become CC (therefore this is a set of potentially removed reviewers - those that were
+    // reviewers but became cc).
+    Set<Account.Id> potentiallyRemovedReviewers =
+        reviewerResults.stream()
+            .filter(r -> r.state() == ReviewerState.CC)
+            .map(r -> r.reviewers)
+            .flatMap(x -> x.stream())
+            .collect(toSet());
+    processRules(
+        bu,
+        changeNotes,
+        isReadyForReview(changeNotes, input),
+        getUpdatedReviewers(changeNotes, potentiallyRemovedReviewers),
+        currentUser);
+  }
+
+  private Set<Account.Id> getUpdatedReviewers(
+      ChangeNotes changeNotes, Set<Account.Id> potentiallyRemovedReviewers) {
+    // Filter by users that are currently reviewers and remove CCs.
+    return approvalsUtil.getReviewers(changeNotes).byState(REVIEWER).stream()
+        .filter(r -> !potentiallyRemovedReviewers.contains(r))
+        .collect(Collectors.toSet());
+  }
+
+  /**
+   * Process the automatic rules of the attention set. All of the automatic rules except
+   * adding/removing reviewers and entering/exiting WIP state are done here, and the rest are done
+   * in {@link ChangeUpdate}
+   */
+  private void processRules(
+      BatchUpdate bu,
+      ChangeNotes changeNotes,
+      boolean readyForReview,
+      Set<Account.Id> reviewers,
+      Account.Id currentUser) {
+    // Replying removes the publishing user from the attention set.
+    removeFromAttentionSet(bu, changeNotes, currentUser, "removed on reply", false);
+
+    // The rest of the conditions only apply if the change is ready for review
+    if (!readyForReview) {
+      return;
+    }
+    Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
+    Account.Id owner = changeNotes.getChange().getOwner();
+    if (currentUser.equals(uploader) && !uploader.equals(owner)) {
+      // When the uploader replies, add the owner to the attention set.
+      addToAttentionSet(bu, changeNotes, owner, "uploader replied", false);
+    }
+    if (currentUser.equals(uploader) || currentUser.equals(owner)) {
+      // When the owner or uploader replies, add the reviewers to the attention set.
+      for (Account.Id reviewer : reviewers) {
+        addToAttentionSet(bu, changeNotes, reviewer, "owner or uploader replied", false);
+      }
+    }
+    if (!currentUser.equals(uploader) && !currentUser.equals(owner)) {
+      // When neither the uploader nor the owner (reviewer or cc) replies, add the owner and the
+      // uploader to the attention set.
+      addToAttentionSet(bu, changeNotes, owner, "reviewer or cc replied", false);
+
+      if (owner.get() != uploader.get()) {
+        addToAttentionSet(bu, changeNotes, uploader, "reviewer or cc replied", false);
+      }
+    }
+  }
+
+  /** Process the manual updates of the attention set. */
+  private void processManualUpdates(BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input)
+      throws BadRequestException, IOException, PermissionBackendException,
+          UnprocessableEntityException, ConfigInvalidException {
+    Set<Account.Id> accountsChangedInCommit = new HashSet<>();
+    // If we specify a user to remove, and the user is in the attention set, we remove it.
+    if (input.removeFromAttentionSet != null) {
+      for (AttentionSetInput remove : input.removeFromAttentionSet) {
+        removeFromAttentionSet(bu, changeNotes, remove, accountsChangedInCommit);
+      }
+    }
+
+    // If we don't specify a user to remove, but we specify addition for that user, the user will be
+    // added if they are not in the attention set yet.
+    if (input.addToAttentionSet != null) {
+      for (AttentionSetInput add : input.addToAttentionSet) {
+        addToAttentionSet(bu, changeNotes, add, accountsChangedInCommit);
+      }
+    }
+  }
+
+  /**
+   * Bots don't process automatic rules, but they do have one special rule: if voted negatively on a
+   * label, add the owner and uploader.
+   */
+  private void botsWithNegativeLabelsAddOwnerAndUploader(
+      BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input) {
+    if (input.labels != null && input.labels.values().stream().anyMatch(vote -> vote < 0)) {
+      Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
+      Account.Id owner = changeNotes.getChange().getOwner();
+      addToAttentionSet(bu, changeNotes, owner, "A robot voted negatively on a label", false);
+      if (!owner.equals(uploader)) {
+        addToAttentionSet(bu, changeNotes, uploader, "A robot voted negatively on a label", false);
+      }
+    }
+  }
+
+  /**
+   * Adds the user to the attention set
+   *
+   * @param bu BatchUpdate to perform the updates to the attention set
+   * @param changeNotes current change
+   * @param user user to add to the attention set
+   * @param reason reason for adding
+   * @param notify whether or not to notify about this addition
+   */
+  private void addToAttentionSet(
+      BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
+    AddToAttentionSetOp addOwnerToAttentionSet =
+        addToAttentionSetOpFactory.create(user, reason, notify);
+    bu.addOp(changeNotes.getChangeId(), addOwnerToAttentionSet);
+  }
+
+  /**
+   * Removes the user from the attention set
+   *
+   * @param bu BatchUpdate to perform the updates to the attention set.
+   * @param changeNotes current change.
+   * @param user user to add remove from the attention set.
+   * @param reason reason for removing.
+   * @param notify whether or not to notify about this removal.
+   */
+  private void removeFromAttentionSet(
+      BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
+    RemoveFromAttentionSetOp removeFromAttentionSetOp =
+        removeFromAttentionSetOpFactory.create(user, reason, notify);
+    bu.addOp(changeNotes.getChangeId(), removeFromAttentionSetOp);
+  }
+
+  private static boolean isReadyForReview(ChangeNotes changeNotes, ReviewInput input) {
+    return (!changeNotes.getChange().isWorkInProgress() && !input.workInProgress) || input.ready;
+  }
+
+  private void addToAttentionSet(
+      BatchUpdate bu,
+      ChangeNotes changeNotes,
+      AttentionSetInput add,
+      Set<Account.Id> accountsChangedInCommit)
+      throws BadRequestException, IOException, PermissionBackendException,
+          UnprocessableEntityException, ConfigInvalidException {
+    AttentionSetUtil.validateInput(add);
+    Account.Id attentionUserId =
+        getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
+
+    addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
+  }
+
+  private void removeFromAttentionSet(
+      BatchUpdate bu,
+      ChangeNotes changeNotes,
+      AttentionSetInput remove,
+      Set<Account.Id> accountsChangedInCommit)
+      throws BadRequestException, IOException, PermissionBackendException,
+          UnprocessableEntityException, ConfigInvalidException {
+    AttentionSetUtil.validateInput(remove);
+    Account.Id attentionUserId =
+        getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
+
+    removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
+  }
+
+  private Account.Id getAccountId(ChangeNotes changeNotes, String user)
+      throws ConfigInvalidException, IOException, UnprocessableEntityException,
+          PermissionBackendException {
+    Account.Id attentionUserId = accountResolver.resolve(user).asUnique().account().id();
+    try {
+      permissionBackend
+          .absentUser(attentionUserId)
+          .change(changeNotes)
+          .check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new UnprocessableEntityException(
+          "Can't add to attention set: Read not permitted for " + attentionUserId, e);
+    }
+    return attentionUserId;
+  }
+
+  private Account.Id getAccountIdAndValidateUser(
+      ChangeNotes changeNotes, String user, Set<Account.Id> accountsChangedInCommit)
+      throws ConfigInvalidException, IOException, PermissionBackendException,
+          UnprocessableEntityException, BadRequestException {
+    Account.Id attentionUserId = getAccountId(changeNotes, user);
+    if (accountsChangedInCommit.contains(attentionUserId)) {
+      throw new BadRequestException(
+          String.format(
+              "%s can not be added/removed twice, and can not be added and "
+                  + "removed at the same time",
+              user));
+    }
+    accountsChangedInCommit.add(attentionUserId);
+    return attentionUserId;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index a72192e..7faf8e0 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.extensions.events.ChangeRestored;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.mail.send.RestoredSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -65,6 +66,7 @@
   private final PatchSetUtil psUtil;
   private final ChangeRestored changeRestored;
   private final ProjectCache projectCache;
+  private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   Restore(
@@ -74,7 +76,8 @@
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
       ChangeRestored changeRestored,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      MessageIdGenerator messageIdGenerator) {
     this.updateFactory = updateFactory;
     this.restoredSenderFactory = restoredSenderFactory;
     this.json = json;
@@ -82,6 +85,7 @@
     this.psUtil = psUtil;
     this.changeRestored = changeRestored;
     this.projectCache = projectCache;
+    this.messageIdGenerator = messageIdGenerator;
   }
 
   @Override
@@ -146,10 +150,13 @@
     @Override
     public void postUpdate(Context ctx) {
       try {
-        ReplyToChangeSender cm = restoredSenderFactory.create(ctx.getProject(), change.getId());
-        cm.setFrom(ctx.getAccountId());
-        cm.setChangeMessage(message.getMessage(), ctx.getWhen());
-        cm.send();
+        ReplyToChangeSender emailSender =
+            restoredSenderFactory.create(ctx.getProject(), change.getId());
+        emailSender.setFrom(ctx.getAccountId());
+        emailSender.setChangeMessage(message.getMessage(), ctx.getWhen());
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+        emailSender.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
       }
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 88db66e..cb3a375 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -58,6 +58,7 @@
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
@@ -126,6 +127,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final GetRelated getRelated;
+  private final MessageIdGenerator messageIdGenerator;
 
   private CherryPickInput cherryPickInput;
   private List<ChangeInfo> results;
@@ -154,7 +156,8 @@
       NotifyResolver notifyResolver,
       BatchUpdate.Factory updateFactory,
       ChangeResource.Factory changeResourceFactory,
-      GetRelated getRelated) {
+      GetRelated getRelated,
+      MessageIdGenerator messageIdGenerator) {
     this.queryProvider = queryProvider;
     this.user = user;
     this.permissionBackend = permissionBackend;
@@ -175,6 +178,7 @@
     this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.getRelated = getRelated;
+    this.messageIdGenerator = messageIdGenerator;
     results = new ArrayList<>();
     cherryPickInput = null;
   }
@@ -598,10 +602,12 @@
       changeReverted.fire(
           change, changeNotesFactory.createChecked(revertChangeId).getChange(), ctx.getWhen());
       try {
-        RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), change.getId());
-        cm.setFrom(ctx.getAccountId());
-        cm.setNotify(ctx.getNotify(change.getId()));
-        cm.send();
+        RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
+        emailSender.setFrom(ctx.getAccountId());
+        emailSender.setNotify(ctx.getNotify(change.getId()));
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+        emailSender.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
             "Cannot send email for revert change %s", change.getId());
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewers.java b/java/com/google/gerrit/server/restapi/change/Reviewers.java
index d702142..4bfcf14 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewers.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeResource;
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 676cc07..2a55e41 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -23,8 +23,8 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ReviewerState;
diff --git a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
index ac0945d..2651ab5 100644
--- a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -23,7 +24,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
index e0398c7..02c2ff0 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.MoreObjects;
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
index cb52fcb..ecb455e 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.MoreObjects;
-import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
diff --git a/java/com/google/gerrit/server/restapi/config/AgreementJson.java b/java/com/google/gerrit/server/restapi/config/AgreementJson.java
index d5c085b..92dd489 100644
--- a/java/com/google/gerrit/server/restapi/config/AgreementJson.java
+++ b/java/com/google/gerrit/server/restapi/config/AgreementJson.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.restapi.config;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.AgreementInfo;
diff --git a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
index 83b0262..8185281 100644
--- a/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetDiffPreferences.java
@@ -19,9 +19,9 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.StoredPreferences;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.DefaultPreferencesCache;
+import com.google.gerrit.server.config.PreferencesParserUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -41,7 +41,7 @@
   public Response<DiffPreferencesInfo> apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     return Response.ok(
-        StoredPreferences.parseDiffPreferences(
+        PreferencesParserUtil.parseDiffPreferences(
             defaultPreferenceCache.get().asConfig(), null, null));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java b/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
index 95fc10e..bb9e483 100644
--- a/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetEditPreferences.java
@@ -19,9 +19,9 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.StoredPreferences;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.DefaultPreferencesCache;
+import com.google.gerrit.server.config.PreferencesParserUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -40,7 +40,7 @@
   public Response<EditPreferencesInfo> apply(ConfigResource configResource)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     return Response.ok(
-        StoredPreferences.parseEditPreferences(
+        PreferencesParserUtil.parseEditPreferences(
             defaultPreferenceCache.get().asConfig(), null, null));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetPreferences.java b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
index 8a28d55..288055b 100644
--- a/java/com/google/gerrit/server/restapi/config/GetPreferences.java
+++ b/java/com/google/gerrit/server/restapi/config/GetPreferences.java
@@ -17,9 +17,9 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.StoredPreferences;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.DefaultPreferencesCache;
+import com.google.gerrit.server.config.PreferencesParserUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -38,7 +38,7 @@
   public Response<GeneralPreferencesInfo> apply(ConfigResource rsrc)
       throws IOException, ConfigInvalidException {
     return Response.ok(
-        StoredPreferences.parseGeneralPreferences(
+        PreferencesParserUtil.parseGeneralPreferences(
             defaultPreferenceCache.get().asConfig(), null, null));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index c83bf42..d08ee50 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -19,7 +19,7 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.entities.ContributorAgreement;
 import com.google.gerrit.extensions.common.AccountDefaultDisplayName;
 import com.google.gerrit.extensions.common.AccountsInfo;
 import com.google.gerrit.extensions.common.AuthInfo;
@@ -174,7 +174,7 @@
 
     if (info.useContributorAgreements != null) {
       Collection<ContributorAgreement> agreements =
-          projectCache.getAllProjects().getConfig().getContributorAgreements();
+          projectCache.getAllProjects().getConfig().getContributorAgreements().values();
       if (!agreements.isEmpty()) {
         info.contributorAgreements = Lists.newArrayListWithCapacity(agreements.size());
         for (ContributorAgreement agreement : agreements) {
@@ -335,17 +335,20 @@
   }
 
   private static final String DEFAULT_THEME = "/static/" + SitePaths.THEME_FILENAME;
+  private static final String DEFAULT_THEME_JS = "/static/" + SitePaths.THEME_JS_FILENAME;
 
   private String getDefaultTheme() {
     if (config.getString("theme", null, "enableDefault") == null) {
       // If not explicitly enabled or disabled, check for the existence of the theme file.
-      return Files.exists(sitePaths.site_theme) ? DEFAULT_THEME : null;
+      return Files.exists(sitePaths.site_theme_js)
+          ? DEFAULT_THEME_JS
+          : Files.exists(sitePaths.site_theme) ? DEFAULT_THEME : null;
     }
     if (config.getBoolean("theme", null, "enableDefault", true)) {
       // Return non-null theme path without checking for file existence. Even if the file doesn't
       // exist under the site path, it may be served from a CDN (in which case it's up to the admin
       // to also pass a proper asset path to the index Soy template).
-      return DEFAULT_THEME;
+      return DEFAULT_THEME_JS;
     }
     return null;
   }
diff --git a/java/com/google/gerrit/server/restapi/group/AddMembers.java b/java/com/google/gerrit/server/restapi/group/AddMembers.java
index 93d095d..700a2ab 100644
--- a/java/com/google/gerrit/server/restapi/group/AddMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/AddMembers.java
@@ -17,9 +17,9 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.common.AccountInfo;
diff --git a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
index 3fd3f29..23fa73d 100644
--- a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
@@ -18,8 +18,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index e5a1478..74ca721 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -18,9 +18,9 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.groups.GroupInput;
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
index a7b2e2d..fa52a79 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
@@ -16,9 +16,9 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
index b9d6ca8..fe67635 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
@@ -16,8 +16,8 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
diff --git a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
index 508547d..8a469f1 100644
--- a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
@@ -16,10 +16,10 @@
 
 import static java.util.Comparator.comparing;
 
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroupByIdAudit;
 import com.google.gerrit.entities.AccountGroupMemberAudit;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
diff --git a/java/com/google/gerrit/server/restapi/group/GetDescription.java b/java/com/google/gerrit/server/restapi/group/GetDescription.java
index b770281..f65b5e0 100644
--- a/java/com/google/gerrit/server/restapi/group/GetDescription.java
+++ b/java/com/google/gerrit/server/restapi/group/GetDescription.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.group.GroupResource;
diff --git a/java/com/google/gerrit/server/restapi/group/GetOwner.java b/java/com/google/gerrit/server/restapi/group/GetOwner.java
index e8bdfaa..2ab9a69c 100644
--- a/java/com/google/gerrit/server/restapi/group/GetOwner.java
+++ b/java/com/google/gerrit/server/restapi/group/GetOwner.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.group;
 
-import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
diff --git a/java/com/google/gerrit/server/restapi/group/GroupJson.java b/java/com/google/gerrit/server/restapi/group/GroupJson.java
index 99c9df7..e1459c3 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupJson.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupJson.java
@@ -19,8 +19,8 @@
 
 import com.google.common.base.Strings;
 import com.google.common.base.Suppliers;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
diff --git a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
index 65a7f4f..e0cfb1e 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.common.collect.ListMultimap;
-import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index bcb199f..3e2a577 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -21,10 +21,10 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.client.ListOption;
diff --git a/java/com/google/gerrit/server/restapi/group/ListMembers.java b/java/com/google/gerrit/server/restapi/group/ListMembers.java
index 23f0aa7..5b3e8dc 100644
--- a/java/com/google/gerrit/server/restapi/group/ListMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/ListMembers.java
@@ -20,9 +20,9 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
diff --git a/java/com/google/gerrit/server/restapi/group/ListSubgroups.java b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
index 540718f..776c17c 100644
--- a/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListSubgroups.java
@@ -18,8 +18,8 @@
 import static java.util.Comparator.comparing;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.Response;
diff --git a/java/com/google/gerrit/server/restapi/group/MembersCollection.java b/java/com/google/gerrit/server/restapi/group/MembersCollection.java
index 6dfb2b6..79f3d6a 100644
--- a/java/com/google/gerrit/server/restapi/group/MembersCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/MembersCollection.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.group;
 
-import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
diff --git a/java/com/google/gerrit/server/restapi/group/PutDescription.java b/java/com/google/gerrit/server/restapi/group/PutDescription.java
index 8fe4b20..942e680 100644
--- a/java/com/google/gerrit/server/restapi/group/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/group/PutDescription.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/restapi/group/PutName.java b/java/com/google/gerrit/server/restapi/group/PutName.java
index 9a3c87d..acdae33 100644
--- a/java/com/google/gerrit/server/restapi/group/PutName.java
+++ b/java/com/google/gerrit/server/restapi/group/PutName.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.NameInput;
diff --git a/java/com/google/gerrit/server/restapi/group/PutOptions.java b/java/com/google/gerrit/server/restapi/group/PutOptions.java
index 53bf571..748861e 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOptions.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOptions.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.restapi.group;
 
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/restapi/group/PutOwner.java b/java/com/google/gerrit/server/restapi/group/PutOwner.java
index 04129af..96ce9e4 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOwner.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOwner.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.restapi.group;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.api.groups.OwnerInput;
 import com.google.gerrit.extensions.common.GroupInfo;
diff --git a/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java b/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
index cebc27a..c7f6473 100644
--- a/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/SubgroupsCollection.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.group;
 
-import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
index 5deace9..783b39b 100644
--- a/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoImpl.java
@@ -77,8 +77,7 @@
     this.defaultSubmitType.value = projectState.getSubmitType();
     this.defaultSubmitType.configuredValue =
         MoreObjects.firstNonNull(
-            projectState.getConfig().getProject().getConfiguredSubmitType(),
-            Project.DEFAULT_SUBMIT_TYPE);
+            projectState.getConfig().getProject().getSubmitType(), Project.DEFAULT_SUBMIT_TYPE);
     ProjectState parent =
         projectState.isAllProjects() ? projectState : projectState.parents().get(0);
     this.defaultSubmitType.inheritedValue = parent.getSubmitType();
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index a87bbd1..eceab43 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -17,7 +17,7 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateChange.java b/java/com/google/gerrit/server/restapi/project/CreateChange.java
new file mode 100644
index 0000000..dbcd8c9
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateChange.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.exceptions.InvalidNameException;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class CreateChange implements RestModifyView<ProjectResource, ChangeInput> {
+  private final com.google.gerrit.server.restapi.change.CreateChange changeCreateChange;
+  private final Provider<CurrentUser> user;
+  private final BatchUpdate.Factory updateFactory;
+
+  @Inject
+  public CreateChange(
+      Provider<CurrentUser> user,
+      BatchUpdate.Factory updateFactory,
+      com.google.gerrit.server.restapi.change.CreateChange changeCreateChange) {
+    this.updateFactory = updateFactory;
+    this.changeCreateChange = changeCreateChange;
+    this.user = user;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ProjectResource rsrc, ChangeInput input)
+      throws PermissionBackendException, IOException, ConfigInvalidException,
+          InvalidChangeOperationException, InvalidNameException, UpdateException, RestApiException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    if (!Strings.isNullOrEmpty(input.project)) {
+      throw new BadRequestException("may not specify project");
+    }
+
+    input.project = rsrc.getName();
+    return changeCreateChange.execute(updateFactory, input, rsrc);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index a85ad39..3e1ef49 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -134,15 +134,15 @@
       throw new BadRequestException("values are required");
     }
 
-    List<LabelValue> values = LabelDefinitionInputParser.parseValues(input.values);
-
-    LabelType labelType;
     try {
-      labelType = new LabelType(label, values);
+      LabelType.checkName(label);
     } catch (IllegalArgumentException e) {
       throw new BadRequestException("invalid name: " + label, e);
     }
 
+    List<LabelValue> values = LabelDefinitionInputParser.parseValues(input.values);
+    LabelType.Builder labelType = LabelType.builder(LabelType.checkName(label), values);
+
     if (input.function != null && !input.function.trim().isEmpty()) {
       labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
     } else {
@@ -203,8 +203,9 @@
       labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
     }
 
-    config.getLabelSections().put(labelType.getName(), labelType);
+    LabelType lt = labelType.build();
+    config.upsertLabelType(lt);
 
-    return labelType;
+    return lt;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index 5a3dbcd..b572db3 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -26,11 +26,11 @@
 import com.google.common.collect.ImmutableBiMap;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
@@ -157,7 +157,7 @@
         projectState = projectCache.get(projectName).orElseThrow(illegalState(projectName));
         perm = permissionBackend.currentUser().project(projectName);
       } else if (config.getRevision() != null
-          && !config.getRevision().equals(projectState.getConfig().getRevision())) {
+          && !config.getRevision().equals(projectState.getConfig().getRevision().orElse(null))) {
         projectCache.evict(config.getProject());
         projectState = projectCache.get(projectName).orElseThrow(illegalState(projectName));
         perm = permissionBackend.currentUser().project(projectName);
@@ -208,9 +208,9 @@
           // user is a member of, as well as groups they own or that
           // are visible to all users.
 
-          AccessSection dst = null;
+          AccessSection.Builder dst = null;
           for (Permission srcPerm : section.getPermissions()) {
-            Permission dstPerm = null;
+            Permission.Builder dstPerm = null;
 
             for (PermissionRule srcRule : srcPerm.getRules()) {
               AccountGroup.UUID groupId = srcRule.getGroup().getUUID();
@@ -221,12 +221,12 @@
               loadGroup(groups, groupId);
               if (dstPerm == null) {
                 if (dst == null) {
-                  dst = new AccessSection(name);
-                  info.local.put(name, createAccessSection(groups, dst));
+                  dst = AccessSection.builder(name);
+                  info.local.put(name, createAccessSection(groups, dst.build()));
                 }
-                dstPerm = dst.getPermission(srcPerm.getName(), true);
+                dstPerm = dst.upsertPermission(srcPerm.getName());
               }
-              dstPerm.add(srcRule);
+              dstPerm.add(srcRule.toBuilder());
             }
           }
         }
diff --git a/java/com/google/gerrit/server/restapi/project/GetConfig.java b/java/com/google/gerrit/server/restapi/project/GetConfig.java
index ce45e7d..ad66587 100644
--- a/java/com/google/gerrit/server/restapi/project/GetConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/GetConfig.java
@@ -24,6 +24,9 @@
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -34,6 +37,7 @@
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
+  private final PermissionBackend permissionBackend;
   private final UiActions uiActions;
   private final DynamicMap<RestView<ProjectResource>> views;
 
@@ -43,24 +47,31 @@
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
+      PermissionBackend permissionBackend,
       UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.pluginConfigEntries = pluginConfigEntries;
     this.allProjects = allProjects;
     this.cfgFactory = cfgFactory;
+    this.permissionBackend = permissionBackend;
     this.uiActions = uiActions;
     this.views = views;
   }
 
   @Override
-  public Response<ConfigInfo> apply(ProjectResource resource) {
+  public Response<ConfigInfo> apply(ProjectResource resource) throws PermissionBackendException {
+    boolean readConfigAllowed =
+        permissionBackend
+            .currentUser()
+            .project(resource.getNameKey())
+            .test(ProjectPermission.READ_CONFIG);
     return Response.ok(
         new ConfigInfoImpl(
             serverEnableSignedPush,
             resource.getProjectState(),
             resource.getUser(),
-            pluginConfigEntries,
+            readConfigAllowed ? pluginConfigEntries : DynamicMap.emptyMap(),
             cfgFactory,
             allProjects,
             uiActions,
diff --git a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
index 1e288f4..0f49e63 100644
--- a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
+++ b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
@@ -14,11 +14,12 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Shorts;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -56,21 +57,22 @@
       if (valueDescription.isEmpty()) {
         throw new BadRequestException("description for value '" + e.getKey() + "' cannot be empty");
       }
-      valueList.add(new LabelValue(value, valueDescription));
+      valueList.add(LabelValue.create(value, valueDescription));
     }
     return valueList;
   }
 
-  public static short parseDefaultValue(LabelType labelType, short defaultValue)
+  public static short parseDefaultValue(LabelType.Builder labelType, short defaultValue)
       throws BadRequestException {
-    if (labelType.getValue(defaultValue) == null) {
+    if (!labelType.getValues().stream().anyMatch(v -> v.getValue() == defaultValue)) {
       throw new BadRequestException("invalid default value: " + defaultValue);
     }
     return defaultValue;
   }
 
-  public static List<String> parseBranches(List<String> branches) throws BadRequestException {
-    List<String> validBranches = new ArrayList<>();
+  public static ImmutableList<String> parseBranches(List<String> branches)
+      throws BadRequestException {
+    ImmutableList.Builder<String> validBranches = ImmutableList.builder();
     for (String branch : branches) {
       String newBranch = branch.trim();
       if (newBranch.isEmpty()) {
@@ -86,7 +88,7 @@
       }
       validBranches.add(newBranch);
     }
-    return validBranches;
+    return validBranches.build();
   }
 
   private LabelDefinitionInputParser() {}
diff --git a/java/com/google/gerrit/server/restapi/project/LabelsCollection.java b/java/com/google/gerrit/server/restapi/project/LabelsCollection.java
index 0409729..54179e5 100644
--- a/java/com/google/gerrit/server/restapi/project/LabelsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/LabelsCollection.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
diff --git a/java/com/google/gerrit/server/restapi/project/ListLabels.java b/java/com/google/gerrit/server/restapi/project/ListLabels.java
index 19a8915..56ee4cd 100644
--- a/java/com/google/gerrit/server/restapi/project/ListLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/ListLabels.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index c56e8c6..0c16822 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -26,8 +26,8 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.NoSuchGroupException;
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/Module.java
index 5b3ea30..ee3914d 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/Module.java
@@ -86,6 +86,7 @@
 
     child(PROJECT_KIND, "branches").to(BranchesCollection.class);
     create(BRANCH_KIND).to(CreateBranch.class);
+    post(PROJECT_KIND, "create.change").to(CreateChange.class);
     put(BRANCH_KIND).to(PutBranch.class);
     get(BRANCH_KIND).to(GetBranch.class);
     delete(BRANCH_KIND).to(DeleteBranch.class);
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabels.java b/java/com/google/gerrit/server/restapi/project/PostLabels.java
index 8835359..0c42ab2 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 9f9433b..55ea312 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -41,7 +41,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.EnableSignedPush;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
@@ -134,28 +133,25 @@
 
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
       ProjectConfig projectConfig = projectConfigFactory.read(md);
-      Project p = projectConfig.getProject();
-
-      p.setDescription(Strings.emptyToNull(input.description));
-
-      for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
-        InheritableBoolean val = BooleanProjectConfigTransformations.get(cfg, input);
-        if (val != null) {
-          p.setBooleanConfig(cfg, val);
-        }
-      }
-
-      if (input.maxObjectSizeLimit != null) {
-        p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
-      }
-
-      if (input.submitType != null) {
-        p.setSubmitType(input.submitType);
-      }
-
-      if (input.state != null) {
-        p.setState(input.state);
-      }
+      projectConfig.updateProject(
+          p -> {
+            p.setDescription(Strings.emptyToNull(input.description));
+            for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
+              InheritableBoolean val = BooleanProjectConfigTransformations.get(cfg, input);
+              if (val != null) {
+                p.setBooleanConfig(cfg, val);
+              }
+            }
+            if (input.maxObjectSizeLimit != null) {
+              p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
+            }
+            if (input.submitType != null) {
+              p.setSubmitType(input.submitType);
+            }
+            if (input.state != null) {
+              p.setState(input.state);
+            }
+          });
 
       if (input.pluginConfigValues != null) {
         setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
@@ -169,7 +165,7 @@
       try {
         projectConfig.commit(md);
         projectCache.evict(projectConfig.getProject());
-        md.getRepository().setGitwebDescription(p.getDescription());
+        md.getRepository().setGitwebDescription(projectConfig.getProject().getDescription());
       } catch (IOException e) {
         if (e.getCause() instanceof ConfigInvalidException) {
           throw new ResourceConflictException(
@@ -179,7 +175,7 @@
         throw new ResourceConflictException("Cannot update " + projectName);
       }
 
-      ProjectState state = projectStateFactory.create(projectConfigFactory.read(md));
+      ProjectState state = projectStateFactory.create(projectConfigFactory.read(md).getCacheable());
       return new ConfigInfoImpl(
           serverEnableSignedPush,
           state,
@@ -205,7 +201,6 @@
       throws BadRequestException {
     for (Map.Entry<String, Map<String, ConfigValue>> e : pluginConfigValues.entrySet()) {
       String pluginName = e.getKey();
-      PluginConfig cfg = projectConfig.getPluginConfig(pluginName);
       for (Map.Entry<String, ConfigValue> v : e.getValue().entrySet()) {
         ProjectConfigEntry projectConfigEntry = pluginConfigEntries.get(pluginName, v.getKey());
         if (projectConfigEntry != null) {
@@ -216,10 +211,11 @@
                 v.getKey(), PARAMETER_NAME_PATTERN.pattern());
             continue;
           }
-          String oldValue = cfg.getString(v.getKey());
+          String oldValue = projectConfig.getPluginConfig(pluginName).getString(v.getKey());
           String value = v.getValue().value;
           if (projectConfigEntry.getType() == ProjectConfigEntryType.ARRAY) {
-            List<String> l = Arrays.asList(cfg.getStringList(v.getKey()));
+            List<String> l =
+                Arrays.asList(projectConfig.getPluginConfig(pluginName).getStringList(v.getKey()));
             oldValue = Joiner.on("\n").join(l);
             value = Joiner.on("\n").join(v.getValue().values);
           }
@@ -233,15 +229,18 @@
                 switch (projectConfigEntry.getType()) {
                   case BOOLEAN:
                     boolean newBooleanValue = Boolean.parseBoolean(value);
-                    cfg.setBoolean(v.getKey(), newBooleanValue);
+                    projectConfig.updatePluginConfig(
+                        pluginName, cfg -> cfg.setBoolean(v.getKey(), newBooleanValue));
                     break;
                   case INT:
                     int newIntValue = Integer.parseInt(value);
-                    cfg.setInt(v.getKey(), newIntValue);
+                    projectConfig.updatePluginConfig(
+                        pluginName, cfg -> cfg.setInt(v.getKey(), newIntValue));
                     break;
                   case LONG:
                     long newLongValue = Long.parseLong(value);
-                    cfg.setLong(v.getKey(), newLongValue);
+                    projectConfig.updatePluginConfig(
+                        pluginName, cfg -> cfg.setLong(v.getKey(), newLongValue));
                     break;
                   case LIST:
                     if (!projectConfigEntry.getPermittedValues().contains(value)) {
@@ -255,10 +254,13 @@
                     }
                     // $FALL-THROUGH$
                   case STRING:
-                    cfg.setString(v.getKey(), value);
+                    String valueToSet = value;
+                    projectConfig.updatePluginConfig(
+                        pluginName, cfg -> cfg.setString(v.getKey(), valueToSet));
                     break;
                   case ARRAY:
-                    cfg.setStringList(v.getKey(), v.getValue().values);
+                    projectConfig.updatePluginConfig(
+                        pluginName, cfg -> cfg.setStringList(v.getKey(), v.getValue().values));
                     break;
                   default:
                     logger.atWarning().log(
@@ -276,7 +278,7 @@
             if (oldValue != null) {
               validateProjectConfigEntryIsEditable(
                   projectConfigEntry, projectState, v.getKey(), pluginName);
-              cfg.unset(v.getKey());
+              projectConfig.updatePluginConfig(pluginName, cfg -> cfg.unset(v.getKey()));
             }
           }
         } else {
diff --git a/java/com/google/gerrit/server/restapi/project/PutDescription.java b/java/com/google/gerrit/server/restapi/project/PutDescription.java
index a0b9feb..a65c626 100644
--- a/java/com/google/gerrit/server/restapi/project/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -73,8 +72,8 @@
 
     try (MetaDataUpdate md = updateFactory.get().create(resource.getNameKey())) {
       ProjectConfig config = projectConfigFactory.read(md);
-      Project project = config.getProject();
-      project.setDescription(Strings.emptyToNull(input.description));
+      String desc = input.description;
+      config.updateProject(p -> p.setDescription(Strings.emptyToNull(desc)));
 
       String msg =
           MoreObjects.firstNonNull(
@@ -86,11 +85,11 @@
       md.setMessage(msg);
       config.commit(md);
       cache.evict(resource.getProjectState().getProject());
-      md.getRepository().setGitwebDescription(project.getDescription());
+      md.getRepository().setGitwebDescription(config.getProject().getDescription());
 
-      return Strings.isNullOrEmpty(project.getDescription())
+      return Strings.isNullOrEmpty(config.getProject().getDescription())
           ? Response.none()
-          : Response.ok(project.getDescription());
+          : Response.ok(config.getProject().getDescription());
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(resource.getName(), notFound);
     } catch (ConfigInvalidException e) {
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 02c1b54..794cae8 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -16,7 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 5d5e779..65cc5a2 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -15,13 +15,13 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
@@ -78,14 +78,14 @@
         continue;
       }
 
-      AccessSection accessSection = new AccessSection(entry.getKey());
+      AccessSection.Builder accessSection = AccessSection.builder(entry.getKey());
       for (Map.Entry<String, PermissionInfo> permissionEntry :
           entry.getValue().permissions.entrySet()) {
         if (permissionEntry.getValue().rules == null) {
           continue;
         }
 
-        Permission p = new Permission(permissionEntry.getKey());
+        Permission.Builder p = Permission.builder(permissionEntry.getKey());
         if (permissionEntry.getValue().exclusive != null) {
           p.setExclusiveGroup(permissionEntry.getValue().exclusive);
         }
@@ -99,7 +99,7 @@
           }
 
           PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
-          PermissionRule r = new PermissionRule(GroupReference.forGroup(group));
+          PermissionRule.Builder r = PermissionRule.builder(GroupReference.forGroup(group));
           if (pri != null) {
             if (pri.max != null) {
               r.setMax(pri.max);
@@ -118,7 +118,7 @@
         }
         accessSection.addPermission(p);
       }
-      sections.add(accessSection);
+      sections.add(accessSection.build());
     }
     return sections;
   }
@@ -193,25 +193,23 @@
 
     // Apply additions
     for (AccessSection section : additions) {
-      AccessSection currentAccessSection = config.getAccessSection(section.getName());
-
-      if (currentAccessSection == null) {
-        // Add AccessSection
-        config.replace(section);
-      } else {
-        for (Permission p : section.getPermissions()) {
-          Permission currentPermission = currentAccessSection.getPermission(p.getName());
-          if (currentPermission == null) {
-            // Add Permission
-            currentAccessSection.addPermission(p);
-          } else {
-            for (PermissionRule r : p.getRules()) {
-              // AddPermissionRule
-              currentPermission.add(r);
+      config.upsertAccessSection(
+          section.getName(),
+          existingAccessSection -> {
+            for (Permission p : section.getPermissions()) {
+              Permission currentPermission =
+                  existingAccessSection.build().getPermission(p.getName());
+              if (currentPermission == null) {
+                // Add Permission
+                existingAccessSection.addPermission(p.toBuilder());
+              } else {
+                for (PermissionRule r : p.getRules()) {
+                  // AddPermissionRule
+                  existingAccessSection.upsertPermission(p.getName()).add(r.toBuilder());
+                }
+              }
             }
-          }
-        }
-      }
+          });
     }
   }
 
@@ -243,7 +241,7 @@
       } catch (UnprocessableEntityException e) {
         throw new ResourceConflictException(e.getMessage(), e);
       }
-      config.getProject().setParentName(newParentProjectName);
+      config.updateProject(p -> p.setParent(newParentProjectName));
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index 9920be0..5aef76a 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -97,11 +96,11 @@
 
     try (MetaDataUpdate md = updateFactory.create(rsrc.getProjectState().getNameKey())) {
       ProjectConfig config = projectConfigFactory.read(md);
-      Project project = config.getProject();
+      String id = input.id;
       if (inherited) {
-        project.setDefaultDashboard(input.id);
+        config.updateProject(p -> p.setDefaultDashboard(id));
       } else {
-        project.setLocalDefaultDashboard(input.id);
+        config.updateProject(p -> p.setLocalDefaultDashboard(id));
       }
 
       String msg =
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index 0a35865..ffc591b 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -88,6 +88,9 @@
         } else {
           md.setMessage("Update label");
         }
+        String newName = Strings.nullToEmpty(input.name).trim();
+        labelType =
+            config.getLabelSections().get(newName.isEmpty() ? labelType.getName() : newName);
 
         config.commit(md);
         projectCache.evict(rsrc.getProject().getProjectState().getProject());
@@ -109,8 +112,7 @@
   public boolean updateLabel(ProjectConfig config, LabelType labelType, LabelDefinitionInput input)
       throws BadRequestException, ResourceConflictException {
     boolean dirty = false;
-
-    config.getLabelSections().remove(labelType.getName());
+    LabelType.Builder labelTypeBuilder = labelType.toBuilder();
 
     if (input.name != null) {
       String newName = input.name.trim();
@@ -130,10 +132,12 @@
         }
 
         try {
-          labelType.setName(newName);
+          LabelType.checkName(newName);
         } catch (IllegalArgumentException e) {
           throw new BadRequestException("invalid name: " + input.name, e);
         }
+
+        labelTypeBuilder.setName(newName);
         dirty = true;
       }
     }
@@ -142,7 +146,7 @@
       if (input.function.trim().isEmpty()) {
         throw new BadRequestException("function cannot be empty");
       }
-      labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
+      labelTypeBuilder.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
       dirty = true;
     }
 
@@ -150,77 +154,79 @@
       if (input.values.isEmpty()) {
         throw new BadRequestException("values cannot be empty");
       }
-      labelType.setValues(LabelDefinitionInputParser.parseValues(input.values));
+      labelTypeBuilder.setValues(LabelDefinitionInputParser.parseValues(input.values));
       dirty = true;
     }
 
     if (input.defaultValue != null) {
-      labelType.setDefaultValue(
-          LabelDefinitionInputParser.parseDefaultValue(labelType, input.defaultValue));
+      labelTypeBuilder.setDefaultValue(
+          LabelDefinitionInputParser.parseDefaultValue(labelTypeBuilder, input.defaultValue));
       dirty = true;
     }
 
     if (input.branches != null) {
-      labelType.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
+      labelTypeBuilder.setRefPatterns(LabelDefinitionInputParser.parseBranches(input.branches));
       dirty = true;
     }
 
     if (input.canOverride != null) {
-      labelType.setCanOverride(input.canOverride);
+      labelTypeBuilder.setCanOverride(input.canOverride);
       dirty = true;
     }
 
     if (input.copyAnyScore != null) {
-      labelType.setCopyAnyScore(input.copyAnyScore);
+      labelTypeBuilder.setCopyAnyScore(input.copyAnyScore);
       dirty = true;
     }
 
     if (input.copyMinScore != null) {
-      labelType.setCopyMinScore(input.copyMinScore);
+      labelTypeBuilder.setCopyMinScore(input.copyMinScore);
       dirty = true;
     }
 
     if (input.copyMaxScore != null) {
-      labelType.setCopyMaxScore(input.copyMaxScore);
+      labelTypeBuilder.setCopyMaxScore(input.copyMaxScore);
       dirty = true;
     }
 
     if (input.copyAllScoresIfNoChange != null) {
-      labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
+      labelTypeBuilder.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
+      dirty = true;
     }
 
     if (input.copyAllScoresIfNoCodeChange != null) {
-      labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
+      labelTypeBuilder.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
       dirty = true;
     }
 
     if (input.copyAllScoresOnTrivialRebase != null) {
-      labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
+      labelTypeBuilder.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
       dirty = true;
     }
 
     if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
-      labelType.setCopyAllScoresOnMergeFirstParentUpdate(
+      labelTypeBuilder.setCopyAllScoresOnMergeFirstParentUpdate(
           input.copyAllScoresOnMergeFirstParentUpdate);
       dirty = true;
     }
 
     if (input.copyValues != null) {
-      labelType.setCopyValues(input.copyValues);
+      labelTypeBuilder.setCopyValues(input.copyValues);
       dirty = true;
     }
 
     if (input.allowPostSubmit != null) {
-      labelType.setAllowPostSubmit(input.allowPostSubmit);
+      labelTypeBuilder.setAllowPostSubmit(input.allowPostSubmit);
       dirty = true;
     }
 
     if (input.ignoreSelfApproval != null) {
-      labelType.setIgnoreSelfApproval(input.ignoreSelfApproval);
+      labelTypeBuilder.setIgnoreSelfApproval(input.ignoreSelfApproval);
       dirty = true;
     }
 
-    config.getLabelSections().put(labelType.getName(), labelType);
+    config.getLabelSections().remove(labelType.getName());
+    config.upsertLabelType(labelTypeBuilder.build());
 
     return dirty;
   }
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index 42790aa..91c29f5 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -103,8 +103,7 @@
     validateParentUpdate(rsrc.getProjectState().getNameKey(), user, parentName, checkIfAdmin);
     try (MetaDataUpdate md = updateFactory.get().create(rsrc.getNameKey())) {
       ProjectConfig config = projectConfigFactory.read(md);
-      Project project = config.getProject();
-      project.setParentName(parentName);
+      config.updateProject(p -> p.setParent(parentName));
 
       String msg = Strings.emptyToNull(input.commitMessage);
       if (msg == null) {
@@ -117,7 +116,7 @@
       config.commit(md);
       cache.evict(rsrc.getProjectState().getProject());
 
-      Project.NameKey parent = project.getParent(allProjects);
+      Project.NameKey parent = config.getProject().getParent(allProjects);
       requireNonNull(parent);
       return parent.get();
     } catch (RepositoryNotFoundException notFound) {
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index 799d706..4592100 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -18,10 +18,10 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
diff --git a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
index 54915fb..b2bfbd5 100644
--- a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -18,12 +18,12 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -66,7 +66,8 @@
       return ruleError(E_UNABLE_TO_FETCH_LABELS);
     }
 
-    boolean shouldIgnoreSelfApproval = labelTypes.stream().anyMatch(LabelType::ignoreSelfApproval);
+    boolean shouldIgnoreSelfApproval =
+        labelTypes.stream().anyMatch(LabelType::isIgnoreSelfApproval);
     if (!shouldIgnoreSelfApproval) {
       // Shortcut to avoid further processing if no label should ignore uploader approvals
       return Optional.empty();
@@ -86,7 +87,7 @@
     submitRecord.requirements = new ArrayList<>();
 
     for (LabelType t : labelTypes) {
-      if (!t.ignoreSelfApproval()) {
+      if (!t.isIgnoreSelfApproval()) {
         // The default rules are enough in this case.
         continue;
       }
diff --git a/java/com/google/gerrit/server/rules/PrologRule.java b/java/com/google/gerrit/server/rules/PrologRule.java
index 1861ee7..8f17fa1 100644
--- a/java/com/google/gerrit/server/rules/PrologRule.java
+++ b/java/com/google/gerrit/server/rules/PrologRule.java
@@ -16,8 +16,8 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 5f1268b..57c4832 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -23,11 +23,11 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.server.account.AccountCache;
@@ -453,7 +453,7 @@
       } else {
         pmc =
             rulesCache.loadMachine(
-                projectState.getNameKey(), projectState.getConfig().getRulesId());
+                projectState.getNameKey(), projectState.getConfig().getRulesId().orElse(null));
       }
       env = envFactory.create(pmc);
     } catch (CompileException err) {
@@ -490,7 +490,7 @@
         parentEnv =
             envFactory.create(
                 rulesCache.loadMachine(
-                    parentState.getNameKey(), parentState.getConfig().getRulesId()));
+                    parentState.getNameKey(), parentState.getConfig().getRulesId().orElse(null)));
       } catch (CompileException err) {
         throw new RuleEvalException("Cannot consult rules.pl for " + parentState.getName(), err);
       }
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/RulesCache.java
index ed67e68..8b72714 100644
--- a/java/com/google/gerrit/server/rules/RulesCache.java
+++ b/java/com/google/gerrit/server/rules/RulesCache.java
@@ -55,6 +55,7 @@
 import java.util.EnumSet;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -128,8 +129,8 @@
    * @return a Prolog machine, after loading the specified rules.
    * @throws CompileException the machine cannot be created.
    */
-  public synchronized PrologMachineCopy loadMachine(Project.NameKey project, ObjectId rulesId)
-      throws CompileException {
+  public synchronized PrologMachineCopy loadMachine(
+      @Nullable Project.NameKey project, @Nullable ObjectId rulesId) throws CompileException {
     if (!enableProjectRules || project == null || rulesId == null) {
       return defaultMachine;
     }
diff --git a/java/com/google/gerrit/server/rules/SubmitRule.java b/java/com/google/gerrit/server/rules/SubmitRule.java
index b221117..90d2137 100644
--- a/java/com/google/gerrit/server/rules/SubmitRule.java
+++ b/java/com/google/gerrit/server/rules/SubmitRule.java
@@ -13,7 +13,7 @@
 // limitations under the License.
 package com.google.gerrit.server.rules;
 
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/server/schema/AclUtil.java b/java/com/google/gerrit/server/schema/AclUtil.java
index f6c3aad..911756b 100644
--- a/java/com/google/gerrit/server/schema/AclUtil.java
+++ b/java/com/google/gerrit/server/schema/AclUtil.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.server.project.ProjectConfig;
 
 /**
@@ -27,13 +27,16 @@
  */
 public class AclUtil {
   public static void grant(
-      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
+      ProjectConfig config,
+      AccessSection.Builder section,
+      String permission,
+      GroupReference... groupList) {
     grant(config, section, permission, false, groupList);
   }
 
   public static void grant(
       ProjectConfig config,
-      AccessSection section,
+      AccessSection.Builder section,
       String permission,
       boolean force,
       GroupReference... groupList) {
@@ -42,39 +45,38 @@
 
   public static void grant(
       ProjectConfig config,
-      AccessSection section,
+      AccessSection.Builder section,
       String permission,
       boolean force,
       Boolean exclusive,
       GroupReference... groupList) {
-    Permission p = section.getPermission(permission, true);
+    Permission.Builder p = section.upsertPermission(permission);
     if (exclusive != null) {
       p.setExclusiveGroup(exclusive);
     }
     for (GroupReference group : groupList) {
       if (group != null) {
-        PermissionRule r = rule(config, group);
-        r.setForce(force);
-        p.add(r);
+        p.add(rule(config, group).setForce(force));
       }
     }
   }
 
   public static void block(
-      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
-    Permission p = section.getPermission(permission, true);
+      ProjectConfig config,
+      AccessSection.Builder section,
+      String permission,
+      GroupReference... groupList) {
+    Permission.Builder p = section.upsertPermission(permission);
     for (GroupReference group : groupList) {
       if (group != null) {
-        PermissionRule r = rule(config, group);
-        r.setBlock();
-        p.add(r);
+        p.add(rule(config, group).setBlock());
       }
     }
   }
 
   public static void grant(
       ProjectConfig config,
-      AccessSection section,
+      AccessSection.Builder section,
       LabelType type,
       int min,
       int max,
@@ -84,35 +86,35 @@
 
   public static void grant(
       ProjectConfig config,
-      AccessSection section,
+      AccessSection.Builder section,
       LabelType type,
       int min,
       int max,
       boolean exclusive,
       GroupReference... groupList) {
     String name = Permission.LABEL + type.getName();
-    Permission p = section.getPermission(name, true);
+    Permission.Builder p = section.upsertPermission(name);
     p.setExclusiveGroup(exclusive);
     for (GroupReference group : groupList) {
       if (group != null) {
-        PermissionRule r = rule(config, group);
-        r.setRange(min, max);
-        p.add(r);
+        p.add(rule(config, group).setRange(min, max));
       }
     }
   }
 
-  public static PermissionRule rule(ProjectConfig config, GroupReference group) {
-    return new PermissionRule(config.resolve(group));
+  public static PermissionRule.Builder rule(ProjectConfig config, GroupReference group) {
+    return PermissionRule.builder(config.resolve(group));
   }
 
   public static void remove(
-      ProjectConfig config, AccessSection section, String permission, GroupReference... groupList) {
-    Permission p = section.getPermission(permission, true);
+      ProjectConfig config,
+      AccessSection.Builder section,
+      String permission,
+      GroupReference... groupList) {
+    Permission.Builder p = section.upsertPermission(permission);
     for (GroupReference group : groupList) {
       if (group != null) {
-        PermissionRule r = rule(config, group);
-        p.remove(r);
+        p.remove(rule(config, group).build());
       }
     }
   }
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index cfa5825..8083118 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -23,14 +23,12 @@
 import static com.google.gerrit.server.schema.AclUtil.rule;
 
 import com.google.gerrit.common.Version;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -116,19 +114,16 @@
 
       // init basic project configs.
       ProjectConfig config = projectConfigFactory.read(md);
-      Project p = config.getProject();
-      p.setDescription(
-          input.projectDescription().orElse("Access inherited by all other projects."));
-
-      // init boolean project configs.
-      input.booleanProjectConfigs().forEach(p::setBooleanConfig);
+      config.updateProject(
+          p -> {
+            p.setDescription(
+                input.projectDescription().orElse("Access inherited by all other projects."));
+            // init boolean project configs.
+            input.booleanProjectConfigs().forEach(p::setBooleanConfig);
+          });
 
       // init labels.
-      input
-          .codeReviewLabel()
-          .ifPresent(
-              codeReviewLabel ->
-                  config.getLabelSections().put(codeReviewLabel.getName(), codeReviewLabel));
+      input.codeReviewLabel().ifPresent(codeReviewLabel -> config.upsertLabelType(codeReviewLabel));
 
       if (input.initDefaultAcls()) {
         // init access sections.
@@ -149,81 +144,107 @@
   }
 
   private void initDefaultAcls(ProjectConfig config, AllProjectsInput input) {
-    AccessSection capabilities = config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true);
-    AccessSection heads = config.getAccessSection(AccessSection.HEADS, true);
-
     checkArgument(input.codeReviewLabel().isPresent());
     LabelType codeReviewLabel = input.codeReviewLabel().get();
 
-    initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
+    config.upsertAccessSection(
+        AccessSection.HEADS,
+        heads -> {
+          initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
+        });
 
-    input
-        .batchUsersGroup()
-        .ifPresent(
-            batchUsersGroup -> initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup));
+    config.upsertAccessSection(
+        AccessSection.GLOBAL_CAPABILITIES,
+        capabilities -> {
+          input
+              .batchUsersGroup()
+              .ifPresent(
+                  batchUsersGroup ->
+                      initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup));
+        });
 
     input
         .administratorsGroup()
-        .ifPresent(
-            adminsGroup ->
-                initDefaultAclsForAdmins(
-                    capabilities, config, heads, codeReviewLabel, adminsGroup));
+        .ifPresent(adminsGroup -> initDefaultAclsForAdmins(config, codeReviewLabel, adminsGroup));
   }
 
   private void initDefaultAclsForRegisteredUsers(
-      AccessSection heads, LabelType codeReviewLabel, ProjectConfig config) {
-    AccessSection refsFor = config.getAccessSection("refs/for/*", true);
-    AccessSection magic = config.getAccessSection("refs/for/" + AccessSection.ALL, true);
-    AccessSection all = config.getAccessSection("refs/*", true);
+      AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
+    config.upsertAccessSection(
+        "refs/for/*",
+        refsFor -> {
+          grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
+        });
 
-    grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
     grant(config, heads, codeReviewLabel, -1, 1, registered);
     grant(config, heads, Permission.FORGE_AUTHOR, registered);
-    grant(config, all, Permission.REVERT, registered);
-    grant(config, magic, Permission.PUSH, registered);
-    grant(config, magic, Permission.PUSH_MERGE, registered);
+
+    config.upsertAccessSection(
+        "refs/*",
+        all -> {
+          grant(config, all, Permission.REVERT, registered);
+        });
+
+    config.upsertAccessSection(
+        "refs/for/" + AccessSection.ALL,
+        magic -> {
+          grant(config, magic, Permission.PUSH, registered);
+          grant(config, magic, Permission.PUSH_MERGE, registered);
+        });
   }
 
   private void initDefaultAclsForBatchUsers(
-      AccessSection capabilities, ProjectConfig config, GroupReference batchUsersGroup) {
-    Permission priority = capabilities.getPermission(GlobalCapability.PRIORITY, true);
-    PermissionRule r = rule(config, batchUsersGroup);
-    r.setAction(Action.BATCH);
-    priority.add(r);
+      AccessSection.Builder capabilities, ProjectConfig config, GroupReference batchUsersGroup) {
+    Permission.Builder priority = capabilities.upsertPermission(GlobalCapability.PRIORITY);
+    priority.add(rule(config, batchUsersGroup).setAction(Action.BATCH));
 
-    Permission stream = capabilities.getPermission(GlobalCapability.STREAM_EVENTS, true);
+    Permission.Builder stream = capabilities.upsertPermission(GlobalCapability.STREAM_EVENTS);
     stream.add(rule(config, batchUsersGroup));
   }
 
   private void initDefaultAclsForAdmins(
-      AccessSection capabilities,
-      ProjectConfig config,
-      AccessSection heads,
-      LabelType codeReviewLabel,
-      GroupReference adminsGroup) {
-    AccessSection all = config.getAccessSection(AccessSection.ALL, true);
-    AccessSection tags = config.getAccessSection("refs/tags/*", true);
-    AccessSection meta = config.getAccessSection(RefNames.REFS_CONFIG, true);
+      ProjectConfig config, LabelType codeReviewLabel, GroupReference adminsGroup) {
+    config.upsertAccessSection(
+        AccessSection.GLOBAL_CAPABILITIES,
+        capabilities -> {
+          grant(config, capabilities, GlobalCapability.ADMINISTRATE_SERVER, adminsGroup);
+        });
 
-    grant(config, capabilities, GlobalCapability.ADMINISTRATE_SERVER, adminsGroup);
-    grant(config, all, Permission.READ, adminsGroup, anonymous);
-    grant(config, heads, codeReviewLabel, -2, 2, adminsGroup, owners);
-    grant(config, heads, Permission.CREATE, adminsGroup, owners);
-    grant(config, heads, Permission.PUSH, adminsGroup, owners);
-    grant(config, heads, Permission.SUBMIT, adminsGroup, owners);
-    grant(config, heads, Permission.FORGE_COMMITTER, adminsGroup, owners);
-    grant(config, heads, Permission.EDIT_TOPIC_NAME, true, adminsGroup, owners);
+    config.upsertAccessSection(
+        AccessSection.ALL,
+        all -> {
+          grant(config, all, Permission.READ, adminsGroup, anonymous);
+        });
 
-    grant(config, tags, Permission.CREATE, adminsGroup, owners);
-    grant(config, tags, Permission.CREATE_TAG, adminsGroup, owners);
-    grant(config, tags, Permission.CREATE_SIGNED_TAG, adminsGroup, owners);
+    config.upsertAccessSection(
+        AccessSection.HEADS,
+        heads -> {
+          grant(config, heads, codeReviewLabel, -2, 2, adminsGroup, owners);
+          grant(config, heads, Permission.CREATE, adminsGroup, owners);
+          grant(config, heads, Permission.PUSH, adminsGroup, owners);
+          grant(config, heads, Permission.SUBMIT, adminsGroup, owners);
+          grant(config, heads, Permission.FORGE_COMMITTER, adminsGroup, owners);
+          grant(config, heads, Permission.EDIT_TOPIC_NAME, true, adminsGroup, owners);
+        });
 
-    meta.getPermission(Permission.READ, true).setExclusiveGroup(true);
-    grant(config, meta, Permission.READ, adminsGroup, owners);
-    grant(config, meta, codeReviewLabel, -2, 2, adminsGroup, owners);
-    grant(config, meta, Permission.CREATE, adminsGroup, owners);
-    grant(config, meta, Permission.PUSH, adminsGroup, owners);
-    grant(config, meta, Permission.SUBMIT, adminsGroup, owners);
+    config.upsertAccessSection(
+        "refs/tags/*",
+        tags -> {
+          grant(config, tags, Permission.CREATE, adminsGroup, owners);
+          grant(config, tags, Permission.CREATE_TAG, adminsGroup, owners);
+          grant(config, tags, Permission.CREATE_SIGNED_TAG, adminsGroup, owners);
+        });
+
+    config.upsertAccessSection(
+        RefNames.REFS_CONFIG,
+        meta -> {
+          meta.upsertPermission(Permission.READ).setExclusiveGroup(true);
+          grant(config, meta, Permission.READ, adminsGroup, owners);
+          grant(config, meta, codeReviewLabel, -2, 2, adminsGroup, owners);
+          grant(config, meta, Permission.CREATE, adminsGroup, owners);
+          grant(config, meta, Permission.PUSH, adminsGroup, owners);
+          grant(config, meta, Permission.SUBMIT, adminsGroup, owners);
+        });
   }
 
   private void initSequences(Repository git, BatchRefUpdate bru, int firstChangeId)
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index 6e11a5d..f8473b2 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -18,10 +18,10 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.notedb.Sequences;
 import java.util.Optional;
@@ -46,18 +46,17 @@
 
   @UsedAt(UsedAt.Project.GOOGLE)
   public static LabelType getDefaultCodeReviewLabel() {
-    LabelType type =
-        new LabelType(
+    return LabelType.builder(
             "Code-Review",
             ImmutableList.of(
-                new LabelValue((short) 2, "Looks good to me, approved"),
-                new LabelValue((short) 1, "Looks good to me, but someone else must approve"),
-                new LabelValue((short) 0, "No score"),
-                new LabelValue((short) -1, "I would prefer this is not merged as is"),
-                new LabelValue((short) -2, "This shall not be merged")));
-    type.setCopyMinScore(true);
-    type.setCopyAllScoresOnTrivialRebase(true);
-    return type;
+                LabelValue.create((short) 2, "Looks good to me, approved"),
+                LabelValue.create((short) 1, "Looks good to me, but someone else must approve"),
+                LabelValue.create((short) 0, "No score"),
+                LabelValue.create((short) -1, "I would prefer this is not merged as is"),
+                LabelValue.create((short) -2, "This shall not be merged")))
+        .setCopyMinScore(true)
+        .setCopyAllScoresOnTrivialRebase(true)
+        .build();
   }
 
   /** The administrator group which gets default permissions granted. */
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 4904028..90973fb 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -22,11 +22,9 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.Version;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
@@ -112,36 +110,41 @@
       md.setMessage("Initialized Gerrit Code Review " + Version.getVersion());
 
       ProjectConfig config = projectConfigFactory.read(md);
-      Project project = config.getProject();
-      project.setDescription("Individual user settings and preferences.");
+      config.updateProject(p -> p.setDescription("Individual user settings and preferences."));
 
-      AccessSection users =
-          config.getAccessSection(
-              RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true);
+      config.upsertAccessSection(
+          RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+          users -> {
+            grant(config, users, Permission.READ, false, true, registered);
+            grant(config, users, Permission.PUSH, false, true, registered);
+            grant(config, users, Permission.SUBMIT, false, true, registered);
+            grant(config, users, codeReviewLabel, -2, 2, true, registered);
+          });
 
       // Initialize "Code-Review" label.
-      config.getLabelSections().put(codeReviewLabel.getName(), codeReviewLabel);
-
-      grant(config, users, Permission.READ, false, true, registered);
-      grant(config, users, Permission.PUSH, false, true, registered);
-      grant(config, users, Permission.SUBMIT, false, true, registered);
-      grant(config, users, codeReviewLabel, -2, 2, true, registered);
+      config.upsertLabelType(codeReviewLabel);
 
       if (admin != null) {
-        AccessSection defaults = config.getAccessSection(RefNames.REFS_USERS_DEFAULT, true);
-        defaults.getPermission(Permission.READ, true).setExclusiveGroup(true);
-        grant(config, defaults, Permission.READ, admin);
-        defaults.getPermission(Permission.PUSH, true).setExclusiveGroup(true);
-        grant(config, defaults, Permission.PUSH, admin);
-        defaults.getPermission(Permission.CREATE, true).setExclusiveGroup(true);
-        grant(config, defaults, Permission.CREATE, admin);
+        config.upsertAccessSection(
+            RefNames.REFS_USERS_DEFAULT,
+            defaults -> {
+              defaults.upsertPermission(Permission.READ).setExclusiveGroup(true);
+              grant(config, defaults, Permission.READ, admin);
+              defaults.upsertPermission(Permission.PUSH).setExclusiveGroup(true);
+              grant(config, defaults, Permission.PUSH, admin);
+              defaults.upsertPermission(Permission.CREATE).setExclusiveGroup(true);
+              grant(config, defaults, Permission.CREATE, admin);
+            });
       }
 
       // Grant read permissions on the group branches to all users.
       // This allows group owners to see the group refs. VisibleRefFilter ensures that read
       // permissions for non-group-owners are ignored.
-      AccessSection groups = config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
-      grant(config, groups, Permission.READ, false, true, registered);
+      config.upsertAccessSection(
+          RefNames.REFS_GROUPS + "*",
+          groups -> {
+            grant(config, groups, Permission.READ, false, true, registered);
+          });
 
       config.commit(md);
     }
diff --git a/java/com/google/gerrit/server/schema/GrantRevertPermission.java b/java/com/google/gerrit/server/schema/GrantRevertPermission.java
index 2f890d5..f3404bc 100644
--- a/java/com/google/gerrit/server/schema/GrantRevertPermission.java
+++ b/java/com/google/gerrit/server/schema/GrantRevertPermission.java
@@ -18,9 +18,9 @@
 import static com.google.gerrit.server.schema.AclUtil.grant;
 import static com.google.gerrit.server.schema.AclUtil.remove;
 
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
@@ -63,28 +64,41 @@
     try (Repository repo = repoManager.openRepository(projectName)) {
       MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, repo);
       ProjectConfig projectConfig = projectConfigFactory.read(md);
-      AccessSection heads = projectConfig.getAccessSection(AccessSection.HEADS, true);
 
-      Permission permissionOnRefsHeads = heads.getPermission(Permission.REVERT);
+      AtomicBoolean shouldExit = new AtomicBoolean(false);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            Permission permissionOnRefsHeads = heads.build().getPermission(Permission.REVERT);
 
-      if (permissionOnRefsHeads != null) {
-        if (permissionOnRefsHeads.getRule(registeredUsers) == null
-            || permissionOnRefsHeads.getRules().size() > 1) {
-          // If admins already changed the permission, don't do anything.
-          return;
-        }
-        // permission already exists in refs/heads/*, delete it for Registered Users.
-        remove(projectConfig, heads, Permission.REVERT, registeredUsers);
-      }
+            if (permissionOnRefsHeads != null) {
+              if (permissionOnRefsHeads.getRule(registeredUsers) == null
+                  || permissionOnRefsHeads.getRules().size() > 1) {
+                // If admins already changed the permission, don't do anything.
+                shouldExit.set(true);
+                return;
+              }
+              // permission already exists in refs/heads/*, delete it for Registered Users.
+              remove(projectConfig, heads, Permission.REVERT, registeredUsers);
+            }
+          });
 
-      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL, true);
-      Permission permissionOnRefsStar = all.getPermission(Permission.REVERT);
-      if (permissionOnRefsStar != null && permissionOnRefsStar.getRule(registeredUsers) != null) {
-        // permission already exists in refs/*, don't do anything.
+      if (shouldExit.get()) {
         return;
       }
-      // If the permission doesn't exist of refs/* for Registered Users, grant it.
-      grant(projectConfig, all, Permission.REVERT, registeredUsers);
+
+      projectConfig.upsertAccessSection(
+          AccessSection.ALL,
+          all -> {
+            Permission permissionOnRefsStar = all.build().getPermission(Permission.REVERT);
+            if (permissionOnRefsStar != null
+                && permissionOnRefsStar.getRule(registeredUsers) != null) {
+              // permission already exists in refs/*, don't do anything.
+              return;
+            }
+            // If the permission doesn't exist of refs/* for Registered Users, grant it.
+            grant(projectConfig, all, Permission.REVERT, registeredUsers);
+          });
 
       md.getCommitBuilder().setAuthor(serverUser);
       md.getCommitBuilder().setCommitter(serverUser);
diff --git a/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java b/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
index 5e7dbf0..868e7ea 100644
--- a/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
+++ b/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
@@ -19,7 +19,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -98,7 +98,7 @@
                     r -> {
                       PermissionRule rule = PermissionRule.fromString(r, false);
                       if (rule.getForce()) {
-                        rule.setForce(false);
+                        rule = rule.toBuilder().setForce(false).build();
                         updated = true;
                       }
                       return rule.asString(false);
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index 78fa5bd..f53f9a6 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -16,8 +16,8 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.metrics.MetricMaker;
@@ -212,7 +212,7 @@
 
   private GroupReference createGroupReference(String name) {
     AccountGroup.UUID groupUuid = GroupUuid.make(name, serverUser);
-    return new GroupReference(groupUuid, name);
+    return GroupReference.create(groupUuid, name);
   }
 
   private InternalGroupCreation getGroupCreation(Sequences seqs, GroupReference groupReference) {
diff --git a/java/com/google/gerrit/server/ssh/SshAddressesModule.java b/java/com/google/gerrit/server/ssh/SshAddressesModule.java
index 0a6bcac..1159e06 100644
--- a/java/com/google/gerrit/server/ssh/SshAddressesModule.java
+++ b/java/com/google/gerrit/server/ssh/SshAddressesModule.java
@@ -40,7 +40,7 @@
   @Provides
   @Singleton
   @SshListenAddresses
-  public List<SocketAddress> getListenAddresses(@GerritServerConfig Config cfg) {
+  public List<SocketAddress> provideListenAddresses(@GerritServerConfig Config cfg) {
     List<SocketAddress> listen = Lists.newArrayListWithExpectedSize(2);
     String[] want = cfg.getStringList("sshd", null, "listenaddress");
     if (want == null || want.length == 0) {
@@ -71,7 +71,7 @@
   @Provides
   @Singleton
   @SshAdvertisedAddresses
-  List<String> getAdvertisedAddresses(
+  List<String> provideAdvertisedAddresses(
       @GerritServerConfig Config cfg, @SshListenAddresses List<SocketAddress> listen) {
     String[] want = cfg.getStringList("sshd", null, "advertisedaddress");
     if (want.length > 0) {
diff --git a/java/com/google/gerrit/server/submit/BranchTips.java b/java/com/google/gerrit/server/submit/BranchTips.java
new file mode 100644
index 0000000..d42517c
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/BranchTips.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Ref;
+
+/**
+ * Current branch tips, taking into account commits created during the submit process as well as
+ * submodule updates produced by this class.
+ */
+class BranchTips {
+
+  private final Map<BranchNameKey, CodeReviewCommit> branchTips = new HashMap<>();
+
+  /**
+   * Returns current tip of the branch, taking into account commits created during the submit
+   * process or submodule updates.
+   *
+   * @param branch branch
+   * @param repo repository to look for the branch if not cached
+   * @return the current tip. Empty if the branch doesn't exist in the repository
+   * @throws IOException Cannot access the underlying storage
+   */
+  Optional<CodeReviewCommit> getTip(BranchNameKey branch, OpenRepo repo) throws IOException {
+    CodeReviewCommit currentCommit;
+    if (branchTips.containsKey(branch)) {
+      currentCommit = branchTips.get(branch);
+    } else {
+      Ref r = repo.repo.exactRef(branch.branch());
+      if (r == null) {
+        return Optional.empty();
+      }
+      currentCommit = repo.rw.parseCommit(r.getObjectId());
+      branchTips.put(branch, currentCommit);
+    }
+
+    return Optional.of(currentCommit);
+  }
+
+  void put(BranchNameKey branch, CodeReviewCommit c) {
+    branchTips.put(branch, c);
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/CircularPathFinder.java b/java/com/google/gerrit/server/submit/CircularPathFinder.java
new file mode 100644
index 0000000..d1920da
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/CircularPathFinder.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+
+class CircularPathFinder {
+  private CircularPathFinder() {}
+
+  /**
+   * Prints a circular path according to the nodes in {@code p} and the start node {@code target}.
+   */
+  public static <T> String printCircularPath(Collection<T> p, T target) {
+    StringBuilder sb = new StringBuilder();
+    sb.append(target);
+    ArrayList<T> reverseP = new ArrayList<>(p);
+    Collections.reverse(reverseP);
+    for (T t : reverseP) {
+      sb.append("->");
+      sb.append(t);
+      if (t.equals(target)) {
+        break;
+      }
+    }
+    return sb.toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index c94d49e..4efa4c8 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
@@ -38,20 +40,23 @@
   interface Factory {
     EmailMerge create(
         Project.NameKey project,
-        Change.Id changeId,
+        Change change,
         Account.Id submitter,
-        NotifyResolver.Result notify);
+        NotifyResolver.Result notify,
+        RepoView repoView);
   }
 
   private final ExecutorService sendEmailsExecutor;
   private final MergedSender.Factory mergedSenderFactory;
   private final ThreadLocalRequestContext requestContext;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final MessageIdGenerator messageIdGenerator;
 
   private final Project.NameKey project;
-  private final Change.Id changeId;
+  private final Change change;
   private final Account.Id submitter;
   private final NotifyResolver.Result notify;
+  private final RepoView repoView;
 
   @Inject
   EmailMerge(
@@ -59,18 +64,22 @@
       MergedSender.Factory mergedSenderFactory,
       ThreadLocalRequestContext requestContext,
       IdentifiedUser.GenericFactory identifiedUserFactory,
+      MessageIdGenerator messageIdGenerator,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId,
+      @Assisted Change change,
       @Assisted @Nullable Account.Id submitter,
-      @Assisted NotifyResolver.Result notify) {
+      @Assisted NotifyResolver.Result notify,
+      @Assisted RepoView repoView) {
     this.sendEmailsExecutor = executor;
     this.mergedSenderFactory = mergedSenderFactory;
     this.requestContext = requestContext;
     this.identifiedUserFactory = identifiedUserFactory;
+    this.messageIdGenerator = messageIdGenerator;
     this.project = project;
-    this.changeId = changeId;
+    this.change = change;
     this.submitter = submitter;
     this.notify = notify;
+    this.repoView = repoView;
   }
 
   void sendAsync() {
@@ -82,14 +91,16 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      MergedSender cm = mergedSenderFactory.create(project, changeId);
+      MergedSender emailSender = mergedSenderFactory.create(project, change.getId());
       if (submitter != null) {
-        cm.setFrom(submitter);
+        emailSender.setFrom(submitter);
       }
-      cm.setNotify(notify);
-      cm.send();
+      emailSender.setNotify(notify);
+      emailSender.setMessageId(
+          messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
+      emailSender.send();
     } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email merged notification for %s", changeId);
+      logger.atSevere().withCause(e).log("Cannot email merged notification for %s", change.getId());
     } finally {
       requestContext.setContext(old);
     }
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index b8b8b55..0b05607 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -22,9 +22,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.registration.DynamicItem;
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index f96b0c5..4dc2c1c 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -32,15 +32,15 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
-import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 33c3584..edc3725 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.submit.CommitMergeStatus.EMPTY_COMMIT;
 import static com.google.gerrit.server.submit.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.BooleanProjectConfig;
@@ -62,20 +63,42 @@
     } catch (IOException | StorageException e) {
       throw new StorageException("Commit sorting failed", e);
     }
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
-    boolean first = true;
 
+    // We cannot rebase merge commits. This is why we integrate merge changes into the target branch
+    // the same way as if MERGE_IF_NECESSARY was the submit strategy. This means if needed we create
+    // a merge commit that integrates the merge change into the target branch.
+    // If we integrate a change series that consists out of a normal change and a merge change,
+    // where the merge change depends on the normal change, we must skip rebasing the normal change,
+    // because it already gets integrated by merging the merge change. If the rebasing of the normal
+    // change is not skipped, it would appear twice in the history after the submit is done (once
+    // through its rebased commit, and once through its original commit which is a parent of the
+    // merge change that was merged into the target branch. To skip the rebasing of the normal
+    // change, we call MergeUtil#reduceToMinimalMerge, as it excludes commits which will be
+    // implicitly integrated by merging the series. Then we use the MergeIfNecessaryOp to integrate
+    // the whole series.
+    // If on the other hand, we integrate a change series that consists out of a merge change and a
+    // normal change, where the normal change depends on the merge change, we can first integrate
+    // the merge change by a merge and then integrate the normal change by a rebase. In this case we
+    // do not want to call MergeUtil#reduceToMinimalMerge as we are not intending to integrate the
+    // whole series by a merge, but rather do the integration of the commits one by one.
+    boolean foundNonMerge = false;
     for (CodeReviewCommit c : sorted) {
       if (c.getParentCount() > 1) {
-        // Since there is a merge commit, sort and prune again using
-        // MERGE_IF_NECESSARY semantics to avoid creating duplicate
-        // commits.
-        //
+        if (!foundNonMerge) {
+          // found a merge change, but it doesn't depend on a normal change, this means we are not
+          // required to merge the whole series at once
+          continue;
+        }
+        // found a merge commit that depends on a normal change, this means we are required to merge
+        // the whole series at once
         sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted);
-        break;
+        return sorted.stream().map(n -> new MergeIfNecessaryOp(n)).collect(toList());
       }
+      foundNonMerge = true;
     }
 
+    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    boolean first = true;
     while (!sorted.isEmpty()) {
       CodeReviewCommit n = sorted.remove(0);
       if (first && args.mergeTip.getInitialTip() == null) {
@@ -87,7 +110,7 @@
       } else if (n.getParentCount() == 1) {
         ops.add(new RebaseOneOp(n));
       } else {
-        ops.add(new RebaseMultipleParentsOp(n));
+        ops.add(new MergeIfNecessaryOp(n));
       }
       first = false;
     }
@@ -254,8 +277,8 @@
     }
   }
 
-  private class RebaseMultipleParentsOp extends SubmitStrategyOp {
-    private RebaseMultipleParentsOp(CodeReviewCommit toMerge) {
+  private class MergeIfNecessaryOp extends SubmitStrategyOp {
+    private MergeIfNecessaryOp(CodeReviewCommit toMerge) {
       super(RebaseSubmitStrategy.this.args, toMerge);
     }
 
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index a4141be..3cc566b 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -22,8 +22,6 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -32,11 +30,11 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -393,24 +391,13 @@
     }
   }
 
-  private String getByAccountName() {
-    requireNonNull(submitter, "getByAccountName called before submitter populated");
-    Optional<Account> account =
-        args.accountCache.get(submitter.accountId()).map(AccountState::account);
-    if (account.isPresent() && account.get().fullName() != null) {
-      return " by " + account.get().fullName();
-    }
-    return "";
-  }
-
   private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s) {
     requireNonNull(s, "CommitMergeStatus may not be null");
     String txt = s.getDescription();
     if (s == CommitMergeStatus.CLEAN_MERGE) {
-      return message(ctx, commit.getPatchsetId(), txt + getByAccountName());
+      return message(ctx, commit.getPatchsetId(), txt);
     } else if (s == CommitMergeStatus.CLEAN_REBASE || s == CommitMergeStatus.CLEAN_PICK) {
-      return message(
-          ctx, commit.getPatchsetId(), txt + " as " + commit.name() + getByAccountName());
+      return message(ctx, commit.getPatchsetId(), txt + " as " + commit.name());
     } else if (s == CommitMergeStatus.SKIPPED_IDENTICAL_TREE) {
       return message(ctx, commit.getPatchsetId(), txt);
     } else if (s == CommitMergeStatus.ALREADY_MERGED) {
@@ -500,7 +487,12 @@
     // have failed fast in one of the other steps.
     try {
       args.mergedSenderFactory
-          .create(ctx.getProject(), getId(), submitter.accountId(), ctx.getNotify(getId()))
+          .create(
+              ctx.getProject(),
+              toMerge.change(),
+              submitter.accountId(),
+              ctx.getNotify(getId()),
+              ctx.getRepoView())
           .sendAsync();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", getId());
@@ -549,7 +541,7 @@
 
     // Modify the commit with gitlink update
     try {
-      return args.submoduleOp.composeGitlinksCommit(args.destBranch, commit);
+      return args.submoduleOp.amendGitlinksCommit(args.destBranch, commit);
     } catch (IOException e) {
       throw new StorageException(
           String.format("cannot update gitlink for the commit at branch %s", args.destBranch), e);
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index b48076194..a1ed373 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -14,19 +14,14 @@
 
 package com.google.gerrit.server.submit;
 
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmoduleSubscription;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -35,7 +30,6 @@
 import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateListener;
@@ -46,18 +40,13 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.Deque;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import org.apache.commons.lang.StringUtils;
 import org.eclipse.jgit.dircache.DirCache;
@@ -71,10 +60,8 @@
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.RefSpec;
 
 public class SubmoduleOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -82,9 +69,11 @@
   /** Only used for branches without code review changes */
   public class GitlinkOp implements RepoOnlyOp {
     private final BranchNameKey branch;
+    private final BranchTips currentBranchTips;
 
-    GitlinkOp(BranchNameKey branch) {
+    GitlinkOp(BranchNameKey branch, BranchTips branchTips) {
       this.branch = branch;
+      this.currentBranchTips = branchTips;
     }
 
     @Override
@@ -92,309 +81,68 @@
       CodeReviewCommit c = composeGitlinksCommit(branch);
       if (c != null) {
         ctx.addRefUpdate(c.getParent(0), c, branch.branch());
-        addBranchTip(branch, c);
+        currentBranchTips.put(branch, c);
       }
     }
   }
 
   @Singleton
   public static class Factory {
-    private final GitModules.Factory gitmodulesFactory;
+    private final SubscriptionGraph.Factory subscriptionGraphFactory;
     private final Provider<PersonIdent> serverIdent;
     private final Config cfg;
-    private final ProjectCache projectCache;
 
     @Inject
     Factory(
-        GitModules.Factory gitmodulesFactory,
+        SubscriptionGraph.Factory subscriptionGraphFactory,
         @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        @GerritServerConfig Config cfg,
-        ProjectCache projectCache) {
-      this.gitmodulesFactory = gitmodulesFactory;
+        @GerritServerConfig Config cfg) {
+      this.subscriptionGraphFactory = subscriptionGraphFactory;
       this.serverIdent = serverIdent;
       this.cfg = cfg;
-      this.projectCache = projectCache;
     }
 
     public SubmoduleOp create(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
         throws SubmoduleConflictException {
-      return new SubmoduleOp(
-          gitmodulesFactory, serverIdent.get(), cfg, projectCache, updatedBranches, orm);
+      SubscriptionGraph subscriptionGraph;
+      if (cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true)) {
+        subscriptionGraph = subscriptionGraphFactory.compute(updatedBranches, orm);
+      } else {
+        logger.atFine().log("Updating superprojects disabled");
+        subscriptionGraph =
+            SubscriptionGraph.createEmptyGraph(ImmutableSet.copyOf(updatedBranches));
+      }
+      return new SubmoduleOp(serverIdent.get(), cfg, orm, subscriptionGraph);
     }
   }
 
-  private final GitModules.Factory gitmodulesFactory;
   private final PersonIdent myIdent;
-  private final ProjectCache projectCache;
   private final VerboseSuperprojectUpdate verboseSuperProject;
-  private final boolean enableSuperProjectSubscriptions;
   private final long maxCombinedCommitMessageSize;
   private final long maxCommitMessages;
   private final MergeOpRepoManager orm;
-  private final Map<BranchNameKey, GitModules> branchGitModules;
+  private final SubscriptionGraph subscriptionGraph;
 
-  /** Branches updated as part of the enclosing submit or push batch. */
-  private final ImmutableSet<BranchNameKey> updatedBranches;
-
-  /**
-   * Current branch tips, taking into account commits created during the submit process as well as
-   * submodule updates produced by this class.
-   */
-  private final Map<BranchNameKey, CodeReviewCommit> branchTips;
-
-  /**
-   * Branches in a superproject that contain submodule subscriptions, plus branches in submodules
-   * which are subscribed to by some superproject.
-   */
-  private final Set<BranchNameKey> affectedBranches;
-
-  /** Copy of {@link #affectedBranches}, sorted by submodule traversal order. */
-  private final ImmutableSet<BranchNameKey> sortedBranches;
-
-  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
-  private final SetMultimap<BranchNameKey, SubmoduleSubscription> targets;
-
-  /**
-   * Multimap of superproject name to all branch names within that superproject which have submodule
-   * subscriptions.
-   */
-  private final SetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
+  private final BranchTips branchTips = new BranchTips();
 
   private SubmoduleOp(
-      GitModules.Factory gitmodulesFactory,
       PersonIdent myIdent,
       Config cfg,
-      ProjectCache projectCache,
-      Set<BranchNameKey> updatedBranches,
-      MergeOpRepoManager orm)
-      throws SubmoduleConflictException {
-    this.gitmodulesFactory = gitmodulesFactory;
+      MergeOpRepoManager orm,
+      SubscriptionGraph subscriptionGraph) {
     this.myIdent = myIdent;
-    this.projectCache = projectCache;
     this.verboseSuperProject =
         cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
-    this.enableSuperProjectSubscriptions =
-        cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true);
     this.maxCombinedCommitMessageSize =
         cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
     this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
     this.orm = orm;
-    this.updatedBranches = ImmutableSet.copyOf(updatedBranches);
-    this.targets = MultimapBuilder.hashKeys().hashSetValues().build();
-    this.affectedBranches = new HashSet<>();
-    this.branchTips = new HashMap<>();
-    this.branchGitModules = new HashMap<>();
-    this.branchesByProject = MultimapBuilder.hashKeys().hashSetValues().build();
-    this.sortedBranches = calculateSubscriptionMaps();
-  }
-
-  /**
-   * Calculate the internal maps used by the operation.
-   *
-   * <p>In addition to the return value, the following fields are populated as a side effect:
-   *
-   * <ul>
-   *   <li>{@link #affectedBranches}
-   *   <li>{@link #targets}
-   *   <li>{@link #branchesByProject}
-   * </ul>
-   *
-   * @return the ordered set to be stored in {@link #sortedBranches}.
-   */
-  // TODO(dborowitz): This setup process is hard to follow, in large part due to the accumulation of
-  // mutable maps, which makes this whole class difficult to understand.
-  //
-  // A cleaner architecture for this process might be:
-  //   1. Separate out the code to parse submodule subscriptions and build up an in-memory data
-  //      structure representing the subscription graph, using a separate class with a properly-
-  //      documented interface.
-  //   2. Walk the graph to produce a work plan. This would be a list of items indicating: create a
-  //      commit in project X reading branch tips for submodules S1..Sn and updating gitlinks in X.
-  //   3. Execute the work plan, i.e. convert the items into BatchUpdate.Ops and add them to the
-  //      relevant updates.
-  //
-  // In addition to improving readability, this approach has the advantage of making (1) and (2)
-  // testable using small tests.
-  private ImmutableSet<BranchNameKey> calculateSubscriptionMaps()
-      throws SubmoduleConflictException {
-    if (!enableSuperProjectSubscriptions) {
-      logger.atFine().log("Updating superprojects disabled");
-      return null;
-    }
-
-    logger.atFine().log("Calculating superprojects - submodules map");
-    LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
-    for (BranchNameKey updatedBranch : updatedBranches) {
-      if (allVisited.contains(updatedBranch)) {
-        continue;
-      }
-
-      searchForSuperprojects(updatedBranch, new LinkedHashSet<>(), allVisited);
-    }
-
-    // Since the searchForSuperprojects will add all branches (related or
-    // unrelated) and ensure the superproject's branches get added first before
-    // a submodule branch. Need remove all unrelated branches and reverse
-    // the order.
-    allVisited.retainAll(affectedBranches);
-    reverse(allVisited);
-    return ImmutableSet.copyOf(allVisited);
-  }
-
-  private void searchForSuperprojects(
-      BranchNameKey current,
-      LinkedHashSet<BranchNameKey> currentVisited,
-      LinkedHashSet<BranchNameKey> allVisited)
-      throws SubmoduleConflictException {
-    logger.atFine().log("Now processing %s", current);
-
-    if (currentVisited.contains(current)) {
-      throw new SubmoduleConflictException(
-          "Branch level circular subscriptions detected:  "
-              + printCircularPath(currentVisited, current));
-    }
-
-    if (allVisited.contains(current)) {
-      return;
-    }
-
-    currentVisited.add(current);
-    try {
-      Collection<SubmoduleSubscription> subscriptions =
-          superProjectSubscriptionsForSubmoduleBranch(current);
-      for (SubmoduleSubscription sub : subscriptions) {
-        BranchNameKey superBranch = sub.getSuperProject();
-        searchForSuperprojects(superBranch, currentVisited, allVisited);
-        targets.put(superBranch, sub);
-        branchesByProject.put(superBranch.project(), superBranch);
-        affectedBranches.add(superBranch);
-        affectedBranches.add(sub.getSubmodule());
-      }
-    } catch (IOException e) {
-      throw new StorageException("Cannot find superprojects for " + current, e);
-    }
-    currentVisited.remove(current);
-    allVisited.add(current);
-  }
-
-  private static <T> void reverse(LinkedHashSet<T> set) {
-    if (set == null) {
-      return;
-    }
-
-    Deque<T> q = new ArrayDeque<>(set);
-    set.clear();
-
-    while (!q.isEmpty()) {
-      set.add(q.removeLast());
-    }
-  }
-
-  private <T> String printCircularPath(LinkedHashSet<T> p, T target) {
-    StringBuilder sb = new StringBuilder();
-    sb.append(target);
-    ArrayList<T> reverseP = new ArrayList<>(p);
-    Collections.reverse(reverseP);
-    for (T t : reverseP) {
-      sb.append("->");
-      sb.append(t);
-      if (t.equals(target)) {
-        break;
-      }
-    }
-    return sb.toString();
-  }
-
-  private Collection<BranchNameKey> getDestinationBranches(BranchNameKey src, SubscribeSection s)
-      throws IOException {
-    Collection<BranchNameKey> ret = new HashSet<>();
-    logger.atFine().log("Inspecting SubscribeSection %s", s);
-    for (RefSpec r : s.getMatchingRefSpecs()) {
-      logger.atFine().log("Inspecting [matching] ref %s", r);
-      if (!r.matchSource(src.branch())) {
-        continue;
-      }
-      if (r.isWildcard()) {
-        // refs/heads/*[:refs/somewhere/*]
-        ret.add(
-            BranchNameKey.create(
-                s.getProject(), r.expandFromSource(src.branch()).getDestination()));
-      } else {
-        // e.g. refs/heads/master[:refs/heads/stable]
-        String dest = r.getDestination();
-        if (dest == null) {
-          dest = r.getSource();
-        }
-        ret.add(BranchNameKey.create(s.getProject(), dest));
-      }
-    }
-
-    for (RefSpec r : s.getMultiMatchRefSpecs()) {
-      logger.atFine().log("Inspecting [all] ref %s", r);
-      if (!r.matchSource(src.branch())) {
-        continue;
-      }
-      OpenRepo or;
-      try {
-        or = orm.getRepo(s.getProject());
-      } catch (NoSuchProjectException e) {
-        // A project listed a non existent project to be allowed
-        // to subscribe to it. Allow this for now, i.e. no exception is
-        // thrown.
-        continue;
-      }
-
-      for (Ref ref : or.repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS)) {
-        if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
-          continue;
-        }
-        BranchNameKey b = BranchNameKey.create(s.getProject(), ref.getName());
-        if (!ret.contains(b)) {
-          ret.add(b);
-        }
-      }
-    }
-    logger.atFine().log("Returning possible branches: %s for project %s", ret, s.getProject());
-    return ret;
+    this.subscriptionGraph = subscriptionGraph;
   }
 
   @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
-  public Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
-      BranchNameKey srcBranch) throws IOException {
-    logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
-    Collection<SubmoduleSubscription> ret = new ArrayList<>();
-    Project.NameKey srcProject = srcBranch.project();
-    for (SubscribeSection s :
-        projectCache
-            .get(srcProject)
-            .orElseThrow(illegalState(srcProject))
-            .getSubscribeSections(srcBranch)) {
-      logger.atFine().log("Checking subscribe section %s", s);
-      Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s);
-      for (BranchNameKey targetBranch : branches) {
-        Project.NameKey targetProject = targetBranch.project();
-        try {
-          OpenRepo or = orm.getRepo(targetProject);
-          ObjectId id = or.repo.resolve(targetBranch.branch());
-          if (id == null) {
-            logger.atFine().log("The branch %s doesn't exist.", targetBranch);
-            continue;
-          }
-        } catch (NoSuchProjectException e) {
-          logger.atFine().log("The project %s doesn't exist", targetProject);
-          continue;
-        }
-
-        GitModules m = branchGitModules.get(targetBranch);
-        if (m == null) {
-          m = gitmodulesFactory.create(targetBranch, orm);
-          branchGitModules.put(targetBranch, m);
-        }
-        ret.addAll(m.subscribedTo(srcBranch));
-      }
-    }
-    logger.atFine().log("Calculated superprojects for %s are %s", srcBranch, ret);
-    return ret;
+  public boolean hasSuperproject(BranchNameKey branch) {
+    return subscriptionGraph.hasSuperproject(branch);
   }
 
   public void updateSuperProjects() throws RestApiException {
@@ -407,11 +155,11 @@
     try {
       for (Project.NameKey project : projects) {
         // only need superprojects
-        if (branchesByProject.containsKey(project)) {
+        if (subscriptionGraph.isAffectedSuperProject(project)) {
           superProjects.add(project);
           // get a new BatchUpdate for the super project
           OpenRepo or = orm.getRepo(project);
-          for (BranchNameKey branch : branchesByProject.get(project)) {
+          for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
             addOp(or.getUpdate(), branch);
           }
         }
@@ -432,18 +180,13 @@
       throw new StorageException("Cannot access superproject", e);
     }
 
-    CodeReviewCommit currentCommit;
-    if (branchTips.containsKey(subscriber)) {
-      currentCommit = branchTips.get(subscriber);
-    } else {
-      Ref r = or.repo.exactRef(subscriber.branch());
-      if (r == null) {
-        throw new SubmoduleConflictException(
-            "The branch was probably deleted from the subscriber repository");
-      }
-      currentCommit = or.rw.parseCommit(r.getObjectId());
-      addBranchTip(subscriber, currentCommit);
-    }
+    CodeReviewCommit currentCommit =
+        branchTips
+            .getTip(subscriber, or)
+            .orElseThrow(
+                () ->
+                    new SubmoduleConflictException(
+                        "The branch was probably deleted from the subscriber repository"));
 
     StringBuilder msgbuf = new StringBuilder();
     PersonIdent author = null;
@@ -452,7 +195,7 @@
     int count = 0;
 
     List<SubmoduleSubscription> subscriptions =
-        targets.get(subscriber).stream()
+        subscriptionGraph.getSubscriptions(subscriber).stream()
             .sorted(comparing(SubmoduleSubscription::getPath))
             .collect(toList());
     for (SubmoduleSubscription s : subscriptions) {
@@ -493,7 +236,7 @@
   }
 
   /** Amend an existing commit with gitlink updates */
-  CodeReviewCommit composeGitlinksCommit(BranchNameKey subscriber, CodeReviewCommit currentCommit)
+  CodeReviewCommit amendGitlinksCommit(BranchNameKey subscriber, CodeReviewCommit currentCommit)
       throws IOException, SubmoduleConflictException {
     OpenRepo or;
     try {
@@ -505,7 +248,7 @@
     StringBuilder msgbuf = new StringBuilder();
     DirCache dc = readTree(or.rw, currentCommit);
     DirCacheEditor ed = dc.editor();
-    for (SubmoduleSubscription s : targets.get(subscriber)) {
+    for (SubmoduleSubscription s : subscriptionGraph.getSubscriptions(subscriber)) {
       updateSubmodule(dc, ed, msgbuf, s);
     }
     ed.finish();
@@ -574,25 +317,15 @@
       }
     }
 
-    final CodeReviewCommit newCommit;
-    if (branchTips.containsKey(s.getSubmodule())) {
-      // This submodule's branch was updated as part of this specific submit batch: update the
-      // gitlink to point to the new commit from the batch.
-      newCommit = branchTips.get(s.getSubmodule());
-    } else {
-      // For whatever reason, this submodule was not updated as part of this submit batch, but the
-      // superproject is still subscribed to this branch. Re-read the ref to see if anything has
-      // changed since the last time the gitlink was updated, and roll that update into the same
-      // commit as all other submodule updates.
-      Ref ref = subOr.repo.getRefDatabase().exactRef(s.getSubmodule().branch());
-      if (ref == null) {
-        ed.add(new DeletePath(s.getPath()));
-        return null;
-      }
-      newCommit = subOr.rw.parseCommit(ref.getObjectId());
-      addBranchTip(s.getSubmodule(), newCommit);
+    Optional<CodeReviewCommit> maybeNewCommit = branchTips.getTip(s.getSubmodule(), subOr);
+    if (!maybeNewCommit.isPresent()) {
+      // This submodule branch is neither in the submit set nor in the repository itself
+      ed.add(new DeletePath(s.getPath()));
+      return null;
     }
 
+    CodeReviewCommit newCommit = maybeNewCommit.get();
+
     if (Objects.equals(newCommit, oldCommit)) {
       // gitlink have already been updated for this submodule
       return null;
@@ -678,11 +411,11 @@
 
   ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleConflictException {
     LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
-    for (Project.NameKey project : branchesByProject.keySet()) {
+    for (Project.NameKey project : subscriptionGraph.getAffectedSuperProjects()) {
       addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
     }
 
-    for (BranchNameKey branch : updatedBranches) {
+    for (BranchNameKey branch : subscriptionGraph.getUpdatedBranches()) {
       projects.add(branch.project());
     }
     return ImmutableSet.copyOf(projects);
@@ -695,7 +428,8 @@
       throws SubmoduleConflictException {
     if (current.contains(project)) {
       throw new SubmoduleConflictException(
-          "Project level circular subscriptions detected:  " + printCircularPath(current, project));
+          "Project level circular subscriptions detected:  "
+              + CircularPathFinder.printCircularPath(current, project));
     }
 
     if (projects.contains(project)) {
@@ -704,8 +438,8 @@
 
     current.add(project);
     Set<Project.NameKey> subprojects = new HashSet<>();
-    for (BranchNameKey branch : branchesByProject.get(project)) {
-      Collection<SubmoduleSubscription> subscriptions = targets.get(branch);
+    for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
+      Collection<SubmoduleSubscription> subscriptions = subscriptionGraph.getSubscriptions(branch);
       for (SubmoduleSubscription s : subscriptions) {
         subprojects.add(s.getSubmodule().project());
       }
@@ -721,15 +455,13 @@
 
   ImmutableSet<BranchNameKey> getBranchesInOrder() {
     LinkedHashSet<BranchNameKey> branches = new LinkedHashSet<>();
-    if (sortedBranches != null) {
-      branches.addAll(sortedBranches);
-    }
-    branches.addAll(updatedBranches);
+    branches.addAll(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches());
+    branches.addAll(subscriptionGraph.getUpdatedBranches());
     return ImmutableSet.copyOf(branches);
   }
 
   boolean hasSubscription(BranchNameKey branch) {
-    return targets.containsKey(branch);
+    return subscriptionGraph.hasSubscription(branch);
   }
 
   void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
@@ -737,6 +469,6 @@
   }
 
   void addOp(BatchUpdate bu, BranchNameKey branch) {
-    bu.addRepoOnlyOp(new GitlinkOp(branch));
+    bu.addRepoOnlyOp(new GitlinkOp(branch, branchTips));
   }
 }
diff --git a/java/com/google/gerrit/server/submit/SubscriptionGraph.java b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
new file mode 100644
index 0000000..f6cffbd
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
@@ -0,0 +1,382 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.entities.SubscribeSection;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+
+/**
+ * A container which stores subscription relationship. A SubscriptionGraph is calculated every time
+ * changes are pushed. Some branches are updated in these changes, and if these branches are
+ * subscribed by other projects, SubscriptionGraph would record information about these updated
+ * branches and branches/projects affected.
+ */
+public class SubscriptionGraph {
+  /** Branches updated as part of the enclosing submit or push batch. */
+  private final ImmutableSet<BranchNameKey> updatedBranches;
+
+  /**
+   * All branches affected, including those in superprojects and submodules, sorted by submodule
+   * traversal order. To support nested subscriptions, GitLink commits need to be updated in order.
+   * The closer to topological "leaf", the earlier a commit should be updated.
+   *
+   * <p>For example, there are three projects, top level project p1 subscribed to p2, p2 subscribed
+   * to bottom level project p3. When submit a change for p3. We need update both p2 and p1. To be
+   * more precise, we need update p2 first and then update p1.
+   */
+  private final ImmutableSet<BranchNameKey> sortedBranches;
+
+  /** Multimap of superproject branch to submodule subscriptions contained in that branch. */
+  private final ImmutableSetMultimap<BranchNameKey, SubmoduleSubscription> targets;
+
+  /**
+   * Multimap of superproject name to all branch names within that superproject which have submodule
+   * subscriptions.
+   */
+  private final ImmutableSetMultimap<Project.NameKey, BranchNameKey> branchesByProject;
+
+  /** All branches subscribed by other projects. */
+  private final ImmutableSet<BranchNameKey> subscribedBranches;
+
+  public SubscriptionGraph(
+      Set<BranchNameKey> updatedBranches,
+      SetMultimap<BranchNameKey, SubmoduleSubscription> targets,
+      SetMultimap<Project.NameKey, BranchNameKey> branchesByProject,
+      Set<BranchNameKey> subscribedBranches,
+      Set<BranchNameKey> sortedBranches) {
+    this.updatedBranches = ImmutableSet.copyOf(updatedBranches);
+    this.targets = ImmutableSetMultimap.copyOf(targets);
+    this.branchesByProject = ImmutableSetMultimap.copyOf(branchesByProject);
+    this.subscribedBranches = ImmutableSet.copyOf(subscribedBranches);
+    this.sortedBranches = ImmutableSet.copyOf(sortedBranches);
+  }
+
+  /** Returns an empty {@code SubscriptionGraph}. */
+  static SubscriptionGraph createEmptyGraph(Set<BranchNameKey> updatedBranches) {
+    return new SubscriptionGraph(
+        updatedBranches,
+        ImmutableSetMultimap.of(),
+        ImmutableSetMultimap.of(),
+        ImmutableSet.of(),
+        ImmutableSet.of());
+  }
+
+  /** Get branches updated as part of the enclosing submit or push batch. */
+  public ImmutableSet<BranchNameKey> getUpdatedBranches() {
+    return updatedBranches;
+  }
+
+  /** Get all superprojects affected. */
+  public ImmutableSet<Project.NameKey> getAffectedSuperProjects() {
+    return branchesByProject.keySet();
+  }
+
+  /** See if a {@code project} is a superproject affected. */
+  boolean isAffectedSuperProject(Project.NameKey project) {
+    return branchesByProject.containsKey(project);
+  }
+
+  /**
+   * Returns all branches within the superproject {@code project} which have submodule
+   * subscriptions.
+   */
+  public ImmutableSet<BranchNameKey> getAffectedSuperBranches(Project.NameKey project) {
+    return branchesByProject.get(project);
+  }
+
+  /**
+   * Get all affected branches, including the submodule branches and superproject branches, sorted
+   * by traversal order.
+   *
+   * @see SubscriptionGraph#sortedBranches
+   */
+  public ImmutableSet<BranchNameKey> getSortedSuperprojectAndSubmoduleBranches() {
+    return sortedBranches;
+  }
+
+  /** Check if a {@code branch} is a submodule of a superproject. */
+  public boolean hasSuperproject(BranchNameKey branch) {
+    return subscribedBranches.contains(branch);
+  }
+
+  /** See if a {@code branch} is a superproject branch affected. */
+  public boolean hasSubscription(BranchNameKey branch) {
+    return targets.containsKey(branch);
+  }
+
+  /** Get all related {@code SubmoduleSubscription}s whose super branch is {@code branch}. */
+  public ImmutableSet<SubmoduleSubscription> getSubscriptions(BranchNameKey branch) {
+    return targets.get(branch);
+  }
+
+  public interface Factory {
+    SubscriptionGraph compute(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
+        throws SubmoduleConflictException;
+  }
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(Factory.class).to(DefaultFactory.class);
+    }
+  }
+
+  static class DefaultFactory implements Factory {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+    private final ProjectCache projectCache;
+    private final GitModules.Factory gitmodulesFactory;
+
+    @Inject
+    DefaultFactory(GitModules.Factory gitmodulesFactory, ProjectCache projectCache) {
+      this.gitmodulesFactory = gitmodulesFactory;
+      this.projectCache = projectCache;
+    }
+
+    @Override
+    public SubscriptionGraph compute(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
+        throws SubmoduleConflictException {
+      Map<BranchNameKey, GitModules> branchGitModules = new HashMap<>();
+      // All affected branches, including those in superprojects and submodules.
+      Set<BranchNameKey> affectedBranches = new HashSet<>();
+
+      // See SubscriptionGraph#targets.
+      SetMultimap<BranchNameKey, SubmoduleSubscription> targets =
+          MultimapBuilder.hashKeys().hashSetValues().build();
+
+      // See SubscriptionGraph#branchesByProject.
+      SetMultimap<Project.NameKey, BranchNameKey> branchesByProject =
+          MultimapBuilder.hashKeys().hashSetValues().build();
+
+      // See SubscriptionGraph#subscribedBranches.
+      Set<BranchNameKey> subscribedBranches = new HashSet<>();
+
+      Set<BranchNameKey> sortedBranches =
+          calculateSubscriptionMaps(
+              updatedBranches,
+              affectedBranches,
+              targets,
+              branchesByProject,
+              subscribedBranches,
+              branchGitModules,
+              orm);
+
+      return new SubscriptionGraph(
+          updatedBranches, targets, branchesByProject, subscribedBranches, sortedBranches);
+    }
+
+    /**
+     * Calculate the internal maps used by the operation.
+     *
+     * <p>In addition to the return value, the following fields are populated as a side effect:
+     *
+     * <ul>
+     *   <li>{@code affectedBranches}
+     *   <li>{@code targets}
+     *   <li>{@code branchesByProject}
+     *   <li>{@code subscribedBranches}
+     * </ul>
+     *
+     * @return the ordered set to be stored in {@link #sortedBranches}.
+     */
+    private Set<BranchNameKey> calculateSubscriptionMaps(
+        Set<BranchNameKey> updatedBranches,
+        Set<BranchNameKey> affectedBranches,
+        SetMultimap<BranchNameKey, SubmoduleSubscription> targets,
+        SetMultimap<Project.NameKey, BranchNameKey> branchesByProject,
+        Set<BranchNameKey> subscribedBranches,
+        Map<BranchNameKey, GitModules> branchGitModules,
+        MergeOpRepoManager orm)
+        throws SubmoduleConflictException {
+      logger.atFine().log("Calculating superprojects - submodules map");
+      LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
+      for (BranchNameKey updatedBranch : updatedBranches) {
+        if (allVisited.contains(updatedBranch)) {
+          continue;
+        }
+
+        searchForSuperprojects(
+            updatedBranch,
+            new LinkedHashSet<>(),
+            allVisited,
+            affectedBranches,
+            targets,
+            branchesByProject,
+            subscribedBranches,
+            branchGitModules,
+            orm);
+      }
+
+      // Since the searchForSuperprojects will add all branches (related or
+      // unrelated) and ensure the superproject's branches get added first before
+      // a submodule branch. Need remove all unrelated branches and reverse
+      // the order.
+      allVisited.retainAll(affectedBranches);
+      reverse(allVisited);
+      return allVisited;
+    }
+
+    private void searchForSuperprojects(
+        BranchNameKey current,
+        LinkedHashSet<BranchNameKey> currentVisited,
+        LinkedHashSet<BranchNameKey> allVisited,
+        Set<BranchNameKey> affectedBranches,
+        SetMultimap<BranchNameKey, SubmoduleSubscription> targets,
+        SetMultimap<Project.NameKey, BranchNameKey> branchesByProject,
+        Set<BranchNameKey> subscribedBranches,
+        Map<BranchNameKey, GitModules> branchGitModules,
+        MergeOpRepoManager orm)
+        throws SubmoduleConflictException {
+      logger.atFine().log("Now processing %s", current);
+
+      if (currentVisited.contains(current)) {
+        throw new SubmoduleConflictException(
+            "Branch level circular subscriptions detected:  "
+                + CircularPathFinder.printCircularPath(currentVisited, current));
+      }
+
+      if (allVisited.contains(current)) {
+        return;
+      }
+
+      currentVisited.add(current);
+      try {
+        Collection<SubmoduleSubscription> subscriptions =
+            superProjectSubscriptionsForSubmoduleBranch(current, branchGitModules, orm);
+        for (SubmoduleSubscription sub : subscriptions) {
+          BranchNameKey superBranch = sub.getSuperProject();
+          searchForSuperprojects(
+              superBranch,
+              currentVisited,
+              allVisited,
+              affectedBranches,
+              targets,
+              branchesByProject,
+              subscribedBranches,
+              branchGitModules,
+              orm);
+          targets.put(superBranch, sub);
+          branchesByProject.put(superBranch.project(), superBranch);
+          affectedBranches.add(superBranch);
+          affectedBranches.add(sub.getSubmodule());
+          subscribedBranches.add(sub.getSubmodule());
+        }
+      } catch (IOException e) {
+        throw new StorageException("Cannot find superprojects for " + current, e);
+      }
+      currentVisited.remove(current);
+      allVisited.add(current);
+    }
+
+    private Collection<BranchNameKey> getDestinationBranches(
+        BranchNameKey src, SubscribeSection s, MergeOpRepoManager orm) throws IOException {
+      OpenRepo or;
+      try {
+        or = orm.getRepo(s.project());
+      } catch (NoSuchProjectException e) {
+        // A project listed a non existent project to be allowed
+        // to subscribe to it. Allow this for now, i.e. no exception is
+        // thrown.
+        return s.getDestinationBranches(src, ImmutableList.of());
+      }
+
+      List<Ref> refs = or.repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS);
+      return s.getDestinationBranches(src, refs);
+    }
+
+    private Collection<SubmoduleSubscription> superProjectSubscriptionsForSubmoduleBranch(
+        BranchNameKey srcBranch,
+        Map<BranchNameKey, GitModules> branchGitModules,
+        MergeOpRepoManager orm)
+        throws IOException {
+      logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
+      Collection<SubmoduleSubscription> ret = new ArrayList<>();
+      Project.NameKey srcProject = srcBranch.project();
+      for (SubscribeSection s :
+          projectCache
+              .get(srcProject)
+              .orElseThrow(illegalState(srcProject))
+              .getSubscribeSections(srcBranch)) {
+        logger.atFine().log("Checking subscribe section %s", s);
+        Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s, orm);
+        for (BranchNameKey targetBranch : branches) {
+          Project.NameKey targetProject = targetBranch.project();
+          try {
+            OpenRepo or = orm.getRepo(targetProject);
+            ObjectId id = or.repo.resolve(targetBranch.branch());
+            if (id == null) {
+              logger.atFine().log("The branch %s doesn't exist.", targetBranch);
+              continue;
+            }
+          } catch (NoSuchProjectException e) {
+            logger.atFine().log("The project %s doesn't exist", targetProject);
+            continue;
+          }
+
+          GitModules m = branchGitModules.get(targetBranch);
+          if (m == null) {
+            m = gitmodulesFactory.create(targetBranch, orm);
+            branchGitModules.put(targetBranch, m);
+          }
+          ret.addAll(m.subscribedTo(srcBranch));
+        }
+      }
+      logger.atFine().log("Calculated superprojects for %s are %s", srcBranch, ret);
+      return ret;
+    }
+
+    private static <T> void reverse(LinkedHashSet<T> set) {
+      if (set == null) {
+        return;
+      }
+
+      Deque<T> q = new ArrayDeque<>(set);
+      set.clear();
+
+      while (!q.isEmpty()) {
+        set.add(q.removeLast());
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
new file mode 100644
index 0000000..56b1dda
--- /dev/null
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -0,0 +1,117 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.util;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
+import com.google.gerrit.server.mail.send.AttentionSetSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
+import com.google.gerrit.server.update.Context;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+
+public class AttentionSetEmail implements Runnable, RequestContext {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+
+    /**
+     * factory for sending an email when adding users to the attention set or removing them from it.
+     *
+     * @param sender sender in charge of sending the email, can be {@link AddToAttentionSetSender}
+     *     or {@link RemoveFromAttentionSetSender}.
+     * @param ctx context for sending the email.
+     * @param change the change that the user was added/removed in.
+     * @param reason reason for adding/removing the user.
+     * @param messageId messageId for tracking the email.
+     * @param attentionUserId the user added/removed.
+     */
+    AttentionSetEmail create(
+        AttentionSetSender sender,
+        Context ctx,
+        Change change,
+        String reason,
+        MessageIdGenerator.MessageId messageId,
+        Account.Id attentionUserId);
+  }
+
+  private ExecutorService sendEmailsExecutor;
+  private AttentionSetSender sender;
+  private Context ctx;
+  private Change change;
+  private String reason;
+
+  private MessageIdGenerator.MessageId messageId;
+  private Account.Id attentionUserId;
+
+  @Inject
+  AttentionSetEmail(
+      @SendEmailExecutor ExecutorService executor,
+      @Assisted AttentionSetSender sender,
+      @Assisted Context ctx,
+      @Assisted Change change,
+      @Assisted String reason,
+      @Assisted MessageIdGenerator.MessageId messageId,
+      @Assisted Account.Id attentionUserId) {
+    this.sendEmailsExecutor = executor;
+    this.sender = sender;
+    this.ctx = ctx;
+    this.change = change;
+    this.reason = reason;
+    this.messageId = messageId;
+    this.attentionUserId = attentionUserId;
+  }
+
+  public void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+  }
+
+  @Override
+  public void run() {
+    try {
+      AccountState accountState =
+          ctx.getUser().isIdentifiedUser() ? ctx.getUser().asIdentifiedUser().state() : null;
+      if (accountState != null) {
+        sender.setFrom(accountState.account().id());
+      }
+      sender.setNotify(ctx.getNotify(change.getId()));
+      sender.setAttentionSetUser(attentionUserId);
+      sender.setReason(reason);
+      sender.setMessageId(messageId);
+      sender.send();
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "send-email comments";
+  }
+
+  @Override
+  public CurrentUser getUser() {
+    return ctx.getUser();
+  }
+}
diff --git a/java/com/google/gerrit/server/util/AttentionSetUtil.java b/java/com/google/gerrit/server/util/AttentionSetUtil.java
index ad2c98c..62cad3f 100644
--- a/java/com/google/gerrit/server/util/AttentionSetUtil.java
+++ b/java/com/google/gerrit/server/util/AttentionSetUtil.java
@@ -14,13 +14,17 @@
 
 package com.google.gerrit.server.util;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import java.util.Collection;
 
 /** Common helpers for dealing with attention set data structures. */
 public class AttentionSetUtil {
+
   /** Returns only updates where the user was added. */
   public static ImmutableSet<AttentionSetUpdate> additionsOnly(
       Collection<AttentionSetUpdate> updates) {
@@ -29,5 +33,21 @@
         .collect(ImmutableSet.toImmutableSet());
   }
 
+  /**
+   * Validates the input for AttentionSetInput. This must be called for all inputs that relate to
+   * adding or removing attention set entries, except for {@link
+   * com.google.gerrit.server.restapi.change.RemoveFromAttentionSet}.
+   */
+  public static void validateInput(AttentionSetInput input) throws BadRequestException {
+    input.user = Strings.nullToEmpty(input.user).trim();
+    if (input.user.isEmpty()) {
+      throw new BadRequestException("missing field: user");
+    }
+    input.reason = Strings.nullToEmpty(input.reason).trim();
+    if (input.reason.isEmpty()) {
+      throw new BadRequestException("missing field: reason");
+    }
+  }
+
   private AttentionSetUtil() {}
 }
diff --git a/java/com/google/gerrit/server/util/LabelVote.java b/java/com/google/gerrit/server/util/LabelVote.java
index a03c1f2..038fe2c 100644
--- a/java/com/google/gerrit/server/util/LabelVote.java
+++ b/java/com/google/gerrit/server/util/LabelVote.java
@@ -18,7 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 
 /** A single vote on a label, consisting of a label name and a value. */
 @AutoValue
diff --git a/java/com/google/gerrit/server/util/MostSpecificComparator.java b/java/com/google/gerrit/server/util/MostSpecificComparator.java
index b22617c..ac33902 100644
--- a/java/com/google/gerrit/server/util/MostSpecificComparator.java
+++ b/java/com/google/gerrit/server/util/MostSpecificComparator.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.util;
 
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.server.project.RefPattern;
 import java.util.Comparator;
 import org.apache.commons.lang.StringUtils;
diff --git a/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java b/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
index 996ad87..76034ce 100644
--- a/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
+++ b/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.validators;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
 import java.util.Map;
 import java.util.Set;
 
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 81964be..78a7381 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -22,8 +22,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.CharStreams;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.GerritApi;
diff --git a/java/com/google/gerrit/testing/FakeEmailSender.java b/java/com/google/gerrit/testing/FakeEmailSender.java
index a60995b..fec9b27 100644
--- a/java/com/google/gerrit/testing/FakeEmailSender.java
+++ b/java/com/google/gerrit/testing/FakeEmailSender.java
@@ -21,9 +21,9 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.send.EmailSender;
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 8800463..6c9fbed 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -15,10 +15,11 @@
 package com.google.gerrit.testing;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.base.Strings;
-import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
 import com.google.gerrit.extensions.client.AuthType;
@@ -30,6 +31,7 @@
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.CacheRefreshExecutor;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -85,6 +87,7 @@
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.gerrit.server.ssh.NoSshKeyCache;
 import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
+import com.google.gerrit.server.submit.SubscriptionGraph;
 import com.google.gerrit.server.util.ReplicaUtil;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
@@ -173,6 +176,7 @@
     install(new SearchingChangeCacheImpl.Module());
     factory(GarbageCollection.Factory.class);
     install(new AuditModule());
+    install(new SubscriptionGraph.Module());
 
     bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
 
@@ -209,7 +213,7 @@
           @Singleton
           @DiffExecutor
           public ExecutorService createDiffExecutor() {
-            return MoreExecutors.newDirectExecutorService();
+            return newDirectExecutorService();
           }
         });
     install(new DefaultMemoryCacheModule());
@@ -277,7 +281,7 @@
   @Singleton
   @SendEmailExecutor
   public ExecutorService createSendEmailExecutor() {
-    return MoreExecutors.newDirectExecutorService();
+    return newDirectExecutorService();
   }
 
   @Provides
@@ -287,6 +291,13 @@
     return queues.createQueue(2, "FanOut");
   }
 
+  @Provides
+  @Singleton
+  @CacheRefreshExecutor
+  public ListeningExecutorService createCacheRefreshExecutor() {
+    return newDirectExecutorService();
+  }
+
   private Module luceneIndexModule() {
     return indexModule("com.google.gerrit.lucene.LuceneIndexModule");
   }
diff --git a/java/com/google/gerrit/testing/TestCommentHelper.java b/java/com/google/gerrit/testing/TestCommentHelper.java
index 5ce6d13..bd859db 100644
--- a/java/com/google/gerrit/testing/TestCommentHelper.java
+++ b/java/com/google/gerrit/testing/TestCommentHelper.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.inject.Inject;
 import java.util.Arrays;
 import java.util.Collection;
@@ -116,7 +117,6 @@
     RobotCommentInput in = new RobotCommentInput();
     in.robotId = "happyRobot";
     in.robotRunId = "1";
-    in.line = 1;
     in.message = "nit: trailing whitespace";
     in.path = path;
     return in;
@@ -144,6 +144,7 @@
     reviewInput.robotComments =
         Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
     reviewInput.message = message;
+    reviewInput.tag = ChangeMessagesUtil.AUTOGENERATED_TAG_PREFIX;
     gApi.changes().id(targetChangeId).current().review(reviewInput);
   }
 }
diff --git a/java/com/google/gerrit/truth/MapSubject.java b/java/com/google/gerrit/truth/MapSubject.java
index 8217920..95a0e0c 100644
--- a/java/com/google/gerrit/truth/MapSubject.java
+++ b/java/com/google/gerrit/truth/MapSubject.java
@@ -1,18 +1,16 @@
-/*
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.truth;
 
diff --git a/java/com/google/gerrit/truth/NullAwareCorrespondence.java b/java/com/google/gerrit/truth/NullAwareCorrespondence.java
new file mode 100644
index 0000000..687ad94
--- /dev/null
+++ b/java/com/google/gerrit/truth/NullAwareCorrespondence.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.truth;
+
+import com.google.common.base.Function;
+import com.google.common.truth.Correspondence;
+import java.util.Optional;
+
+/** Utility class for constructing null aware {@link Correspondence}s. */
+public class NullAwareCorrespondence {
+  /**
+   * Constructs a {@link Correspondence} that compares elements by transforming the actual elements
+   * using the given function and testing for equality with the expected elements.
+   *
+   * <p>If the actual element is null, it will correspond to a null expected element. This is
+   * different to {@link Correspondence#transforming(Function, String)} which would invoke the
+   * function with a {@code null} argument, requiring the function being able to handle {@code
+   * null}.
+   *
+   * @param actualTransform a {@link Function} taking an actual value and returning a new value
+   *     which will be compared with an expected value to determine whether they correspond
+   * @param description should fill the gap in a failure message of the form {@code "not true that
+   *     <some actual element> is an element that <description> <some expected element>"}, e.g.
+   *     {@code "has an ID of"}
+   */
+  public static <A, E> Correspondence<A, E> transforming(
+      Function<A, ? extends E> actualTransform, String description) {
+    return Correspondence.transforming(
+        actualValue -> Optional.ofNullable(actualValue).map(actualTransform).orElse(null),
+        description);
+  }
+
+  /**
+   * Constructs a {@link Correspondence} that compares elements by transforming the actual elements
+   * using the given function and testing for equality with the expected elements.
+   *
+   * <p>If the actual element is null, it will correspond to a null expected element. This is
+   * different to {@link Correspondence#transforming(Function, Function, String)} which would invoke
+   * the function with a {@code null} argument, requiring the function being able to handle {@code
+   * null}.
+   *
+   * <p>If the expected element is null, it will correspond to a new null expected element. This is
+   * different to {@link Correspondence#transforming(Function, Function, String)} which would invoke
+   * the function with a {@code null} argument, requiring the function being able to handle {@code
+   * null}.
+   *
+   * @param actualTransform a {@link Function} taking an actual value and returning a new value
+   *     which will be compared with an expected value to determine whether they correspond
+   * @param expectedTransform a {@link Function} taking an expected value and returning a new value
+   *     which will be compared with a transformed actual value
+   * @param description should fill the gap in a failure message of the form {@code "not true that
+   *     <some actual element> is an element that <description> <some expected element>"}, e.g.
+   *     {@code "has an ID of"}
+   */
+  public static <A, E> Correspondence<A, E> transforming(
+      Function<A, ? extends E> actualTransform,
+      Function<E, ?> expectedTransform,
+      String description) {
+    return Correspondence.transforming(
+        actualValue -> Optional.ofNullable(actualValue).map(actualTransform).orElse(null),
+        expectedValue -> Optional.ofNullable(expectedValue).map(expectedTransform).orElse(null),
+        description);
+  }
+
+  /**
+   * Private constructor to prevent instantiation of this class.
+   *
+   * <p>This class contains only static method and hence never needs to be instantiated.
+   */
+  private NullAwareCorrespondence() {}
+}
diff --git a/java/gerrit/PRED__load_commit_labels_1.java b/java/gerrit/PRED__load_commit_labels_1.java
index 90a2cbf..5ee292ff 100644
--- a/java/gerrit/PRED__load_commit_labels_1.java
+++ b/java/gerrit/PRED__load_commit_labels_1.java
@@ -2,8 +2,8 @@
 
 package gerrit;
 
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.StoredValues;
diff --git a/java/gerrit/PRED_files_1.java b/java/gerrit/PRED_files_1.java
new file mode 100644
index 0000000..ac45449
--- /dev/null
+++ b/java/gerrit/PRED_files_1.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package gerrit;
+
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.ListTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
+
+/** Exports list of Strings that each represent a file name in the current patchset. */
+public class PRED_files_1 extends Predicate.P1 {
+  private static final SymbolTerm file = SymbolTerm.intern("file", 3);
+
+  PRED_files_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+    Term listHead = Prolog.Nil;
+
+    try (RevWalk revWalk = new RevWalk(StoredValues.REPOSITORY.get(engine))) {
+      RevCommit commit = revWalk.parseCommit(StoredValues.getPatchSet(engine).commitId());
+      List<PatchListEntry> patches = StoredValues.PATCH_LIST.get(engine).getPatches();
+      Set<String> submodules =
+          getAllSubmodulePaths(StoredValues.REPOSITORY.get(engine), commit, patches);
+      for (PatchListEntry entry : patches) {
+        if (Patch.isMagic(entry.getNewName())) {
+          continue;
+        }
+        SymbolTerm fileNameTerm = SymbolTerm.create(entry.getNewName());
+        SymbolTerm changeType = SymbolTerm.create(entry.getChangeType().getCode());
+        SymbolTerm fileType;
+        if (submodules.contains(entry.getNewName())) {
+          fileType = SymbolTerm.create("SUBMODULE");
+        } else {
+          fileType = SymbolTerm.create("REGULAR");
+        }
+        listHead =
+            new ListTerm(new StructureTerm(file, fileNameTerm, changeType, fileType), listHead);
+      }
+    } catch (IOException ex) {
+      return engine.fail();
+    }
+    if (!a1.unify(listHead, engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+
+  /** Returns the paths for all {@code GITLINK} files. */
+  private static Set<String> getAllSubmodulePaths(
+      Repository repository, RevCommit commit, List<PatchListEntry> patches)
+      throws PrologException, IOException {
+    Set<String> submodules = new HashSet<>();
+    try (TreeWalk treeWalk = new TreeWalk(repository)) {
+      treeWalk.addTree(commit.getTree());
+      Set<String> allPaths =
+          patches.stream()
+              .map(PatchListEntry::getNewName)
+              .filter(f -> !Patch.isMagic(f))
+              .collect(Collectors.toSet());
+      treeWalk.setFilter(PathFilterGroup.createFromStrings(allPaths));
+
+      while (treeWalk.next()) {
+        if (treeWalk.getFileMode() == FileMode.GITLINK) {
+          submodules.add(treeWalk.getPathString());
+        }
+      }
+      return submodules;
+    }
+  }
+}
diff --git a/java/gerrit/PRED_get_legacy_label_types_1.java b/java/gerrit/PRED_get_legacy_label_types_1.java
index 2f0c1ea..dfed17b 100644
--- a/java/gerrit/PRED_get_legacy_label_types_1.java
+++ b/java/gerrit/PRED_get_legacy_label_types_1.java
@@ -14,8 +14,8 @@
 
 package gerrit;
 
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
diff --git a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
new file mode 100644
index 0000000..30f1dcb
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
@@ -0,0 +1,138 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.accounts.EmailInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.testing.FakeEmailSender;
+import java.net.URL;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class OutgoingEmailIT extends AbstractDaemonTest {
+
+  @Test
+  public void messageIdHeaderFromChangeUpdate() throws Exception {
+    Repository repository = repoManager.openRepository(project);
+    PushOneCommit.Result result = createChange();
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user.email();
+    gApi.changes().id(result.getChangeId()).addReviewer(addReviewerInput);
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).abandon();
+    assertThat(getMessageId(sender))
+        .isEqualTo(
+            withPrefixAndSuffixForMessageId(
+                repository
+                        .getRefDatabase()
+                        .exactRef(result.getChange().getId().toRefPrefix() + "meta")
+                        .getObjectId()
+                        .getName()
+                    + "-HTML"));
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).restore();
+    assertThat(getMessageId(sender))
+        .isEqualTo(
+            withPrefixAndSuffixForMessageId(
+                repository
+                        .getRefDatabase()
+                        .exactRef(result.getChange().getId().toRefPrefix() + "meta")
+                        .getObjectId()
+                        .getName()
+                    + "-HTML"));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "auth.registerEmailPrivateKey",
+      value = "HsOc6l_2lhS9G7sE_RsnS7Z6GJjdRDX14co=")
+  public void messageIdHeaderFromAccountUpdate() throws Exception {
+    Repository allUsersRepo = repoManager.openRepository(allUsers);
+    String email = "new.email@example.com";
+    EmailInput input = new EmailInput();
+    input.email = email;
+    sender.clear();
+    gApi.accounts().self().addEmail(input);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    FakeEmailSender.Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(Address.create(email));
+
+    assertThat(getMessageId(sender))
+        .isEqualTo(
+            withPrefixAndSuffixForMessageId(
+                allUsersRepo
+                        .getRefDatabase()
+                        .exactRef(RefNames.refsUsers(admin.id()))
+                        .getObjectId()
+                        .getName()
+                    + "-HTML"));
+  }
+
+  @Test
+  public void messageIdHeaderFromPasswordUpdate() throws Exception {
+    sender.clear();
+    String newPassword = gApi.accounts().self().generateHttpPassword();
+    assertThat(newPassword).isNotNull();
+    assertThat(getMessageId(sender))
+        .containsMatch("<HTTP_password_change-" + admin.id().toString() + ".*@.*>");
+  }
+
+  @Test
+  public void htmlAndPlainTextSuffixAddedToMessageId() throws Exception {
+    PushOneCommit.Result result = createChange();
+    GeneralPreferencesInfo generalPreferencesInfo = new GeneralPreferencesInfo();
+    generalPreferencesInfo.emailFormat = GeneralPreferencesInfo.EmailFormat.PLAINTEXT;
+    gApi.accounts().id(user.id().get()).setPreferences(generalPreferencesInfo);
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.reviewer = user.email();
+    gApi.changes().id(result.getChangeId()).addReviewer(addReviewerInput);
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
+    assertThat(getMessageId(sender)).contains("-PLAIN");
+    sender.clear();
+
+    generalPreferencesInfo.emailFormat = GeneralPreferencesInfo.EmailFormat.HTML_PLAINTEXT;
+    gApi.accounts().id(user.id().get()).setPreferences(generalPreferencesInfo);
+    sender.clear();
+
+    gApi.changes().id(result.getChangeId()).current().review(ReviewInput.reject());
+    assertThat(getMessageId(sender)).contains("-HTML");
+  }
+
+  private static String getMessageId(FakeEmailSender sender) {
+    return ((EmailHeader.String)
+            (Iterables.getOnlyElement(sender.getMessages()).headers().get("Message-ID")))
+        .getString();
+  }
+
+  // Each message-id must start with '<' and end with '>'. Also, it must contain no spaces and it
+  // must contain a '@'.
+  private String withPrefixAndSuffixForMessageId(String id) throws Exception {
+    return "<" + id + "@" + new URL(canonicalWebUrl.get()).getHost() + ">";
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index f9ba8a2..aaca9e8 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -76,15 +76,16 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -122,7 +123,6 @@
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.testing.TestKey;
 import com.google.gerrit.httpd.CacheBasedWebSession;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountProperties;
@@ -139,7 +139,6 @@
 import com.google.gerrit.server.index.account.StalenessChecker;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.update.RetryHelper;
@@ -148,6 +147,7 @@
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.jcraft.jsch.KeyPair;
@@ -161,7 +161,6 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -284,12 +283,16 @@
       String labelName,
       int min,
       int max) {
-    ProjectConfig cfg = projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
-    AccessSection accessSection = cfg.getAccessSection(ref);
-    assertThat(accessSection).isNotNull();
+    Optional<AccessSection> accessSection =
+        projectCache
+            .get(project)
+            .orElseThrow(illegalState(project))
+            .getConfig()
+            .getAccessSection(ref);
+    assertThat(accessSection).isPresent();
 
     String permissionName = Permission.LABEL + labelName;
-    Permission permission = accessSection.getPermission(permissionName);
+    Permission permission = accessSection.get().getPermission(permissionName);
     assertPermission(permission, permissionName, exclusive, labelName);
     assertPermissionRule(
         permission.getRule(groupReference), groupReference, Action.ALLOW, false, min, max);
@@ -1221,7 +1224,7 @@
   @Test
   public void cannotAddNonConfirmedEmailWithoutModifyAccountPermission() throws Exception {
     TestAccount account = accountCreator.create(name("user"));
-    EmailInput input = newEmailInput("test@test.com");
+    EmailInput input = newEmailInput("test@example.com");
     requestScopeOperations.setApiUser(user.id());
     assertThrows(AuthException.class, () -> gApi.accounts().id(account.username()).addEmail(input));
   }
@@ -1653,8 +1656,11 @@
       // remove default READ permissions
       try (ProjectConfigUpdate u = updateProject(allUsers)) {
         u.getConfig()
-            .getAccessSection(RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}", true)
-            .remove(new Permission(Permission.READ));
+            .upsertAccessSection(
+                RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}",
+                as -> {
+                  as.remove(Permission.builder(Permission.READ));
+                });
         u.save();
       }
 
@@ -2901,12 +2907,7 @@
   }
 
   private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
-    return Correspondence.from(
-        (actualGroup, expectedName) -> {
-          String groupName = actualGroup == null ? null : actualGroup.name;
-          return Objects.equals(groupName, expectedName);
-        },
-        "has name");
+    return NullAwareCorrespondence.transforming(groupInfo -> groupInfo.name, "has name");
   }
 
   private void assertSequenceNumbers(List<SshKeyInfo> sshKeys) {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 11ca391..4c3c77f 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.comparing;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -27,11 +28,11 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -67,7 +68,8 @@
   protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig config = projectConfigFactory.read(md);
-      config.getProject().setBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, value);
+      config.updateProject(
+          p -> p.setBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, value));
       config.commit(md);
       projectCache.evict(config.getProject());
     }
@@ -75,21 +77,21 @@
 
   protected ContributorAgreement configureContributorAgreement(boolean autoVerify)
       throws Exception {
-    ContributorAgreement ca;
+    ContributorAgreement.Builder ca;
     String name = autoVerify ? "cla-test-group" : "cla-test-no-auto-verify-group";
     AccountGroup.UUID g = groupOperations.newGroup().name(name).create();
     GroupApi groupApi = gApi.groups().id(g.get());
     groupApi.description("CLA test group");
     InternalGroup caGroup = group(AccountGroup.uuid(groupApi.detail().id));
-    GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
-    PermissionRule rule = new PermissionRule(groupRef);
-    rule.setAction(PermissionRule.Action.ALLOW);
+    GroupReference groupRef = GroupReference.create(caGroup.getGroupUUID(), caGroup.getName());
+    PermissionRule rule =
+        PermissionRule.builder(groupRef).setAction(PermissionRule.Action.ALLOW).build();
     if (autoVerify) {
-      ca = new ContributorAgreement("cla-test");
+      ca = ContributorAgreement.builder("cla-test");
       ca.setAutoVerify(groupRef);
       ca.setAccepted(ImmutableList.of(rule));
     } else {
-      ca = new ContributorAgreement("cla-test-no-auto-verify");
+      ca = ContributorAgreement.builder("cla-test-no-auto-verify");
     }
     ca.setDescription("description");
     ca.setAgreementUrl("agreement-url");
@@ -97,9 +99,10 @@
     ca.setExcludeProjectsRegexes(ImmutableList.of("ExcludedProject"));
 
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      u.getConfig().replace(ca);
+      ContributorAgreement contributorAgreement = ca.build();
+      u.getConfig().replace(contributorAgreement);
       u.save();
-      return ca;
+      return contributorAgreement;
     }
   }
 
@@ -123,8 +126,11 @@
     if (isContributorAgreementsEnabled()) {
       assertThat(info.auth.useContributorAgreements).isTrue();
       assertThat(info.auth.contributorAgreements).hasSize(2);
-      assertAgreement(info.auth.contributorAgreements.get(0), caAutoVerify);
-      assertAgreement(info.auth.contributorAgreements.get(1), caNoAutoVerify);
+      // Sort to get a stable assertion as the API does not guarantee ordering.
+      List<AgreementInfo> agreements =
+          ImmutableList.sortedCopyOf(comparing(a -> a.name), info.auth.contributorAgreements);
+      assertAgreement(agreements.get(0), caAutoVerify);
+      assertAgreement(agreements.get(1), caNoAutoVerify);
     } else {
       assertThat(info.auth.useContributorAgreements).isNull();
       assertThat(info.auth.contributorAgreements).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
index 673379d..3c605e1 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
@@ -6,7 +6,6 @@
     group = "api_account",
     labels = [
         "api",
-        "noci",
         "no_windows",
     ],
     deps = [
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 746e6fe..f66bc8d 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DiffView;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.Theme;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.TimeFormat;
 import com.google.gerrit.extensions.client.MenuItem;
 import com.google.gerrit.extensions.config.DownloadScheme;
@@ -68,6 +69,7 @@
 
     // change all default values
     i.changesPerPage *= -1;
+    i.theme = Theme.DARK;
     i.dateFormat = DateFormat.US;
     i.timeFormat = TimeFormat.HHMM_24;
     i.emailStrategy = EmailStrategy.DISABLED;
@@ -90,6 +92,7 @@
     assertPrefs(o, i, "my");
     assertThat(o.my).containsExactlyElementsIn(i.my);
     assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
+    assertThat(o.theme).isEqualTo(i.theme);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
new file mode 100644
index 0000000..0309646
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/MessageIdGeneratorIT.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.mail.MailMessage;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.time.Instant;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class MessageIdGeneratorIT extends AbstractDaemonTest {
+  @Inject private MessageIdGenerator messageIdGenerator;
+
+  @Test
+  public void fromAccountUpdate() throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String messageId = messageIdGenerator.fromAccountUpdate(admin.id()).id();
+      String sha1 =
+          repo.getRefDatabase().findRef(RefNames.refsUsers(admin.id())).getObjectId().getName();
+      assertThat(sha1).isEqualTo(messageId);
+    }
+  }
+
+  @Test
+  public void fromChangeUpdate() throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      PushOneCommit.Result result = createChange();
+      PatchSet.Id patchsetId = result.getChange().currentPatchSet().id();
+      String messageId = messageIdGenerator.fromChangeUpdate(project, patchsetId).id();
+      String sha1 =
+          repo.getRefDatabase()
+              .findRef(String.format("%smeta", patchsetId.changeId().toRefPrefix()))
+              .getObjectId()
+              .getName();
+      assertThat(sha1).isEqualTo(messageId);
+    }
+  }
+
+  @Test
+  public void fromMailMessage() throws Exception {
+    String id = "unique-id";
+    MailMessage mailMessage =
+        MailMessage.builder()
+            .id(id)
+            .from(Address.create("email@email.com"))
+            .dateReceived(Instant.EPOCH)
+            .subject("subject")
+            .build();
+    assertThat(messageIdGenerator.fromMailMessage(mailMessage).id()).isEqualTo(id + "-REJECTION");
+  }
+
+  @Test
+  public void fromReasonAccountIdAndTimestamp() throws Exception {
+    String reason = "reason";
+    Instant timestamp = TimeUtil.now();
+    assertThat(
+            messageIdGenerator.fromReasonAccountIdAndTimestamp(reason, admin.id(), timestamp).id())
+        .isEqualTo(reason + "-" + admin.id().toString() + "-" + timestamp.toString());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index d04eebd..80431ee 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/BUILD b/javatests/com/google/gerrit/acceptance/api/change/BUILD
index 9279488..94fb0dc 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/change/BUILD
@@ -1,11 +1,10 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "api_change",
+[acceptance_tests(
+    srcs = [f],
+    group = f[:f.index(".")],
     labels = [
         "api",
-        "noci",
     ],
     deps = ["//java/com/google/gerrit/server/util/time"],
-)
+) for f in glob(["*IT.java"])]
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 5c786a5..d4affb7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -89,14 +89,15 @@
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -156,7 +157,6 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.PostFilterPredicate;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.ChangeResource;
@@ -1878,7 +1878,7 @@
     Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "ab" with one user: testUser
-    String email = "abcd@test.com";
+    String email = "abcd@example.com";
     String fullname = "abcd";
     Account.Id accountIdOfTestUser =
         accountOperations
@@ -1931,11 +1931,11 @@
     accountOperations
         .newAccount()
         .username("kobebryant")
-        .preferredEmail("kobebryant@test.com")
+        .preferredEmail("kobebryant@example.com")
         .fullname(testUserFullname)
         .create();
 
-    String myGroupUserEmail = "lee@test.com";
+    String myGroupUserEmail = "lee@example.com";
     String myGroupUserFullname = "lee";
     Account.Id accountIdOfGroupUser =
         accountOperations
@@ -2231,7 +2231,7 @@
     LabelType verified =
         label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.getConfig().upsertLabelType(verified);
       u.save();
     }
     projectOperations
@@ -2518,7 +2518,7 @@
         label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
     String heads = "refs/heads/*";
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.getConfig().upsertLabelType(verified);
       u.save();
     }
     projectOperations
@@ -2863,9 +2863,9 @@
     LabelType custom2 =
         label("Custom2", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
-      u.getConfig().getLabelSections().put(custom1.getName(), custom1);
-      u.getConfig().getLabelSections().put(custom2.getName(), custom2);
+      u.getConfig().upsertLabelType(verified);
+      u.getConfig().upsertLabelType(custom1);
+      u.getConfig().upsertLabelType(custom2);
       u.save();
     }
     projectOperations
@@ -3065,7 +3065,8 @@
 
       assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
       PersonIdent expectedAuthor =
-          changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.updated, serverIdent.get());
+          changeNoteUtil.newAccountIdIdent(
+              getAccount(admin.id()).id(), c.updated, serverIdent.get());
       assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitPatchSetCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
@@ -3074,7 +3075,8 @@
       RevCommit commitChangeCreation = rw.parseCommit(commitPatchSetCreation.getParent(0));
       assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
       expectedAuthor =
-          changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.created, serverIdent.get());
+          changeNoteUtil.newAccountIdIdent(
+              getAccount(admin.id()).id(), c.created, serverIdent.get());
       assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitChangeCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
@@ -3601,7 +3603,7 @@
     String heads = RefNames.REFS_HEADS + "*";
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.getConfig().upsertLabelType(verified);
       u.save();
     }
     projectOperations
@@ -3669,7 +3671,7 @@
 
     // add new label and assert that it's returned for existing changes
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.getConfig().upsertLabelType(verified);
       u.save();
     }
     projectOperations
@@ -3745,7 +3747,7 @@
             "Configure Notifications",
             "project.config",
             "[notify \"my=notify-config\"]\n"
-                + "  email = foo@test.com\n"
+                + "  email = foo@example.com\n"
                 + "  filter = dir:\\\"foo/bar/baz\\\"");
     push.to(RefNames.REFS_CONFIG);
     testRepo.reset(oldHead);
@@ -3757,7 +3759,8 @@
             admin.newIdent(), testRepo, "Test change", "foo/bar/baz/test.txt", "some content");
     PushOneCommit.Result r = push.to("refs/for/master");
     assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(Address.parse("foo@test.com"));
+    assertThat(sender.getMessages().get(0).rcpt())
+        .containsExactly(Address.parse("foo@example.com"));
 
     // Comment on the change.
     sender.clear();
@@ -3765,7 +3768,8 @@
     reviewInput.message = "some message";
     gApi.changes().id(r.getChangeId()).current().review(reviewInput);
     assertThat(sender.getMessages()).hasSize(1);
-    assertThat(sender.getMessages().get(0).rcpt()).containsExactly(Address.parse("foo@test.com"));
+    assertThat(sender.getMessages().get(0).rcpt())
+        .containsExactly(Address.parse("foo@example.com"));
   }
 
   @Test
@@ -4382,6 +4386,7 @@
           .message("Modify rules.pl")
           .create();
     }
+    projectCache.evict(project);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
index 40dd70e..fd9af0e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
@@ -19,8 +19,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInfo;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index be0cc04..7d73374 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -31,13 +31,16 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -54,6 +57,7 @@
 import java.sql.Timestamp;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -64,6 +68,7 @@
 
   @Inject private CommentValidator mockCommentValidator;
   @Inject private TestCommentHelper testCommentHelper;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private static final String COMMENT_TEXT = "The comment text";
   private static final CommentForValidation FILE_COMMENT_FOR_VALIDATION =
@@ -382,6 +387,93 @@
         .contains("Exceeding maximum cumulative size of comments");
   }
 
+  @Test
+  public void ccToReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // User adds themselves and changes state
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput input = new ReviewInput().reviewer(user.id().toString(), ReviewerState.CC, false);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.CC));
+    assertThat(reviewer._accountId).isEqualTo(user.id().get());
+
+    // CC -> Reviewer
+    ReviewInput input2 = new ReviewInput().reviewer(user.id().toString());
+    gApi.changes().id(r.getChangeId()).current().review(input2);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers2 =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers2).hasSize(1);
+    AccountInfo reviewer2 = Iterables.getOnlyElement(reviewers2.get(ReviewerState.REVIEWER));
+    assertThat(reviewer2._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  public void reviewerToCc() throws Exception {
+    // Admin owns the change
+    PushOneCommit.Result r = createChange();
+    // User adds themselves and changes state
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput input = new ReviewInput().reviewer(user.id().toString());
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.REVIEWER));
+    assertThat(reviewer._accountId).isEqualTo(user.id().get());
+
+    // Reviewer -> CC
+    ReviewInput input2 = new ReviewInput().reviewer(user.id().toString(), ReviewerState.CC, false);
+    gApi.changes().id(r.getChangeId()).current().review(input2);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers2 =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers2).hasSize(1);
+    AccountInfo reviewer2 = Iterables.getOnlyElement(reviewers2.get(ReviewerState.CC));
+    assertThat(reviewer2._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  public void votingMakesCallerReviewer() throws Exception {
+    // Admin owns the change
+    PushOneCommit.Result r = createChange();
+    // User adds themselves and changes state
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput input = new ReviewInput().label("Code-Review", 1);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.REVIEWER));
+    assertThat(reviewer._accountId).isEqualTo(user.id().get());
+  }
+
+  @Test
+  public void commentingMakesUserCC() throws Exception {
+    // Admin owns the change
+    PushOneCommit.Result r = createChange();
+    // User adds themselves and changes state
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput input = new ReviewInput().message("Foo bar!");
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    Map<ReviewerState, Collection<AccountInfo>> reviewers =
+        gApi.changes().id(r.getChangeId()).get().reviewers;
+    assertThat(reviewers).hasSize(1);
+    AccountInfo reviewer = Iterables.getOnlyElement(reviewers.get(ReviewerState.CC));
+    assertThat(reviewer._accountId).isEqualTo(user.id().get());
+  }
+
   private List<RobotCommentInfo> getRobotComments(String changeId) throws RestApiException {
     return gApi.changes().id(changeId).robotComments().values().stream()
         .flatMap(Collection::stream)
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
index f043c9b..97b7148 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 448f347..7865e32 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -29,9 +29,9 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 24d08db..69278b4 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -26,8 +26,8 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -398,7 +398,7 @@
         .review(ReviewInput.approve());
     gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit();
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getProject().setState(ProjectState.READ_ONLY);
+      u.getConfig().updateProject(p -> p.setState(ProjectState.READ_ONLY));
       u.save();
     }
 
@@ -481,7 +481,7 @@
 
     // revoke write permissions for the first repository.
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getProject().setState(ProjectState.READ_ONLY);
+      u.getConfig().updateProject(p -> p.setState(ProjectState.READ_ONLY));
       u.save();
     }
 
@@ -1283,6 +1283,7 @@
           .message("Modify rules.pl")
           .create();
     }
+    projectCache.evict(project);
   }
 
   private List<ChangeApi> getChangeApis(RevertSubmissionInfo revertSubmissionInfo)
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 923b66f..58ea6ea 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -26,7 +26,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
@@ -39,7 +39,7 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -78,8 +78,8 @@
     try (ProjectConfigUpdate u = updateProject(project)) {
       // Overwrite "Code-Review" label that is inherited from All-Projects.
       // This way changes to the "Code Review" label don't affect other tests.
-      LabelType codeReview =
-          label(
+      LabelType.Builder codeReview =
+          labelBuilder(
               "Code-Review",
               value(2, "Looks good to me, approved"),
               value(1, "Looks good to me, but someone else must approve"),
@@ -87,12 +87,12 @@
               value(-1, "I would prefer that you didn't submit this"),
               value(-2, "Do not submit"));
       codeReview.setCopyAllScoresIfNoChange(false);
-      u.getConfig().getLabelSections().put(codeReview.getName(), codeReview);
+      u.getConfig().upsertLabelType(codeReview.build());
 
-      LabelType verified =
-          label("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      LabelType.Builder verified =
+          labelBuilder("Verified", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
       verified.setCopyAllScoresIfNoChange(false);
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.getConfig().upsertLabelType(verified.build());
 
       u.save();
     }
@@ -121,7 +121,7 @@
   @Test
   public void stickyOnAnyScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyAnyScore(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAnyScore(true));
       u.save();
     }
 
@@ -143,7 +143,7 @@
   @Test
   public void stickyOnMinScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyMinScore(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMinScore(true));
       u.save();
     }
 
@@ -165,7 +165,7 @@
   @Test
   public void stickyOnMaxScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyMaxScore(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
       u.save();
     }
 
@@ -190,9 +190,8 @@
 
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .getLabelSections()
-          .get("Code-Review")
-          .setCopyValues(ImmutableList.of((short) -1, (short) 1));
+          .updateLabelType(
+              "Code-Review", b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
       u.save();
     }
 
@@ -216,7 +215,7 @@
   @Test
   public void stickyOnTrivialRebase() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresOnTrivialRebase(true));
       u.save();
     }
 
@@ -262,7 +261,7 @@
   @Test
   public void stickyOnNoCodeChange() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -287,9 +286,7 @@
   public void stickyOnMergeFirstParentUpdate() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .getLabelSections()
-          .get("Code-Review")
-          .setCopyAllScoresOnMergeFirstParentUpdate(true);
+          .updateLabelType("Code-Review", b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
       u.save();
     }
 
@@ -313,7 +310,7 @@
   @Test
   public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyAllScoresIfNoChange(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresIfNoChange(true));
       u.save();
     }
 
@@ -330,8 +327,8 @@
   @Test
   public void removedVotesNotSticky() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyAllScoresOnTrivialRebase(true);
-      u.getConfig().getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyAllScoresOnTrivialRebase(true));
+      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -360,8 +357,8 @@
   @Test
   public void stickyAcrossMultiplePatchSets() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyMaxScore(true);
-      u.getConfig().getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -386,8 +383,8 @@
     // change kind against all prior patch sets. This is a regression that made Gerrit do expensive
     // work in O(num-patch-sets). This test ensures that we aren't regressing.
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyMaxScore(true);
-      u.getConfig().getLabelSections().get("Verified").setCopyAllScoresIfNoCodeChange(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType("Verified", b -> b.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
@@ -418,8 +415,8 @@
   @Test
   public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get("Code-Review").setCopyMaxScore(true);
-      u.getConfig().getLabelSections().get("Code-Review").setCopyMinScore(true);
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMaxScore(true));
+      u.getConfig().updateLabelType("Code-Review", b -> b.setCopyMinScore(true));
       u.save();
     }
 
@@ -459,7 +456,7 @@
   public void deleteStickyVote() throws Exception {
     String label = "Code-Review";
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().get(label).setCopyMaxScore(true);
+      u.getConfig().updateLabelType(label, b -> b.setCopyMaxScore(true));
       u.save();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
index dab2d00..a9afcbc 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
@@ -106,6 +106,7 @@
       r.rule = rule;
       r.commit(md);
     }
+    projectCache.evict(project);
   }
 
   private static final String SUBMIT_TYPE_FROM_SUBJECT =
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index dcf2afd..9e405c7 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -56,10 +56,10 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -100,6 +100,7 @@
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.GerritJUnit.ThrowingRunnable;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -111,7 +112,6 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.stream.Stream;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -1517,12 +1517,8 @@
   }
 
   private static Correspondence<AccountInfo, String> getAccountToUsernameCorrespondence() {
-    return Correspondence.from(
-        (actualAccount, expectedName) -> {
-          String username = actualAccount == null ? null : actualAccount.username;
-          return Objects.equals(username, expectedName);
-        },
-        "has username");
+    return NullAwareCorrespondence.transforming(
+        accountInfo -> accountInfo.username, "has username");
   }
 
   private void assertStaleGroupAndReindex(AccountGroup.UUID groupUuid) throws IOException {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
index 6fcca8c..c977d43 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
@@ -18,9 +18,9 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.ServerInitiated;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 3fc6e44..f1d537f 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -27,8 +27,8 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.AccessCheckInfo;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index e67770c..80e04c0 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
index 6442645..a22b558 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 8dc76dd..e99a6f5 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -39,8 +39,8 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -64,7 +64,6 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.CommentLinkInfoImpl;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
@@ -697,6 +696,31 @@
   }
 
   @Test
+  public void pluginConfigsReturnedWhenRefsMetaConfigReadable() throws Exception {
+    ProjectConfigEntry entry = new ProjectConfigEntry("enabled", "true");
+    try (Registration ignored =
+        extensionRegistry.newRegistration().add(entry, "test-config-entry")) {
+      // The admin can see refs/meta/config and hence has the READ_CONFIG permission.
+      requestScopeOperations.setApiUser(admin.id());
+      ConfigInfo configInfo = getConfig();
+      assertThat(configInfo.pluginConfig).isNotNull();
+      assertThat(configInfo.pluginConfig).isNotEmpty();
+    }
+  }
+
+  @Test
+  public void pluginConfigsNotReturnedWhenRefsMetaConfigNotReadable() throws Exception {
+    ProjectConfigEntry entry = new ProjectConfigEntry("enabled", "true");
+    try (Registration ignored =
+        extensionRegistry.newRegistration().add(entry, "test-config-entry")) {
+      // This user cannot see refs/meta/config and hence does not have the READ_CONFIG permission.
+      requestScopeOperations.setApiUser(user.id());
+      ConfigInfo configInfo = getConfig();
+      assertThat(configInfo.pluginConfig).isNull();
+    }
+  }
+
+  @Test
   public void noCommentlinksByDefault() throws Exception {
     assertThat(getConfig().commentlinks).isEmpty();
   }
@@ -916,7 +940,11 @@
   }
 
   private CommentLinkInfo commentLinkInfo(String name, String match, String link) {
-    return new CommentLinkInfoImpl(name, match, link, null /*html*/, null /*enabled*/);
+    CommentLinkInfo info = new CommentLinkInfo();
+    info.name = name;
+    info.match = match;
+    info.link = link;
+    return info;
   }
 
   private void assertCommentLinks(ConfigInfo actual, Map<String, CommentLinkInfo> expected) {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
index dad09f9..e45d95c 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -104,18 +104,18 @@
     Project.NameKey p1 = projectOperations.newProject().create();
     Project.NameKey p2 = projectOperations.newProject().create();
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getProject().setParentName(p1);
+      u.getConfig().updateProject(p -> p.setParent(p1));
       u.save();
     }
     assertThat(stalenessChecker.check(project).isStale()).isFalse();
 
-    updateProjectConfigWithoutIndexUpdate(p1, c -> c.getProject().setParentName(p2));
+    updateProjectConfigWithoutIndexUpdate(p1, c -> c.updateProject(p -> p.setParent(p2)));
     assertThat(stalenessChecker.check(project).isStale()).isTrue();
   }
 
   private void updateProjectConfigWithoutIndexUpdate(Project.NameKey project) throws Exception {
     updateProjectConfigWithoutIndexUpdate(
-        project, c -> c.getProject().setDescription("making it stale"));
+        project, c -> c.updateProject(p -> p.setDescription("making it stale")));
   }
 
   private void updateProjectConfigWithoutIndexUpdate(
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index 1539334..2bdbe50 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/BUILD b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
index 06e45c5..3bfe2f0 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/revision/BUILD
@@ -1,7 +1,7 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
-acceptance_tests(
-    srcs = glob(["*IT.java"]),
-    group = "api_revision",
+[acceptance_tests(
+    srcs = [f],
+    group = f[:f.index(".")],
     labels = ["api"],
-)
+) for f in glob(["*IT.java"])]
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/GetBlameIT.java b/javatests/com/google/gerrit/acceptance/api/revision/GetBlameIT.java
index 8dfebad..62140ed 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/GetBlameIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/GetBlameIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.revision;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -35,6 +36,16 @@
   }
 
   @Test
+  public void forPatchsetLevelFile() throws Exception {
+    PushOneCommit.Result r = createChange("Test Change", "foo.txt", "FOO");
+    List<BlameInfo> blameInfos =
+        gApi.changes().id(r.getChangeId()).current().file(PATCHSET_LEVEL).blameRequest().get();
+
+    // File doesn't exist in commit.
+    assertThat(blameInfos).isEmpty();
+  }
+
+  @Test
   public void forNonExistingFileFromBase() throws Exception {
     PushOneCommit.Result r = createChange("Test Change", "foo.txt", "FOO");
     List<BlameInfo> blameInfos =
@@ -51,6 +62,22 @@
   }
 
   @Test
+  public void forPatchsetLevelFileFromBase() throws Exception {
+    PushOneCommit.Result r = createChange("Test Change", "foo.txt", "FOO");
+    List<BlameInfo> blameInfos =
+        gApi.changes()
+            .id(r.getChangeId())
+            .current()
+            .file(PATCHSET_LEVEL)
+            .blameRequest()
+            .forBase(true)
+            .get();
+
+    // File doesn't exist in base commit.
+    assertThat(blameInfos).isEmpty();
+  }
+
+  @Test
   public void forNewlyAddedFile() throws Exception {
     PushOneCommit.Result r = createChange("Test Change", "foo.txt", "FOO");
     List<BlameInfo> blameInfos =
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 717d3cc..5684b1f 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.FileInfoSubject.assertThat;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
@@ -121,6 +122,24 @@
   }
 
   @Test
+  public void patchsetLevelFileDiffIsEmpty() throws Exception {
+    PushOneCommit.Result result = createChange();
+    DiffInfo diffForPatchsetLevelFile =
+        gApi.changes()
+            .id(result.getChangeId())
+            .revision(result.getCommit().name())
+            .file(PATCHSET_LEVEL)
+            .diff();
+    // This behavior is the same as the behavior for non-existent files.
+    assertThat(diffForPatchsetLevelFile).binary().isNull();
+    assertThat(diffForPatchsetLevelFile).content().isEmpty();
+    assertThat(diffForPatchsetLevelFile).diffHeader().isNull();
+    assertThat(diffForPatchsetLevelFile).metaA().isNull();
+    assertThat(diffForPatchsetLevelFile).metaB().isNull();
+    assertThat(diffForPatchsetLevelFile).webLinks().isNull();
+  }
+
+  @Test
   public void deletedFileIsIncludedInDiff() throws Exception {
     gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
     gApi.changes().id(changeId).edit().publish();
@@ -353,6 +372,31 @@
   }
 
   @Test
+  public void copiedFileDetectedIfOriginalFileIsRenamedInDiff() throws Exception {
+    /*
+     * Copies are detected when a file is deleted and more than 1 file with the same content are
+     * added. In this case, the added file with the closest name to the original file is tagged as a
+     * rename and the remaining files are considered copies. This implementation is done by JGit in
+     * the RenameDetector component.
+     */
+    String renamedFileName = "renamed_some_file.txt";
+    String copyFileName1 = "copy1_with_different_name.txt";
+    String copyFileName2 = "copy2_with_different_name.txt";
+    gApi.changes().id(changeId).edit().modifyFile(copyFileName1, RawInputUtil.create(FILE_CONTENT));
+    gApi.changes().id(changeId).edit().modifyFile(copyFileName2, RawInputUtil.create(FILE_CONTENT));
+    gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFileName);
+    gApi.changes().id(changeId).edit().publish();
+
+    Map<String, FileInfo> changedFiles = gApi.changes().id(changeId).current().files();
+
+    assertThat(changedFiles.keySet())
+        .containsExactly("/COMMIT_MSG", renamedFileName, copyFileName1, copyFileName2);
+    assertThat(changedFiles.get(renamedFileName).status).isEqualTo('R');
+    assertThat(changedFiles.get(copyFileName1).status).isEqualTo('C');
+    assertThat(changedFiles.get(copyFileName2).status).isEqualTo('C');
+  }
+
+  @Test
   public void addedBinaryFileIsIncludedInDiff() throws Exception {
     String imageFileName = "an_image.png";
     byte[] imageBytes = createRgbImage(255, 0, 0);
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 74f9134..7ab006d 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
@@ -49,11 +50,12 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.BranchOrderSection;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -1295,8 +1297,25 @@
     assertThat(changes).hasSize(1);
     assertThat(changes.get(0).changeId).isEqualTo(r2.getChangeId());
     assertThat(changes.get(0).mergeable).isEqualTo(Boolean.TRUE);
+  }
 
-    // TODO(dborowitz): Test for other-branches.
+  @Test
+  public void mergeableOtherBranches() throws Exception {
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, "mergeable-other-branch"), head);
+    createBranchWithRevision(BranchNameKey.create(project, "ignored"), head);
+    PushOneCommit.Result change1 = createChange();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .setBranchOrderSection(
+              BranchOrderSection.create(
+                  ImmutableList.of("master", "nonexistent", "mergeable-other-branch")));
+      u.save();
+    }
+
+    MergeableInfo mergeableInfo =
+        gApi.changes().id(change1.getChangeId()).current().mergeableOtherBranches();
+    assertThat(mergeableInfo.mergeableInto).containsExactly("mergeable-other-branch");
   }
 
   @Test
@@ -1500,6 +1519,19 @@
   }
 
   @Test
+  public void patchsetLevelContentDoesNotExist() throws Exception {
+    PushOneCommit.Result change = createChange();
+    assertThrows(
+        ResourceNotFoundException.class,
+        () ->
+            gApi.changes()
+                .id(change.getChangeId())
+                .revision(change.getCommit().name())
+                .file(PATCHSET_LEVEL)
+                .content());
+  }
+
+  @Test
   public void cannotGetContentOfDirectory() throws Exception {
     Map<String, String> files = ImmutableMap.of("dir/file1.txt", "content 1");
     PushOneCommit.Result result =
@@ -1860,6 +1892,13 @@
     assertThat(approvals).hasSize(2);
   }
 
+  @Test
+  public void uploaderNotAddedAsReviewer() throws Exception {
+    PushOneCommit.Result result = createChange();
+    amendChangeWithUploader(result, project, user);
+    assertThat(result.getChange().reviewers().all()).isEmpty();
+  }
+
   private static void assertCherryPickResult(
       ChangeInfo changeInfo, CherryPickInput input, String srcChangeId) throws Exception {
     assertThat(changeInfo.changeId).isEqualTo(srcChangeId);
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 0b8f441..27b866b 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
@@ -35,6 +36,7 @@
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.ChangeType;
@@ -151,7 +153,7 @@
     TestTimeUtil.resetWithClockStep(0, TimeUnit.SECONDS);
     createChange();
     /* Advancing the time after creating the change so that the first robot comment is not in the same timestamp as with the change creation */
-    TestTimeUtil.incrementClock(5, TimeUnit.SECONDS);
+    TestTimeUtil.incrementClock(10, TimeUnit.SECONDS);
 
     RobotCommentInput c1 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
     RobotCommentInput c2 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
@@ -220,9 +222,9 @@
             .changeMessageId;
 
     /**
-     * Upload PS message, robot message 1 & robot comment 1 all have the same timestamp. The robot
-     * comment is matched to robot message 1 because the PS upload message is auto-generated and is
-     * ignored in matching
+     * All change messages have the auto-generated tag. Robot comments can be linked to
+     * auto-generated messages where each comment is linked to the next nearest change message in
+     * timestamp
      */
     assertThat(message1ChangeId).isEqualTo(comment1MessageId);
     assertThat(message2ChangeId).isEqualTo(comment2MessageId);
@@ -267,6 +269,57 @@
   }
 
   @Test
+  public void patchsetLevelRobotCommentCanBeAddedAndRetrieved() throws Exception {
+    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(PATCHSET_LEVEL);
+    testCommentHelper.addRobotComment(changeId, input);
+
+    List<RobotCommentInfo> results = getRobotComments();
+    assertThatList(results).onlyElement().path().isEqualTo(PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void patchsetLevelRobotCommentCantHaveLine() throws Exception {
+    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(PATCHSET_LEVEL);
+    input.line = 1;
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> testCommentHelper.addRobotComment(changeId, input));
+    assertThat(ex.getMessage()).contains("line");
+  }
+
+  @Test
+  public void patchsetLevelRobotCommentCantHaveRange() throws Exception {
+    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(PATCHSET_LEVEL);
+    input.range = createRange(2, 9, 5, 10);
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> testCommentHelper.addRobotComment(changeId, input));
+    assertThat(ex.getMessage()).contains("range");
+  }
+
+  @Test
+  public void patchsetLevelRobotCommentCantHaveSide() throws Exception {
+    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(PATCHSET_LEVEL);
+    input.side = Side.REVISION;
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> testCommentHelper.addRobotComment(changeId, input));
+    assertThat(ex.getMessage()).contains("side");
+  }
+
+  @Test
+  public void fixSuggestionCannotPointToPatchsetLevel() throws Exception {
+    RobotCommentInput input = TestCommentHelper.createRobotCommentInput(FILE_NAME);
+    FixReplacementInfo brokenFixReplacement = createFixReplacementInfo();
+    brokenFixReplacement.path = PATCHSET_LEVEL;
+    input.fixSuggestions = ImmutableList.of(createFixSuggestionInfo(brokenFixReplacement));
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> testCommentHelper.addRobotComment(changeId, input));
+    assertThat(ex.getMessage()).contains("file path must not be " + PATCHSET_LEVEL);
+  }
+
+  @Test
   public void hugeRobotCommentIsRejected() {
     int defaultSizeLimit = 1 << 20;
     fixReplacementInfo.replacement = getStringFor(defaultSizeLimit + 1);
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index f0bb201..22cecdb 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -40,10 +40,10 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.FileContentInput;
@@ -772,8 +772,9 @@
     String cr = "Code-Review";
     try (ProjectConfigUpdate u = updateProject(project)) {
       LabelType codeReview = TestLabels.codeReview();
-      codeReview.setCopyAllScoresIfNoCodeChange(true);
-      u.getConfig().getLabelSections().put(cr, codeReview);
+      u.getConfig().upsertLabelType(codeReview);
+      u.getConfig()
+          .updateLabelType(codeReview.getName(), lt -> lt.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
index 3b80312..88d0937 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 7213a9f..6fb444f 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -63,13 +63,14 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -93,7 +94,6 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.git.ObjectIds;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.receive.NoteDbPushOption;
@@ -161,7 +161,7 @@
   public void setUpPatchSetLock() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       patchSetLock = TestLabels.patchSetLock();
-      u.getConfig().getLabelSections().put(patchSetLock.getName(), patchSetLock);
+      u.getConfig().upsertLabelType(patchSetLock);
       u.save();
     }
     projectOperations
@@ -1200,7 +1200,7 @@
         label("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
     String heads = "refs/heads/*";
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(Q.getName(), Q);
+      u.getConfig().upsertLabelType(Q);
       u.save();
     }
     projectOperations
@@ -1686,8 +1686,10 @@
 
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .getProject()
-          .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
+          .updateProject(
+              p ->
+                  p.setBooleanConfig(
+                      BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE));
       u.save();
     }
 
@@ -1712,8 +1714,10 @@
 
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .getProject()
-          .setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE);
+          .updateProject(
+              p ->
+                  p.setBooleanConfig(
+                      BooleanProjectConfig.REQUIRE_CHANGE_ID, InheritableBoolean.FALSE));
       u.save();
     }
 
@@ -1863,9 +1867,8 @@
   @Test
   public void pushNewPatchsetOverridingStickyLabel() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReview = TestLabels.codeReview();
-      codeReview.setCopyMaxScore(true);
-      u.getConfig().getLabelSections().put(codeReview.getName(), codeReview);
+      LabelType codeReview = TestLabels.codeReview().toBuilder().setCopyMaxScore(true).build();
+      u.getConfig().upsertLabelType(codeReview);
       u.save();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index dcee118..23bcdec 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -26,16 +26,16 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.notedb.ChangeNotes;
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index a0725c3..415aa79 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -22,9 +22,9 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubscribeSection;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -204,12 +204,12 @@
       throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(submodule)) {
       md.setMessage("Added superproject subscription");
-      SubscribeSection s;
+      SubscribeSection.Builder s;
       ProjectConfig pc = projectConfigFactory.read(md);
       if (pc.getSubscribeSections().containsKey(superproject)) {
-        s = pc.getSubscribeSections().get(superproject);
+        s = pc.getSubscribeSections().get(superproject).toBuilder();
       } else {
-        s = new SubscribeSection(superproject);
+        s = SubscribeSection.builder(superproject);
       }
       String refspec;
       if (superBranch == null) {
@@ -222,7 +222,7 @@
       } else {
         s.addMultiMatchRefSpec(refspec);
       }
-      pc.addSubscribeSection(s);
+      pc.addSubscribeSection(s.build());
       ObjectId oldId = pc.getRevision();
       ObjectId newId = pc.commit(md);
       assertThat(newId).isNotEqualTo(oldId);
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
index b51263e..80cc508 100644
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
@@ -85,8 +85,10 @@
   private void setRejectImplicitMerges() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
-          .getProject()
-          .setBooleanConfig(BooleanProjectConfig.REJECT_IMPLICIT_MERGES, InheritableBoolean.TRUE);
+          .updateProject(
+              p ->
+                  p.setBooleanConfig(
+                      BooleanProjectConfig.REJECT_IMPLICIT_MERGES, InheritableBoolean.TRUE));
       u.save();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
index 6f7a4c3..27962da 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
@@ -33,8 +33,9 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -43,7 +44,6 @@
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.ProjectWatches;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.testing.ConfigSuite;
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index f9c751f..64c8792 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -26,11 +26,11 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.common.ChangeInput;
@@ -175,7 +175,7 @@
   public void readOnlyProjectRejectedBeforeTestingPermissions() throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       try (ProjectConfigUpdate u = updateProject(project)) {
-        u.getConfig().getProject().setState(ProjectState.READ_ONLY);
+        u.getConfig().updateProject(p -> p.setState(ProjectState.READ_ONLY));
         u.save();
       }
     }
@@ -362,22 +362,28 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    cfg.getAccessSections().stream()
-        .filter(
-            s ->
-                s.getName().startsWith("refs/heads/")
-                    || s.getName().startsWith("refs/for/")
-                    || s.getName().equals("refs/*"))
-        .forEach(s -> Arrays.stream(permissions).forEach(s::removePermission));
+    for (AccessSection s : ImmutableList.copyOf(cfg.getAccessSections())) {
+      if (s.getName().startsWith("refs/heads/")
+          || s.getName().startsWith("refs/for/")
+          || s.getName().equals("refs/*")) {
+        cfg.upsertAccessSection(
+            s.getName(),
+            updatedSection -> {
+              Arrays.stream(permissions).forEach(p -> updatedSection.remove(Permission.builder(p)));
+            });
+      }
+    }
   }
 
   private static void removeAllGlobalCapabilities(ProjectConfig cfg, String... capabilities) {
     Arrays.stream(capabilities)
         .forEach(
             c ->
-                cfg.getAccessSection(AccessSection.GLOBAL_CAPABILITIES, true)
-                    .getPermission(c, true)
-                    .clearRules());
+                cfg.upsertAccessSection(
+                    AccessSection.GLOBAL_CAPABILITIES,
+                    as -> {
+                      as.upsertPermission(c).clearRules();
+                    }));
   }
 
   private PushResult push(String... refSpecs) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 1083377..d4cd1fe 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -35,13 +35,13 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -127,8 +127,14 @@
   private void setUpPermissions() throws Exception {
     // Remove read permissions for all users besides admin.
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
-      for (AccessSection sec : u.getConfig().getAccessSections()) {
-        sec.removePermission(Permission.READ);
+
+      for (AccessSection sec : ImmutableList.copyOf(u.getConfig().getAccessSections())) {
+        u.getConfig()
+            .upsertAccessSection(
+                sec.getName(),
+                updatedSec -> {
+                  updatedSec.removePermission(Permission.READ);
+                });
       }
       u.save();
     }
@@ -139,8 +145,13 @@
 
     // Remove all read permissions on All-Users.
     try (ProjectConfigUpdate u = updateProject(allUsers)) {
-      for (AccessSection sec : u.getConfig().getAccessSections()) {
-        sec.removePermission(Permission.READ);
+      for (AccessSection sec : ImmutableList.copyOf(u.getConfig().getAccessSections())) {
+        u.getConfig()
+            .upsertAccessSection(
+                sec.getName(),
+                updatedSec -> {
+                  updatedSec.removePermission(Permission.READ);
+                });
       }
       u.save();
     }
@@ -1071,7 +1082,7 @@
 
       PersonIdent committer = serverIdent.get();
       PersonIdent author =
-          noteUtil.newIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
+          noteUtil.newAccountIdIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
       tr.branch(RefNames.changeMetaRef(cd3.getId()))
           .commit()
           .author(author)
diff --git a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
index d7952e4..9c5afd2 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.events.RefReceivedEvent;
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 0efc4f9..1c8ca93 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -24,6 +24,12 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -47,6 +53,7 @@
   }
 
   @Inject private ProjectOperations projectOperations;
+  @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
 
   @Test
   @GerritConfig(name = "submodule.enableSuperProjectSubscriptions", value = "false")
@@ -631,6 +638,124 @@
     expectToHaveSubmoduleState(superRepo, "master", subKey, badId);
   }
 
+  @Test
+  public void blockSubmissionForChangesModifyingSpecifiedSubmodule() throws Exception {
+    ObjectId commitId = getCommitWithSubmoduleUpdate();
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = "branch";
+    cherryPickInput.allowConflicts = true;
+
+    // The rule will fail if the next change has a submodule file modification with subKey.
+    modifySubmitRulesToBlockSubmoduleChanges(String.format("file('%s','M','SUBMODULE')", subKey));
+
+    // Cherry-pick the newly created commit which contains a submodule update, to branch "branch".
+    ChangeApi changeApi =
+        gApi.projects().name(superKey.get()).commit(commitId.getName()).cherryPick(cherryPickInput);
+
+    // Add another file to this change for good measure.
+    PushOneCommit.Result result =
+        amendChange(changeApi.get().changeId, "subject", "newFile", "content");
+
+    assertThat(getStatus(result.getChange())).isEqualTo("NOT_READY");
+    assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isFalse();
+  }
+
+  @Test
+  public void blockSubmissionWithSubmodules() throws Exception {
+    ObjectId commitId = getCommitWithSubmoduleUpdate();
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = "branch";
+    cherryPickInput.allowConflicts = true;
+
+    // The rule will fail if the next change has any submodule file.
+    modifySubmitRulesToBlockSubmoduleChanges("file(_,_,'SUBMODULE')");
+
+    // Cherry-pick the newly created commit which contains a submodule update, to branch "branch".
+    ChangeApi changeApi =
+        gApi.projects().name(superKey.get()).commit(commitId.getName()).cherryPick(cherryPickInput);
+
+    // Add another file to this change for good measure.
+    PushOneCommit.Result result =
+        amendChange(changeApi.get().changeId, "subject", "newFile", "content");
+
+    assertThat(getStatus(result.getChange())).isEqualTo("NOT_READY");
+    assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isFalse();
+  }
+
+  @Test
+  public void doNotBlockSubmissionWithoutSubmodules() throws Exception {
+    modifySubmitRulesToBlockSubmoduleChanges("file(_,_,'SUBMODULE')");
+
+    PushOneCommit.Result result =
+        createChange(superRepo, "refs/heads/master", "subject", "newFile", "content", null);
+
+    assertThat(getStatus(result.getChange())).isEqualTo("OK");
+    assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isTrue();
+  }
+
+  private ObjectId getCommitWithSubmoduleUpdate() throws Exception {
+    allowMatchingSubmoduleSubscription(subKey, "refs/heads/*", superKey, "refs/heads/*");
+    // Create branch "branch" for the parent and the submodule
+    pushChangeTo(superRepo, "branch");
+    pushChangeTo(subRepo, "branch");
+
+    // Make the superRepo a parent repo of the subRepo, for both branches.
+    createSubmoduleSubscription(superRepo, "master", subKey, "master");
+    createSubmoduleSubscription(superRepo, "branch", subKey, "branch");
+    pushChangeTo(subRepo, "master");
+    pushChangeTo(subRepo, "branch");
+
+    // This push creates a new commit in subRepo, master branch, which makes superRepo update their
+    // submodule.
+    pushChangeTo(subRepo, "master");
+
+    // Fetch the commit from superRepo that Gerrit created automatically to fulfill the submodule
+    // subscription.
+    return superRepo
+        .git()
+        .fetch()
+        .setRemote("origin")
+        .call()
+        .getAdvertisedRef("refs/heads/" + "master")
+        .getObjectId();
+  }
+
+  private void modifySubmitRulesToBlockSubmoduleChanges(String filePrologQuery) throws Exception {
+    String newContent =
+        String.format(
+            "submit_rule(submit(R)) :-\n"
+                + "  gerrit:includes_file(%s),\n"
+                + "  !,\n"
+                + "  R = label('All-Submodules-Resolved', need(_)).\n"
+                + "submit_rule(submit(label('All-Submodules-Resolved', ok(A)))) :-\n"
+                + "  gerrit:commit_author(A).",
+            filePrologQuery);
+
+    try (Repository repo = repoManager.openRepository(superKey);
+        TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .author(admin.newIdent())
+          .committer(admin.newIdent())
+          .add("rules.pl", newContent)
+          .message("Modify rules.pl")
+          .create();
+    }
+    projectCache.evict(superKey);
+  }
+
+  private String getStatus(ChangeData cd) throws Exception {
+
+    try (AutoCloseable changeIndex = disableChangeIndex()) {
+      try (AutoCloseable accountIndex = disableAccountIndex()) {
+        SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
+        return ruleEvaluator.evaluate(cd).iterator().next().status.toString();
+      }
+    }
+  }
+
   private ObjectId directUpdateRef(Project.NameKey project, String ref) throws Exception {
     try (Repository serverRepo = repoManager.openRepository(project);
         TestRepository<Repository> tr = new TestRepository<>(serverRepo)) {
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 0715b7e..8367f60 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
diff --git a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
index 4caee64..4db0177 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.MustBeClosed;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.StandaloneSiteTest;
 import com.google.gerrit.entities.Project;
@@ -82,27 +83,33 @@
 
   private void setProjectsIndexLastModifiedInThePast(Path indexDir, Instant time)
       throws IOException {
-    for (Path path : getAllProjectsIndexFiles(indexDir).collect(Collectors.toList())) {
-      FS.DETECTED.setLastModified(path, time);
+    try (Stream<Path> allprojectsIndexFiles = getAllProjectsIndexFiles(indexDir)) {
+      for (Path path : allprojectsIndexFiles.collect(Collectors.toList())) {
+        FS.DETECTED.setLastModified(path, time);
+      }
     }
   }
 
   private Optional<Instant> getProjectsIndexLastModified(Path indexDir) throws IOException {
-    return getAllProjectsIndexFiles(indexDir)
-        .map(FS.DETECTED::lastModifiedInstant)
-        .max(Comparator.comparingLong(Instant::toEpochMilli));
+    try (Stream<Path> allprojectsIndexFiles = getAllProjectsIndexFiles(indexDir)) {
+      return allprojectsIndexFiles
+          .map(FS.DETECTED::lastModifiedInstant)
+          .max(Comparator.comparingLong(Instant::toEpochMilli));
+    }
   }
 
+  @MustBeClosed
   private Stream<Path> getAllProjectsIndexFiles(Path indexDir) throws IOException {
-    Optional<Path> projectsPath =
-        Files.walk(indexDir, 1)
-            .filter(Files::isDirectory)
-            .filter(p -> p.getFileName().toString().startsWith("projects_"))
-            .findFirst();
-    if (!projectsPath.isPresent()) {
-      return Stream.empty();
+    try (Stream<Path> stream = Files.walk(indexDir, 1)) {
+      Optional<Path> projectsPath =
+          stream
+              .filter(Files::isDirectory)
+              .filter(p -> p.getFileName().toString().startsWith("projects_"))
+              .findFirst();
+      if (!projectsPath.isPresent()) {
+        return Stream.empty();
+      }
+      return Files.walk(projectsPath.get(), 1, FileVisitOption.FOLLOW_LINKS);
     }
-
-    return Files.walk(projectsPath.get(), 1, FileVisitOption.FOLLOW_LINKS);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/PluginsCapabilityIT.java b/javatests/com/google/gerrit/acceptance/rest/PluginsCapabilityIT.java
index af947f8..0780832 100644
--- a/javatests/com/google/gerrit/acceptance/rest/PluginsCapabilityIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/PluginsCapabilityIT.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.acceptance.rest.CreateTestPlugin.Input;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
 import com.google.gerrit.extensions.api.access.PermissionInfo;
 import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 09680fb..f5d9e3a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.config.GerritConfig;
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.httpd.restapi.ParameterParser;
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 53e871f..ac82a78 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -41,8 +41,8 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index a3c0295..a11328f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -37,13 +37,13 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -174,7 +174,7 @@
   public void voteOnBehalfOfLabelNotPermitted() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       LabelType verified = TestLabels.verified();
-      u.getConfig().getLabelSections().put(verified.getName(), verified);
+      u.getConfig().upsertLabelType(verified);
       u.save();
     }
 
@@ -225,7 +225,8 @@
     assertThat(psa.realAccountId()).isEqualTo(admin.id());
 
     ChangeData cd = r.getChange();
-    Comment c = Iterables.getOnlyElement(commentsUtil.publishedByChange(cd.notes()));
+    HumanComment c =
+        Iterables.getOnlyElement(commentsUtil.publishedHumanCommentsByChange(cd.notes()));
     assertThat(c.message).isEqualTo(ci.message);
     assertThat(c.author.getId()).isEqualTo(user.id());
     assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index 55eeaf4..f1c0110 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -29,8 +29,8 @@
 import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
 import com.google.gerrit.acceptance.rest.util.RestCall;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -70,6 +70,7 @@
           RestCall.get("/projects/%s/statistics.git"),
           RestCall.post("/projects/%s/index"),
           RestCall.post("/projects/%s/gc"),
+          RestCall.post("/projects/%s/create.change"),
           RestCall.get("/projects/%s/children"),
           RestCall.get("/projects/%s/branches"),
           RestCall.post("/projects/%s/branches:delete"),
@@ -277,7 +278,7 @@
     }
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getProject().setLocalDefaultDashboard(dashboardRef + ":overview");
+      u.getConfig().updateProject(p -> p.setLocalDefaultDashboard(dashboardRef + ":overview"));
       u.save();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 72db9b3..faef5aa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -55,15 +55,18 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
@@ -105,6 +108,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -299,6 +303,89 @@
     assertTrees(project, actual);
   }
 
+  /**
+   * Tests the following situation:
+   *
+   * <ul>
+   *   <li>1. create a change series, consisting out of a merge commit and a normal commit
+   *   <li>2. before submitting the change series, another non-conflicting change gets submitted
+   *   <li>3. when the change series gets submitted, Gerrit must perform a merge/rebase/cherry-pick
+   * </ul>
+   */
+  @Test
+  public void submitChangeSeriesWithMergeCommitThatIsBasedOnOldTip() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+
+    // create a commit which will become the first parent of a merge commit
+    PushOneCommit.Result parent1 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "parent 2",
+                ImmutableMap.of("foo", "foo-2", "bar", "bar-2"))
+            .to("refs/heads/master");
+
+    // reset the testRepo in order to create a sibling of parent1
+    testRepo.reset(initialHead);
+
+    // create a stable branch that we can merge back into master later
+    BranchInput in = new BranchInput();
+    in.revision = initialHead.getName();
+    gApi.projects().name(project.get()).branch("refs/heads/stable").create(in);
+
+    // create one commit in the stable branch, which will become the second parent of the merge
+    // commit
+    PushOneCommit.Result parent2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "parent 1",
+                ImmutableMap.of("foo", "foo-1", "bar", "bar-1"))
+            .to("refs/heads/stable");
+
+    // create a merge change that merges the stable branch back into master
+    testRepo.reset(parent1.getCommit());
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "merge", ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
+    PushOneCommit.Result mergeChange = m.to("refs/for/master");
+    mergeChange.assertOkStatus();
+
+    // approve the merge change so that it becomes submittable
+    approve(mergeChange.getChangeId());
+
+    // create a successor change that depends on the merge change
+    PushOneCommit.Result successorChange = createChange("refs/for/master");
+
+    // simulate another developer submitting a change in the meantime (non-conflicting sibling
+    // commit of the merge commit), this means when the change series gets submitted Gerrit must
+    // perform a merge/rebase/cherry-pick now
+    testRepo.reset(parent1.getCommit());
+    submit(createChange("Other Change", "x.txt", "x content").getChangeId());
+
+    // submit the change series
+    if (getSubmitType() != SubmitType.FAST_FORWARD_ONLY) {
+      submit(successorChange.getChangeId());
+    } else {
+      submitWithConflict(
+          successorChange.getChangeId(),
+          "Failed to submit 2 changes due to the following problems:\n"
+              + "Change "
+              + mergeChange.getChange().getId()
+              + ": Project policy "
+              + "requires all submissions to be a fast-forward. Please "
+              + "rebase the change locally and upload again for review.\n"
+              + "Change "
+              + successorChange.getChange().getId()
+              + ": Project policy "
+              + "requires all submissions to be a fast-forward. Please "
+              + "rebase the change locally and upload again for review.");
+    }
+  }
+
   @Test
   public void submitNoPermission() throws Throwable {
     // create project where submit is blocked
@@ -1254,6 +1341,36 @@
     }
   }
 
+  @Test
+  public void submitThatAddsUsersAsReviewersEnsuresTheyAreNotAddedToAttentionSet()
+      throws Exception {
+    PushOneCommit.Result r = createChange("refs/heads/master", "file1", "content");
+
+    // Someone else approves, because if admin reviews, they will be added to the reviewers (and the
+    // bug won't be reproduced).
+    requestScopeOperations.setApiUser(accountCreator.admin2().id());
+    change(r).current().review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    change(r).attention(admin.email()).remove(new AttentionSetInput("remove"));
+    change(r).current().submit();
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("remove");
+  }
+
+  private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
+      PushOneCommit.Result r, TestAccount account) {
+    return r.getChange().attentionSet().stream()
+        .filter(a -> a.account().get() == account.id().get())
+        .collect(Collectors.toList());
+  }
+
   private void assertSubmitter(PushOneCommit.Result change) throws Throwable {
     ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
     assertThat(info.messages).isNotNull();
@@ -1261,11 +1378,11 @@
     assertThat(messages).hasSize(3);
     String last = Iterables.getLast(messages);
     if (getSubmitType() == SubmitType.CHERRY_PICK) {
-      assertThat(last).startsWith("Change has been successfully cherry-picked as ");
+      assertThat(last).startsWith("Change has been successfully cherry-picked as");
     } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
       assertThat(last).startsWith("Change has been successfully rebased and submitted as");
     } else {
-      assertThat(last).isEqualTo("Change has been successfully merged by Administrator");
+      assertThat(last).isEqualTo("Change has been successfully merged");
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index fff67f3..955dd7a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -28,8 +28,8 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 2d47dd8..36cd3cb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.client.ReviewerState;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index caa8832..c88dbff 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -15,25 +15,43 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.AttentionSetUpdate;
-import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
-import com.google.gerrit.extensions.api.changes.RemoveFromAttentionSetInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.FakeEmailSender;
+import com.google.inject.Inject;
 import java.time.Duration;
 import java.time.Instant;
+import java.util.List;
 import java.util.concurrent.TimeUnit;
 import java.util.function.LongSupplier;
+import java.util.stream.Collectors;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 @UseClockStep(clockStepUnit = TimeUnit.MINUTES)
 public class AttentionSetIT extends AbstractDaemonTest {
+
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private FakeEmailSender email;
+
   /** Simulates a fake clock. Uses second granularity. */
   private static class FakeClock implements LongSupplier {
     Instant now = Instant.now();
@@ -68,8 +86,9 @@
   @Test
   public void addUser() throws Exception {
     PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
     int accountId =
-        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "first"))._accountId;
+        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "first"))._accountId;
     assertThat(accountId).isEqualTo(user.id().get());
     AttentionSetUpdate expectedAttentionSetUpdate =
         AttentionSetUpdate.createFromRead(
@@ -78,9 +97,16 @@
 
     // Second add is ignored.
     accountId =
-        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "second"))._accountId;
+        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "second"))._accountId;
     assertThat(accountId).isEqualTo(user.id().get());
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+
+    // Only one email since the second add was ignored.
+    String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
+    assertThat(emailBody)
+        .contains(
+            user.fullName()
+                + " added themselves to the attention set of this change.\n The reason is: first.");
   }
 
   @Test
@@ -88,13 +114,13 @@
     PushOneCommit.Result r = createChange();
     Instant timestamp1 = fakeClock.now();
     int accountId1 =
-        change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "user"))._accountId;
+        change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"))._accountId;
     assertThat(accountId1).isEqualTo(user.id().get());
     fakeClock.advance(Duration.ofSeconds(42));
     Instant timestamp2 = fakeClock.now();
     int accountId2 =
         change(r)
-            .addToAttentionSet(new AddToAttentionSetInput(admin.id().toString(), "admin"))
+            .addToAttentionSet(new AttentionSetInput(admin.id().toString(), "admin"))
             ._accountId;
     assertThat(accountId2).isEqualTo(admin.id().get());
 
@@ -111,9 +137,11 @@
   @Test
   public void removeUser() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).addToAttentionSet(new AddToAttentionSetInput(user.email(), "added"));
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "added"));
+    requestScopeOperations.setApiUser(user.id());
+
     fakeClock.advance(Duration.ofSeconds(42));
-    change(r).attention(user.id().toString()).remove(new RemoveFromAttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
     AttentionSetUpdate expectedAttentionSetUpdate =
         AttentionSetUpdate.createFromRead(
             fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed");
@@ -121,16 +149,834 @@
 
     // Second removal is ignored.
     fakeClock.advance(Duration.ofSeconds(42));
-    change(r)
-        .attention(user.id().toString())
-        .remove(new RemoveFromAttentionSetInput("removed again"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed again"));
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+
+    // Only one email since the second remove was ignored.
+    String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
+    assertThat(emailBody)
+        .contains(
+            user.fullName()
+                + " removed themselves from the attention set of this change.\n The reason is: removed.");
+  }
+
+  @Test
+  public void removeUserWithInvalidUserInput() throws Exception {
+    PushOneCommit.Result r = createChange();
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                change(r)
+                    .attention(user.id().toString())
+                    .remove(new AttentionSetInput("invalid user", "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo("The user specified in the input body couldn't be found.");
+
+    exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                change(r)
+                    .attention(user.id().toString())
+                    .remove(new AttentionSetInput(admin.email(), "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "The field \"user\" must be empty, or must match the user specified in the URL.");
   }
 
   @Test
   public void removeUnrelatedUser() throws Exception {
     PushOneCommit.Result r = createChange();
-    change(r).attention(user.id().toString()).remove(new RemoveFromAttentionSetInput("foo"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("foo"));
     assertThat(r.getChange().attentionSet()).isEmpty();
   }
+
+  @Test
+  public void abandonRemovesUsers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+    change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "admin"));
+
+    change(r).abandon();
+
+    AttentionSetUpdate userUpdate =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(userUpdate.account()).isEqualTo(user.id());
+    assertThat(userUpdate.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(userUpdate.reason()).isEqualTo("Change was abandoned");
+
+    AttentionSetUpdate adminUpdate =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(adminUpdate.account()).isEqualTo(admin.id());
+    assertThat(adminUpdate.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(adminUpdate.reason()).isEqualTo("Change was abandoned");
+  }
+
+  @Test
+  public void workInProgressRemovesUsers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+
+    change(r).setWorkInProgress();
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Change was marked work in progress");
+  }
+
+  @Test
+  public void submitRemovesUsersForAllSubmittedChanges() throws Exception {
+    PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
+
+    change(r1)
+        .current()
+        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+    PushOneCommit.Result r2 = createChange("refs/heads/master", "file2", "content");
+    change(r2)
+        .current()
+        .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+
+    change(r2).current().submit();
+
+    // Attention set updates that relate to the admin (the person who replied) are filtered out.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r1, user));
+
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Change was submitted");
+
+    // Attention set updates that relate to the admin (the person who replied) are filtered out.
+    attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r2, user));
+
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Change was submitted");
+  }
+
+  @Test
+  public void addedReviewersAreAddedToAttentionSetOnMergedChanges() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).current().review(ReviewInput.approve());
+    change(r).current().submit();
+
+    change(r).addReviewer(user.email());
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was added");
+  }
+
+  @Test
+  public void reviewersAddedAndRemovedFromAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r).addReviewer(user.id().toString());
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was added");
+
+    change(r).reviewer(user.email()).remove();
+
+    attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+  }
+
+  @Test
+  public void reviewersAddedAndRemovedByEmailFromAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r).addReviewer(user.email());
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was added");
+
+    change(r).reviewer(user.email()).remove();
+
+    attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+  }
+
+  @Test
+  public void reviewersInWorkProgressNotAddedToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void addingReviewerWhileMarkingWorkInprogressDoesntAddToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = ReviewInput.create().setWorkInProgress(true);
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = ReviewerState.REVIEWER;
+    addReviewerInput.reviewer = user.email();
+    reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+
+    change(r).current().review(reviewInput);
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
+  public void reviewersAddedAsReviewersAgainAreNotAddedToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    change(r).addReviewer(user.id().toString());
+    change(r)
+        .attention(user.id().toString())
+        .remove(new AttentionSetInput("removed and not re-added when re-adding as reviewer"));
+
+    change(r).addReviewer(user.id().toString());
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason())
+        .isEqualTo("removed and not re-added when re-adding as reviewer");
+  }
+
+  @Test
+  public void ccsAreIgnored() throws Exception {
+    PushOneCommit.Result r = createChange();
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = ReviewerState.CC;
+    addReviewerInput.reviewer = user.email();
+
+    change(r).addReviewer(addReviewerInput);
+
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void ccsConsideredSameAsRemovedForExistingReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addReviewer(user.email());
+
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = ReviewerState.CC;
+    addReviewerInput.reviewer = user.email();
+    change(r).addReviewer(addReviewerInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+  }
+
+  @Test
+  public void readyForReviewAddsAllReviewersToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    change(r).setReadyForReview();
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("Change was marked ready for review");
+  }
+
+  @Test
+  public void readyForReviewWhileRemovingReviewerRemovesThemToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+
+    ReviewInput reviewInput = ReviewInput.create().setReady(true);
+    AddReviewerInput addReviewerInput = new AddReviewerInput();
+    addReviewerInput.state = ReviewerState.CC;
+    addReviewerInput.reviewer = user.email();
+    reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+  }
+
+  @Test
+  public void readyForReviewWhileAddingReviewerAddsThemToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+
+    ReviewInput reviewInput = ReviewInput.create().setReady(true).reviewer(user.email());
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was added");
+  }
+
+  @Test
+  public void reviewersAreNotAddedForNoReasonBecauseOfAnUpdate() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+
+    HashtagsInput hashtagsInput = new HashtagsInput();
+    hashtagsInput.add = ImmutableSet.of("tag");
+    change(r).setHashtags(hashtagsInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("removed");
+  }
+
+  @Test
+  public void reviewAddsManuallyAddedUserToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet(user.email(), "reason");
+
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("reason");
+
+    // No emails for adding to attention set were sent.
+    email.getMessages().isEmpty();
+  }
+
+  @Test
+  public void reviewRemovesManuallyRemovedUserFromAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput reviewInput =
+        ReviewInput.create().removeUserFromAttentionSet(user.email(), "reason");
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("reason");
+
+    // No emails for removing from attention set were sent.
+    email.getMessages().isEmpty();
+  }
+
+  @Test
+  public void reviewWithManualAdditionToAttentionSetFailsWithoutReason() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+
+    ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet(user.email(), "");
+
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> change(r).current().review(reviewInput));
+
+    assertThat(exception.getMessage()).isEqualTo("missing field: reason");
+  }
+
+  @Test
+  public void reviewWithManualAdditionToAttentionSetFailsWithoutUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet("", "reason");
+
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> change(r).current().review(reviewInput));
+
+    assertThat(exception.getMessage()).isEqualTo("missing field: user");
+  }
+
+  @Test
+  public void reviewAddReviewerWhileRemovingFromAttentionSetJustRemovesUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "addition"));
+
+    ReviewInput reviewInput =
+        ReviewInput.create()
+            .reviewer(user.email())
+            .removeUserFromAttentionSet(user.email(), "reason");
+
+    change(r).current().review(reviewInput);
+
+    // Attention set updates that relate to the admin (the person who replied) are filtered out.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("reason");
+  }
+
+  @Test
+  public void cantAddAndRemoveSameUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput =
+        ReviewInput.create()
+            .removeUserFromAttentionSet(user.email(), "reason")
+            .addUserToAttentionSet(user.username(), "reason");
+
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> change(r).current().review(reviewInput));
+
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "user can not be added/removed twice, and can not be added and removed at the same time");
+  }
+
+  @Test
+  public void cantRemoveSameUserTwice() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput =
+        ReviewInput.create()
+            .removeUserFromAttentionSet(user.email(), "reason1")
+            .removeUserFromAttentionSet(user.username(), "reason2");
+
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> change(r).current().review(reviewInput));
+
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "user can not be added/removed twice, and can not be added and removed at the same time");
+  }
+
+  @Test
+  public void cantAddSameUserTwice() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput reviewInput =
+        ReviewInput.create()
+            .addUserToAttentionSet(user.email(), "reason1")
+            .addUserToAttentionSet(user.username(), "reason2");
+
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> change(r).current().review(reviewInput));
+
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "user can not be added/removed twice, and can not be added and removed at the same time");
+  }
+
+  @Test
+  public void reviewRemoveFromAttentionSetWhileMarkingReadyForReviewJustRemovesUser()
+      throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    change(r).addReviewer(user.email());
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+
+    ReviewInput reviewInput =
+        ReviewInput.create().setReady(true).removeUserFromAttentionSet(user.email(), "reason");
+
+    change(r).current().review(reviewInput);
+
+    // Attention set updates that relate to the admin (the person who replied) are filtered out.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("reason");
+  }
+
+  @Test
+  public void reviewAddToAttentionSetWhileMarkingWorkInProgressJustAddsUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addReviewer(user.email());
+
+    ReviewInput reviewInput =
+        ReviewInput.create().setWorkInProgress(true).addUserToAttentionSet(user.email(), "reason");
+
+    change(r).attention(user.email()).remove(new AttentionSetInput("removal"));
+    change(r).current().review(reviewInput);
+
+    // Attention set updates that relate to the admin (the person who replied) are filtered out.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("reason");
+  }
+
+  @Test
+  public void reviewRemovesUserFromAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"));
+
+    ReviewInput reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("removed on reply");
+  }
+
+  @Test
+  public void reviewAddUserToAttentionSetWhileReplyingJustAddsUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet(admin.email(), "reason");
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("reason");
+  }
+
+  @Test
+  public void reviewWhileAddingThemselvesAsReviewerStillRemovesThem() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+    ReviewInput reviewInput = ReviewInput.create().reviewer(user.email());
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("removed on reply");
+  }
+
+  @Test
+  public void reviewWhileAddingThemselvesAsReviewerDoesNotAddThem() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput reviewInput = ReviewInput.create().reviewer(user.email());
+    change(r).current().review(reviewInput);
+
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
+  public void repliesAddsOwner() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("reviewer or cc replied");
+  }
+
+  @Test
+  public void repliesDoNotAddOwnerWhenChangeIsWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    requestScopeOperations.setApiUser(user.id());
+
+    ReviewInput reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+  }
+
+  @Test
+  public void repliesDoNotAddOwnerWhenChangeIsBecomingWorkInProgress() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(accountCreator.admin2().id());
+
+    ReviewInput reviewInput = ReviewInput.create().setWorkInProgress(true);
+    change(r).current().review(reviewInput);
+
+    assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+  }
+
+  @Test
+  public void repliesAddOwnerWhenChangeIsBecomingReadyForReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).setWorkInProgress();
+    requestScopeOperations.setApiUser(accountCreator.admin2().id());
+
+    ReviewInput reviewInput = ReviewInput.create().setReady(true);
+    change(r).current().review(reviewInput);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("reviewer or cc replied");
+  }
+
+  @Test
+  public void repliesAddsOwnerAndUploader() throws Exception {
+    // Create change with owner: admin
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+
+    change(r).attention(user.email()).remove(new AttentionSetInput("reason"));
+    ReviewInput reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    // Uploader added
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("reviewer or cc replied");
+
+    // Owner added
+    attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("reviewer or cc replied");
+  }
+
+  @Test
+  public void ownerRepliesAddsReviewersOnly() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // add reviewer and cc
+    change(r).addReviewer(user.email());
+    change(r)
+        .attention(user.email())
+        .remove(new AttentionSetInput("Reviewer is not in attention-set"));
+
+    TestAccount cc = accountCreator.admin2();
+    AddReviewerInput input = new AddReviewerInput();
+    input.state = ReviewerState.CC;
+    input.reviewer = cc.email();
+    change(r).addReviewer(input);
+
+    ReviewInput reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    // cc not added
+    assertThat(getAttentionSetUpdatesForUser(r, cc)).isEmpty();
+
+    // reviewer added
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("owner or uploader replied");
+  }
+
+  @Test
+  public void ownerRepliesWhileRemovingReviewerStillRemovesFromAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addReviewer(user.email());
+
+    ReviewInput reviewInput = ReviewInput.create().reviewer(user.email(), ReviewerState.CC, false);
+    change(r).current().review(reviewInput);
+
+    // cc removed
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+  }
+
+  @Test
+  public void uploaderRepliesAddsOwnerAndReviewersOnly() throws Exception {
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+
+    // Add reviewer and cc
+    TestAccount reviewer = accountCreator.user2();
+    change(r).addReviewer(reviewer.email());
+    TestAccount cc = accountCreator.admin2();
+    AddReviewerInput input = new AddReviewerInput();
+    input.state = ReviewerState.CC;
+    input.reviewer = cc.email();
+    change(r).addReviewer(input);
+
+    requestScopeOperations.setApiUser(user.id());
+    change(r).attention(reviewer.email()).remove(new AttentionSetInput("reason"));
+
+    ReviewInput reviewInput = new ReviewInput();
+    change(r).current().review(reviewInput);
+
+    // cc not added
+    assertThat(getAttentionSetUpdatesForUser(r, cc)).isEmpty();
+
+    // reviewer added
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, reviewer));
+    assertThat(attentionSet.account()).isEqualTo(reviewer.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("owner or uploader replied");
+
+    // Owner added
+    attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("uploader replied");
+  }
+
+  @Test
+  public void repliesWhileAddingAsReviewerStillRemovesUser() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(user.email(), "remove"));
+
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput reviewInput = ReviewInput.recommend();
+    change(r).current().review(reviewInput);
+
+    // reviewer removed
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("removed on reply");
+  }
+
+  @Test
+  public void attentionSetUnchangedWithIgnoreAutomaticAttentionSetRules() throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"));
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .reviewer(admin.email(), ReviewerState.CC, false)
+                .blockAutomaticAttentionSetRules());
+
+    // admin is still in the attention set, although replies remove from attention set, and removing
+    // from reviewer also should remove from attention set.
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("reason");
+  }
+
+  @Test
+  public void attentionSetStillChangesWithIgnoreAutomaticAttentionSetRulesWithInputList()
+      throws Exception {
+    PushOneCommit.Result r = createChange();
+    change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"));
+    change(r)
+        .current()
+        .review(
+            ReviewInput.create()
+                .removeUserFromAttentionSet(admin.email(), "removed")
+                .blockAutomaticAttentionSetRules());
+
+    // Admin is still removed although we block default attention set rules, since we remove
+    // the admin manually.
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet.reason()).isEqualTo("removed");
+  }
+
+  @Test
+  public void robotsNotAddedToAttentionSet() throws Exception {
+    TestAccount robot =
+        accountCreator.create(
+            "robot1", "robot1@example.com", "Ro Bot", "Ro", "Non-Interactive Users");
+    PushOneCommit.Result r = createChange();
+
+    // Throw an error when adding a robot explicitly.
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> change(r).addToAttentionSet(new AttentionSetInput(robot.email(), "reason")));
+    assertThat(exception.getMessage())
+        .isEqualTo(
+            "robot1@example.com is a robot, and robots can't be added to the attention set.");
+
+    // Robots are not added implicitly.
+    change(r).addReviewer(robot.email());
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void robotAddingAReviewerChangeAttentionSet() throws Exception {
+    TestAccount robot =
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", "Non-Interactive Users");
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).addReviewer(user.id().toString());
+
+    // Bots can still change the attention set, just not when replying.
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet.account()).isEqualTo(user.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("Reviewer was added");
+  }
+
+  @Test
+  public void robotReviewDoesNotChangeAttentionSet() throws Exception {
+    TestAccount robot =
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", "Non-Interactive Users");
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).current().review(ReviewInput.recommend());
+
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
+  public void robotReviewWithNegativeLabelAddsOwner() throws Exception {
+    TestAccount robot =
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", "Non-Interactive Users");
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).current().review(ReviewInput.dislike());
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("A robot voted negatively on a label");
+  }
+
+  @Test
+  public void robotCanChangeAttentionSetExplicitly() throws Exception {
+    TestAccount robot =
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", "Non-Interactive Users");
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).current().review(new ReviewInput().addUserToAttentionSet(admin.email(), "reason"));
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+    assertThat(attentionSet.account()).isEqualTo(admin.id());
+    assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet.reason()).isEqualTo("reason");
+  }
+
+  private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
+      PushOneCommit.Result r, TestAccount account) {
+    return r.getChange().attentionSet().stream()
+        .filter(a -> a.account().get() == account.id().get())
+        .collect(Collectors.toList());
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
index 47fb20a..dd85cb0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index 843ecc6..012e98d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -33,7 +34,6 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 94357b9..a6bd5eb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -36,7 +36,8 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
@@ -53,7 +54,6 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index 243991b..ed21050 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index a0ebf02..7fe2a50 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
-import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.entities.Permission.READ;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static com.google.gerrit.extensions.common.testing.GitPersonSubject.assertThat;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
@@ -36,9 +36,9 @@
 import com.google.gerrit.acceptance.UseSystemTime;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -429,7 +429,8 @@
       assertThat(commit.getShortMessage()).isEqualTo("Create change");
 
       PersonIdent expectedAuthor =
-          changeNoteUtil.newIdent(getAccount(admin.id()).id(), c.created, serverIdent.get());
+          changeNoteUtil.newAccountIdIdent(
+              getAccount(admin.id()).id(), c.created, serverIdent.get());
       assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
 
       assertThat(commit.getCommitterIdent())
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index 0099fe6..058a96f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index 61dc4d4..def4ed8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -26,8 +26,8 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 37b1713..d5881ea 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -28,10 +28,10 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -245,7 +245,7 @@
 
     LabelType patchSetLock = TestLabels.patchSetLock();
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(patchSetLock.getName(), patchSetLock);
+      u.getConfig().upsertLabelType(patchSetLock);
       u.save();
     }
     projectOperations
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 670cff2..1912697 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index b259d90..5fe741d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -19,7 +19,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
-import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.entities.Permission.READ;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -28,9 +28,9 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 551a349..888878f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -19,7 +19,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
-import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.entities.Permission.READ;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java b/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
index a3c1722..614ce80 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.restapi.config.IndexChanges;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
index d70d120..191d5c5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.PersonIdent;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 0514e03..33d0d29 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -31,10 +31,10 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
@@ -120,8 +120,11 @@
     try (Repository repo = repoManager.openRepository(newProjectName)) {
       MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
       ProjectConfig projectConfig = projectConfigFactory.read(md);
-      AccessSection heads = projectConfig.getAccessSection(AccessSection.HEADS, true);
-      grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+          });
       md.getCommitBuilder().setAuthor(admin.newIdent());
       md.getCommitBuilder().setCommitter(admin.newIdent());
       md.setMessage("Add revert permission for all registered users\n");
@@ -155,15 +158,19 @@
     try (Repository repo = repoManager.openRepository(newProjectName)) {
       MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
       ProjectConfig projectConfig = projectConfigFactory.read(md);
-      AccessSection heads = projectConfig.getAccessSection(AccessSection.HEADS, true);
-      grant(projectConfig, heads, Permission.REVERT, registeredUsers);
-      grant(projectConfig, heads, Permission.REVERT, otherGroup);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+            grant(projectConfig, heads, Permission.REVERT, otherGroup);
+          });
       md.getCommitBuilder().setAuthor(admin.newIdent());
       md.getCommitBuilder().setCommitter(admin.newIdent());
       md.setMessage("Add revert permission for all registered users\n");
 
       projectConfig.commit(md);
     }
+    projectCache.evict(newProjectName);
     ProjectAccessInfo expected = pApi().access();
 
     grantRevertPermission.execute(newProjectName);
@@ -181,7 +188,7 @@
     try (Repository repo = repoManager.openRepository(newProjectName)) {
       MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
       ProjectConfig projectConfig = projectConfigFactory.read(md);
-      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL, true);
+      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL);
 
       Permission permission = all.getPermission(Permission.REVERT);
       assertThat(permission.getRules()).hasSize(1);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index 54ae5af..5e1fc83 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -21,6 +21,7 @@
     ],
     deps = [
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index b01a07b..096c72b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -29,10 +29,10 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
new file mode 100644
index 0000000..0c221aa
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.entities.RefNames.REFS_HEADS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.common.ChangeInput;
+import org.junit.Test;
+
+public class CreateChangeIT extends AbstractDaemonTest {
+
+  /**
+   * Just a basic test. The real functionality is tested by {@link
+   * com.google.gerrit.acceptance.rest.change.CreateChangeIT}.
+   */
+  @Test
+  public void basic() throws Exception {
+    BranchInput branchInput = new BranchInput();
+    branchInput.ref = "foo";
+    assertThat(gApi.projects().name(project.get()).branches().get().stream().map(i -> i.ref))
+        .doesNotContain(REFS_HEADS + branchInput.ref);
+    RestResponse r =
+        adminRestSession.put(
+            "/projects/" + project.get() + "/branches/" + branchInput.ref, branchInput);
+    r.assertCreated();
+
+    ChangeInput input = new ChangeInput();
+    input.branch = "foo";
+    input.subject = "subject";
+    RestResponse cr = adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+    cr.assertCreated();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index e5587a9..94511f8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -25,8 +25,8 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index 874f07a..10fd65f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -224,7 +224,7 @@
     Project project = projectCache.get(Project.nameKey(newProjectName)).get().getProject();
     assertProjectInfo(project, p);
     assertThat(project.getDescription()).isEqualTo(in.description);
-    assertThat(project.getConfiguredSubmitType()).isEqualTo(in.submitType);
+    assertThat(project.getSubmitType()).isEqualTo(in.submitType);
     assertThat(project.getBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS))
         .isEqualTo(in.useContributorAgreements);
     assertThat(project.getBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY))
@@ -368,8 +368,11 @@
 
   @Test
   public void createProjectWithCreateProjectCapabilityAndParentNotVisible() throws Exception {
-    Project parent = projectCache.get(allProjects).get().getProject();
-    parent.setState(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
+    try (ProjectConfigUpdate u = updateProject(allProjects)) {
+      u.getConfig()
+          .updateProject(p -> p.setState(com.google.gerrit.extensions.client.ProjectState.HIDDEN));
+      u.save();
+    }
     projectOperations
         .allProjectsForUpdate()
         .add(
@@ -383,7 +386,12 @@
       ProjectInfo p = gApi.projects().create(in).get();
       assertThat(p.name).isEqualTo(in.name);
     } finally {
-      parent.setState(com.google.gerrit.extensions.client.ProjectState.ACTIVE);
+      try (ProjectConfigUpdate u = updateProject(allProjects)) {
+        u.getConfig()
+            .updateProject(
+                p -> p.setState(com.google.gerrit.extensions.client.ProjectState.ACTIVE));
+        u.save();
+      }
       projectOperations
           .allProjectsForUpdate()
           .remove(
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index 5636014..c98a58e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -26,8 +26,8 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
index ad90109..98fc020 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
index c916285..57c7b17 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 9770031..7e60395 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
index b18db81..5bd0e25 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.inject.Inject;
 import org.eclipse.jgit.junit.TestRepository;
@@ -129,7 +129,12 @@
 
   private void unblockRead() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getAccessSection("refs/*").remove(new Permission(Permission.READ));
+      u.getConfig()
+          .upsertAccessSection(
+              "refs/*",
+              as -> {
+                as.remove(Permission.builder(Permission.READ));
+              });
       u.save();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
index 940fae5..a2c5c64 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
@@ -21,8 +21,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -75,9 +74,7 @@
 
     // set default value
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setDefaultValue((short) 1);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig().updateLabelType("foo", labelType -> labelType.setDefaultValue((short) 1));
       u.save();
     }
 
@@ -100,11 +97,14 @@
 
     // unset rules which are enabled by default
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setCanOverride(false);
-      labelType.setCopyAllScoresIfNoChange(false);
-      labelType.setAllowPostSubmit(false);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig()
+          .updateLabelType(
+              "foo",
+              labelType -> {
+                labelType.setCanOverride(false);
+                labelType.setCopyAllScoresIfNoChange(false);
+                labelType.setAllowPostSubmit(false);
+              });
       u.save();
     }
 
@@ -128,16 +128,19 @@
 
     // set rules which are not enabled by default
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setCopyAnyScore(true);
-      labelType.setCopyMinScore(true);
-      labelType.setCopyMaxScore(true);
-      labelType.setCopyAllScoresIfNoCodeChange(true);
-      labelType.setCopyAllScoresOnTrivialRebase(true);
-      labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
-      labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
-      labelType.setIgnoreSelfApproval(true);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig()
+          .updateLabelType(
+              "foo",
+              labelType -> {
+                labelType.setCopyAnyScore(true);
+                labelType.setCopyMinScore(true);
+                labelType.setCopyMaxScore(true);
+                labelType.setCopyAllScoresIfNoCodeChange(true);
+                labelType.setCopyAllScoresOnTrivialRebase(true);
+                labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
+                labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
+                labelType.setIgnoreSelfApproval(true);
+              });
       u.save();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
index 65e352b..201bb53 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index 91a2c4b..f8be28b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index ef08079..d39c96e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -26,9 +26,8 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -88,9 +87,7 @@
 
     // set default value
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setDefaultValue((short) 1);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig().updateLabelType("foo", labelType -> labelType.setDefaultValue((short) 1));
       u.save();
     }
 
@@ -119,11 +116,14 @@
 
     // unset rules which are enabled by default
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setCanOverride(false);
-      labelType.setCopyAllScoresIfNoChange(false);
-      labelType.setAllowPostSubmit(false);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig()
+          .updateLabelType(
+              "foo",
+              labelType -> {
+                labelType.setCanOverride(false);
+                labelType.setCopyAllScoresIfNoChange(false);
+                labelType.setAllowPostSubmit(false);
+              });
       u.save();
     }
 
@@ -150,16 +150,19 @@
 
     // set rules which are not enabled by default
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setCopyAnyScore(true);
-      labelType.setCopyMinScore(true);
-      labelType.setCopyMaxScore(true);
-      labelType.setCopyAllScoresIfNoCodeChange(true);
-      labelType.setCopyAllScoresOnTrivialRebase(true);
-      labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
-      labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
-      labelType.setIgnoreSelfApproval(true);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig()
+          .updateLabelType(
+              "foo",
+              labelType -> {
+                labelType.setCopyAnyScore(true);
+                labelType.setCopyMinScore(true);
+                labelType.setCopyMaxScore(true);
+                labelType.setCopyAllScoresIfNoCodeChange(true);
+                labelType.setCopyAllScoresOnTrivialRebase(true);
+                labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
+                labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
+                labelType.setIgnoreSelfApproval(true);
+              });
       u.save();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index bb08267..2e274d9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java
index e7663f7..93b1f12 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java
@@ -18,7 +18,7 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
 import com.google.gerrit.extensions.api.access.PermissionInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
index 9e6b051..ba52024 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.BatchLabelInput;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index b08c72b..1e8d978 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -25,9 +25,8 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInput;
@@ -450,9 +449,7 @@
   public void setCanOverride() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setCanOverride(false);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig().updateLabelType("foo", lt -> lt.setCanOverride(false));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().canOverride).isNull();
@@ -501,9 +498,7 @@
   public void unsetCopyAnyScore() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setCopyAnyScore(true);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAnyScore(true));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isTrue();
@@ -537,9 +532,7 @@
   public void unsetCopyMinScore() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setCopyMinScore(true);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig().updateLabelType("foo", lt -> lt.setCopyMinScore(true));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isTrue();
@@ -573,9 +566,7 @@
   public void unsetCopyMaxScore() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setCopyMaxScore(true);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig().updateLabelType("foo", lt -> lt.setCopyMaxScore(true));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isTrue();
@@ -594,9 +585,7 @@
   public void setCopyAllScoresIfNoChange() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setCopyAllScoresIfNoChange(false);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresIfNoChange(false));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
@@ -651,9 +640,7 @@
   public void unsetCopyAllScoresIfNoCodeChange() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setCopyAllScoresIfNoCodeChange(true);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresIfNoCodeChange(true));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
@@ -691,9 +678,7 @@
   public void unsetCopyAllScoresOnTrivialRebase() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setCopyAllScoresOnTrivialRebase(true);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresOnTrivialRebase(true));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
@@ -741,9 +726,7 @@
   public void unsetCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresOnMergeFirstParentUpdate(true));
       u.save();
     }
     assertThat(
@@ -791,9 +774,8 @@
   public void unsetCopyValues() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig()
+          .updateLabelType("foo", lt -> lt.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNotEmpty();
@@ -812,9 +794,7 @@
   public void setAllowPostSubmit() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setAllowPostSubmit(false);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig().updateLabelType("foo", lt -> lt.setAllowPostSubmit(false));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isNull();
@@ -863,9 +843,7 @@
   public void unsetIgnoreSelfApproval() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType labelType = u.getConfig().getLabelSections().get("foo");
-      labelType.setIgnoreSelfApproval(true);
-      u.getConfig().getLabelSections().put(labelType.getName(), labelType);
+      u.getConfig().updateLabelType("foo", lt -> lt.setIgnoreSelfApproval(true));
       u.save();
     }
     assertThat(gApi.projects().name(project.get()).label("foo").get().ignoreSelfApproval).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index f5d2db4..b1879f6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -28,7 +28,8 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
@@ -158,6 +159,12 @@
   @Test
   public void listTagsOfNonVisibleBranch() throws Exception {
     grantTagPermissions();
+    // Allow creating a new hidden branch
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).group(REGISTERED_USERS).ref("refs/heads/hidden"))
+        .update();
 
     PushOneCommit push1 = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r1 = push1.to("refs/heads/master");
@@ -169,7 +176,7 @@
     assertThat(result.ref).isEqualTo(R_TAGS + tag1.ref);
     assertThat(result.revision).isEqualTo(tag1.revision);
 
-    pushTo("refs/heads/hidden");
+    pushTo("refs/heads/hidden").assertOkStatus();
     PushOneCommit push2 = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r2 = push2.to("refs/heads/hidden");
     r2.assertOkStatus();
@@ -470,8 +477,12 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    cfg.getAccessSections().stream()
-        .filter(s -> s.getName().startsWith("refs/tags/"))
-        .forEach(s -> Arrays.stream(permissions).forEach(s::removePermission));
+    for (AccessSection accessSection : ImmutableList.copyOf(cfg.getAccessSections())) {
+      cfg.upsertAccessSection(
+          accessSection.getName(),
+          updatedAccessSection -> {
+            Arrays.stream(permissions).forEach(updatedAccessSection::removePermission);
+          });
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index ecd4025..15f1a6a 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -18,7 +18,9 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
 import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.toList;
 
@@ -32,6 +34,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
@@ -176,6 +179,203 @@
   }
 
   @Test
+  public void patchsetLevelCommentCanBeAddedAndRetrieved() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addComments(changeId, ps1, comment);
+
+    Map<String, List<CommentInfo>> results = getPublishedComments(changeId, ps1);
+    assertThatMap(results).keys().containsExactly(PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void deletePatchsetLevelComment() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    String commentMessage = "to be deleted";
+    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, commentMessage);
+    addComments(changeId, revId, comment);
+
+    Map<String, List<CommentInfo>> results = getPublishedComments(changeId, revId);
+    CommentInfo oldComment = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL));
+
+    DeleteCommentInput input = new DeleteCommentInput("reason");
+    gApi.changes().id(changeId).revision(revId).comment(oldComment.id).delete(input);
+    CommentInfo updatedComment =
+        Iterables.getOnlyElement(getPublishedComments(changeId, revId).get(PATCHSET_LEVEL));
+
+    assertThat(updatedComment.message).doesNotContain(commentMessage);
+  }
+
+  @Test
+  public void patchsetLevelCommentEmailNotification() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput comment = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addComments(changeId, ps1, comment);
+
+    String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
+    assertThat(emailBody).contains("Patchset");
+    assertThat(emailBody).doesNotContain("/PATCHSET_LEVEL");
+  }
+
+  @Test
+  public void patchsetLevelCommentCantHaveLine() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    input.line = 1;
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+    assertThat(ex.getMessage()).contains("line");
+  }
+
+  @Test
+  public void patchsetLevelCommentCantHaveRange() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    input.range = createLineRange(1, 3);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+    assertThat(ex.getMessage()).contains("range");
+  }
+
+  @Test
+  public void patchsetLevelCommentCantHaveSide() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String ps1 = result.getCommit().name();
+
+    CommentInput input = newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    input.side = Side.REVISION;
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> addComments(changeId, ps1, input));
+    assertThat(ex.getMessage()).contains("side");
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCanBeAddedAndRetrieved() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addDraft(changeId, revId, comment);
+    Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
+    assertThatMap(results).keys().containsExactly(PATCHSET_LEVEL);
+  }
+
+  @Test
+  public void deletePatchsetLevelDraft() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput draft = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment 1");
+    CommentInfo returned = addDraft(changeId, revId, draft);
+    deleteDraft(changeId, revId, returned.id);
+    Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
+    assertThat(drafts).isEmpty();
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCantHaveLine() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    comment.line = 1;
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
+    assertThat(ex.getMessage()).contains("line");
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCantHaveRange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    comment.range = createLineRange(1, 3);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
+    assertThat(ex.getMessage()).contains("range");
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCantHaveSide() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    comment.side = Side.REVISION;
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> addDraft(changeId, revId, comment));
+    assertThat(ex.getMessage()).contains("range");
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCantBeUpdatedToHaveLine() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addDraft(changeId, revId, comment);
+    Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
+    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
+    update.line = 1;
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> updateDraft(changeId, revId, update, update.id));
+    assertThat(ex.getMessage()).contains("line");
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCantBeUpdatedToHaveRange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addDraft(changeId, revId, comment);
+    Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
+    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
+    update.range = createLineRange(1, 3);
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> updateDraft(changeId, revId, update, update.id));
+    assertThat(ex.getMessage()).contains("range");
+  }
+
+  @Test
+  public void patchsetLevelDraftCommentCantBeUpdatedToHaveSide() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    DraftInput comment = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    addDraft(changeId, revId, comment);
+    Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
+    DraftInput update = newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+    update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
+    update.side = Side.REVISION;
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class, () -> updateDraft(changeId, revId, update, update.id));
+    assertThat(ex.getMessage()).contains("side");
+  }
+
+  @Test
   public void postCommentWithReply() throws Exception {
     for (Integer line : lines) {
       String file = "file";
@@ -1083,7 +1283,7 @@
     addComments(changeId, ps4, c7, c8);
 
     // 11th commit: Add (c9) to PS2.
-    CommentInput c9 = newComment("b.txt", "comment 9");
+    CommentInput c9 = newCommentWithOnlyMandatoryFields("b.txt", "comment 9");
     addComments(changeId, ps2, c9);
 
     List<CommentInfo> commentsBeforeDelete = getChangeSortedComments(id.get());
@@ -1226,16 +1426,16 @@
         RevCommit commitBefore = beforeDelete.get(i);
         RevCommit commitAfter = afterDelete.get(i);
 
-        Map<String, com.google.gerrit.entities.Comment> commentMapBefore =
+        Map<String, HumanComment> commentMapBefore =
             DeleteCommentRewriter.getPublishedComments(
                 noteUtil, reader, NoteMap.read(reader, commitBefore));
-        Map<String, com.google.gerrit.entities.Comment> commentMapAfter =
+        Map<String, HumanComment> commentMapAfter =
             DeleteCommentRewriter.getPublishedComments(
                 noteUtil, reader, NoteMap.read(reader, commitAfter));
 
         if (commentMapBefore.containsKey(targetCommentUuid)) {
           assertThat(commentMapAfter).containsKey(targetCommentUuid);
-          com.google.gerrit.entities.Comment comment = commentMapAfter.get(targetCommentUuid);
+          HumanComment comment = commentMapAfter.get(targetCommentUuid);
           assertThat(comment.message).isEqualTo(expectedMessage);
           comment.message = commentMapBefore.get(targetCommentUuid).message;
           commentMapAfter.put(targetCommentUuid, comment);
@@ -1340,6 +1540,11 @@
     return newComment(file, Side.REVISION, 0, message, false);
   }
 
+  private static CommentInput newCommentWithOnlyMandatoryFields(String path, String message) {
+    CommentInput c = new CommentInput();
+    return populate(c, path, null, null, null, null, message, false);
+  }
+
   private static CommentInput newComment(
       String path, Side side, int line, String message, Boolean unresolved) {
     CommentInput c = new CommentInput();
@@ -1367,19 +1572,24 @@
     return populate(d, path, Side.PARENT, parent, line, message, false);
   }
 
+  private DraftInput newDraftWithOnlyMandatoryFields(String path, String message) {
+    DraftInput d = new DraftInput();
+    return populate(d, path, null, null, null, null, message, false);
+  }
+
   private static <C extends Comment> C populate(
       C c,
       String path,
       Side side,
       Integer parent,
-      int line,
+      Integer line,
       Comment.Range range,
       String message,
       Boolean unresolved) {
     c.path = path;
     c.side = side;
     c.parent = parent;
-    c.line = line != 0 ? line : null;
+    c.line = line != null && line != 0 ? line : null;
     c.message = message;
     c.unresolved = unresolved;
     if (range != null) {
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 069387c..e39f967 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -831,7 +831,7 @@
   private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
     PersonIdent committer = serverIdent.get();
     PersonIdent author =
-        noteUtil.newIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
+        noteUtil.newAccountIdIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
     serverSideTestRepo
         .branch(RefNames.changeMetaRef(id))
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index b544f6e..74dfa04 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -58,7 +58,6 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
-import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -636,10 +635,8 @@
 
   private static Correspondence<RelatedChangeAndCommitInfo, String>
       getRelatedChangeToStatusCorrespondence() {
-    return Correspondence.from(
-        (relatedChangeAndCommitInfo, status) ->
-            Objects.equals(relatedChangeAndCommitInfo.status, status),
-        "has status");
+    return Correspondence.transforming(
+        relatedChangeAndCommitInfo -> relatedChangeAndCommitInfo.status, "has status");
   }
 
   private RevCommit parseBody(RevCommit c) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index 8469fff..002b860 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -61,8 +61,8 @@
 
   private void saveLabelConfig() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(label.getName(), label);
-      u.getConfig().getLabelSections().put(pLabel.getName(), pLabel);
+      u.getConfig().upsertLabelType(label);
+      u.getConfig().upsertLabelType(pLabel);
       u.save();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
index d68cada..075997b 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -22,11 +22,15 @@
 import static org.mockito.MockitoAnnotations.initMocks;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -101,6 +105,53 @@
   }
 
   @Test
+  public void attentionSetUpdatedReviewerAdded() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.email());
+    gApi.changes().id(changeId).attention(user.email()).remove(new AttentionSetInput("removed"));
+    String revId = result.getCommit().getName();
+    DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, comment);
+    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    AttentionSetUpdate attentionSetUpdate =
+        Iterables.getOnlyElement(amendResult.getChange().attentionSet());
+    assertThat(attentionSetUpdate.account()).isEqualTo(user.id());
+    assertThat(attentionSetUpdate.reason()).isEqualTo("owner or uploader replied");
+    assertThat(attentionSetUpdate.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+  }
+
+  @Test
+  public void attentionSetUpdatedReviewerNotAddedWhenRemoved() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.email());
+    gApi.changes().id(changeId).attention(user.email()).remove(new AttentionSetInput("removed"));
+    String revId = result.getCommit().getName();
+    DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
+    testCommentHelper.addDraft(changeId, revId, comment);
+    Result amendResult =
+        amendChange(
+            changeId, "refs/for/master%publish-comments,cc=" + user.email(), admin, testRepo);
+    AttentionSetUpdate attentionSetUpdate =
+        Iterables.getOnlyElement(amendResult.getChange().attentionSet());
+    assertThat(attentionSetUpdate.account()).isEqualTo(user.id());
+    assertThat(attentionSetUpdate.reason()).isEqualTo("removed");
+    assertThat(attentionSetUpdate.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+  }
+
+  @Test
+  public void attentionSetNotUpdatedWhenNoCommentsPublished() throws Exception {
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.email());
+    gApi.changes().id(changeId).attention(user.email()).remove(new AttentionSetInput("removed"));
+    ImmutableSet<AttentionSetUpdate> attentionSet = result.getChange().attentionSet();
+    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    assertThat(attentionSet).isEqualTo(amendResult.getChange().attentionSet());
+  }
+
+  @Test
   public void validateComments_commentRejected() throws Exception {
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index d74cd71..9b12f29 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -17,17 +17,17 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.entities.NotifyConfig.NotifyType.ABANDONED_CHANGES;
+import static com.google.gerrit.entities.NotifyConfig.NotifyType.ALL_COMMENTS;
+import static com.google.gerrit.entities.NotifyConfig.NotifyType.NEW_CHANGES;
+import static com.google.gerrit.entities.NotifyConfig.NotifyType.NEW_PATCHSETS;
+import static com.google.gerrit.entities.NotifyConfig.NotifyType.SUBMITTED_CHANGES;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.ALL;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.NONE;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER;
 import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER_REVIEWERS;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.ENABLED;
-import static com.google.gerrit.server.account.ProjectWatches.NotifyType.ABANDONED_CHANGES;
-import static com.google.gerrit.server.account.ProjectWatches.NotifyType.ALL_COMMENTS;
-import static com.google.gerrit.server.account.ProjectWatches.NotifyType.NEW_CHANGES;
-import static com.google.gerrit.server.account.ProjectWatches.NotifyType.NEW_PATCHSETS;
-import static com.google.gerrit.server.account.ProjectWatches.NotifyType.SUBMITTED_CHANGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableList;
@@ -37,7 +37,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -1500,7 +1500,7 @@
       throws Exception {
     for (SubmitType submitType : SubmitType.values()) {
       try (ProjectConfigUpdate u = updateProject(project)) {
-        u.getConfig().getProject().setSubmitType(submitType);
+        u.getConfig().updateProject(p -> p.setSubmitType(submitType));
         u.save();
       }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
index 0826c166..6dd2f32 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -22,9 +22,9 @@
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
-import com.google.gerrit.mail.EmailHeader;
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender;
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index fc44822..4f79e09 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -28,6 +28,7 @@
 import com.google.common.collect.Streams;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
@@ -47,6 +48,7 @@
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import java.net.URL;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.util.Collection;
@@ -296,6 +298,10 @@
     assertNotifyTo(user);
     Message message = sender.nextMessage();
     assertThat(message.body()).contains("rejected one or more comments");
+
+    // ensure the message header contains a valid message id.
+    assertThat(((EmailHeader.String) (message.headers().get("Message-ID"))).getString())
+        .containsMatch("<someid-REJECTION-HTML@" + new URL(canonicalWebUrl.get()).getHost() + ">");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index 0ae9ad2..1c916a3 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.acceptance.config.GerritConfig;
-import com.google.gerrit.mail.EmailHeader;
+import com.google.gerrit.entities.EmailHeader;
 import java.net.URI;
 import java.util.Map;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendIT.java
new file mode 100644
index 0000000..2aab159
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/PermissionBackendIT.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.permissions;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+/** Asserts behavior on {@link PermissionBackend} using a fully-started Gerrit. */
+public class PermissionBackendIT extends AbstractDaemonTest {
+  @Inject PermissionBackend pb;
+  @Inject ChangeNotes.Factory changeNotesFactory;
+
+  @Test
+  public void changeDataFromIndex_canCheckReviewerState() throws Exception {
+    Change.Id changeId = createChange().getChange().getId();
+    gApi.changes().id(changeId.get()).setPrivate(true);
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    ChangeData changeData =
+        Iterables.getOnlyElement(queryProvider.get().byLegacyChangeId(changeId));
+    boolean reviewerCanSee =
+        pb.absentUser(user.id()).change(changeData).test(ChangePermission.READ);
+    assertThat(reviewerCanSee).isTrue();
+  }
+
+  @Test
+  public void changeDataFromNoteDb_canCheckReviewerState() throws Exception {
+    Change.Id changeId = createChange().getChange().getId();
+    gApi.changes().id(changeId.get()).setPrivate(true);
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    ChangeData changeData = changeDataFactory.create(notes);
+    boolean reviewerCanSee =
+        pb.absentUser(user.id()).change(changeData).test(ChangePermission.READ);
+    assertThat(reviewerCanSee).isTrue();
+  }
+
+  @Test
+  public void changeNotes_canCheckReviewerState() throws Exception {
+    Change.Id changeId = createChange().getChange().getId();
+    gApi.changes().id(changeId.get()).setPrivate(true);
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    ChangeNotes notes = changeNotesFactory.create(project, changeId);
+    boolean reviewerCanSee = pb.absentUser(user.id()).change(notes).test(ChangePermission.READ);
+    assertThat(reviewerCanSee).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 1d5204b..df5bfca 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -17,11 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
-import static com.google.gerrit.common.data.LabelFunction.ANY_WITH_BLOCK;
-import static com.google.gerrit.common.data.LabelFunction.MAX_NO_BLOCK;
-import static com.google.gerrit.common.data.LabelFunction.MAX_WITH_BLOCK;
-import static com.google.gerrit.common.data.LabelFunction.NO_BLOCK;
-import static com.google.gerrit.common.data.LabelFunction.NO_OP;
+import static com.google.gerrit.entities.LabelFunction.ANY_WITH_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.MAX_NO_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.MAX_WITH_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.NO_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.NO_OP;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
@@ -32,55 +32,55 @@
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
-import java.util.Arrays;
 import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
 public class CustomLabelIT extends AbstractDaemonTest {
+  private static final String LABEL_NAME = "CustomLabel";
+  private static final LabelType LABEL =
+      label("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+  private static final String P_LABEL_NAME = "CustomLabel2";
+  private static final LabelType P =
+      label("CustomLabel2", value(1, "Positive"), value(0, "No score"));
 
   @Inject private ProjectOperations projectOperations;
   @Inject private ExtensionRegistry extensionRegistry;
 
-  private final LabelType label =
-      label("CustomLabel", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
-
-  private final LabelType P = label("CustomLabel2", value(1, "Positive"), value(0, "No score"));
-
   @Before
   public void setUp() throws Exception {
     projectOperations
         .project(project)
         .forUpdate()
-        .add(allowLabel(label.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
-        .add(allowLabel(P.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(0, 1))
+        .add(allowLabel(LABEL_NAME).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(P_LABEL_NAME).ref("refs/heads/*").group(ANONYMOUS_USERS).range(0, 1))
         .update();
   }
 
   @Test
   public void customLabelNoOp_NegativeVoteNotBlock() throws Exception {
-    label.setFunction(NO_OP);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(NO_OP));
     PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNull();
     assertThat(q.recommended).isNull();
@@ -91,12 +91,11 @@
 
   @Test
   public void customLabelNoBlock_NegativeVoteNotBlock() throws Exception {
-    label.setFunction(NO_BLOCK);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(NO_BLOCK));
     PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNull();
     assertThat(q.recommended).isNull();
@@ -107,12 +106,11 @@
 
   @Test
   public void customLabelMaxNoBlock_NegativeVoteNotBlock() throws Exception {
-    label.setFunction(MAX_NO_BLOCK);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(MAX_NO_BLOCK));
     PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNull();
     assertThat(q.recommended).isNull();
@@ -123,16 +121,14 @@
 
   @Test
   public void customLabelMaxNoBlock_MaxVoteSubmittable() throws Exception {
-    label.setFunction(MAX_NO_BLOCK);
-    P.setFunction(NO_OP);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(MAX_NO_BLOCK), P.toBuilder().setFunction(NO_OP));
     PushOneCommit.Result r = createChange();
     assertThat(info(r.getChangeId()).submittable).isNull();
-    revision(r).review(ReviewInput.approve().label(label.getName(), 1));
+    revision(r).review(ReviewInput.approve().label(LABEL_NAME, 1));
 
     ChangeInfo c = getWithLabels(r);
     assertThat(c.submittable).isTrue();
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNotNull();
     assertThat(q.recommended).isNull();
@@ -143,12 +139,11 @@
 
   @Test
   public void customLabelAnyWithBlock_NegativeVoteBlock() throws Exception {
-    label.setFunction(ANY_WITH_BLOCK);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(ANY_WITH_BLOCK));
     PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNull();
     assertThat(q.recommended).isNull();
@@ -170,19 +165,18 @@
   public void customLabelAnyWithBlock_Addreviewer_ZeroVote() throws Exception {
     TestListener testListener = new TestListener();
     try (Registration registration = extensionRegistry.newRegistration().add(testListener)) {
-      P.setFunction(ANY_WITH_BLOCK);
-      saveLabelConfig();
+      saveLabelConfig(P.toBuilder().setFunction(ANY_WITH_BLOCK));
       PushOneCommit.Result r = createChange();
       AddReviewerInput in = new AddReviewerInput();
       in.reviewer = user.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-      ReviewInput input = new ReviewInput().label(P.getName(), 0);
+      ReviewInput input = new ReviewInput().label(P_LABEL_NAME, 0);
       input.message = "foo";
 
       revision(r).review(input);
       ChangeInfo c = getWithLabels(r);
-      LabelInfo q = c.labels.get(P.getName());
+      LabelInfo q = c.labels.get(P_LABEL_NAME);
       assertThat(q.all).hasSize(1);
       assertThat(q.approved).isNull();
       assertThat(q.recommended).isNull();
@@ -196,12 +190,11 @@
 
   @Test
   public void customLabelMaxWithBlock_NegativeVoteBlock() throws Exception {
-    label.setFunction(MAX_WITH_BLOCK);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(MAX_WITH_BLOCK));
     PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNull();
     assertThat(q.recommended).isNull();
@@ -212,16 +205,15 @@
 
   @Test
   public void customLabelMaxWithBlock_MaxVoteSubmittable() throws Exception {
-    label.setFunction(MAX_WITH_BLOCK);
-    P.setFunction(NO_OP);
-    saveLabelConfig();
+    saveLabelConfig(
+        LABEL.toBuilder().setFunction(MAX_WITH_BLOCK), P.toBuilder().setFunction(NO_OP));
     PushOneCommit.Result r = createChange();
     assertThat(info(r.getChangeId()).submittable).isNull();
-    revision(r).review(ReviewInput.approve().label(label.getName(), 1));
+    revision(r).review(ReviewInput.approve().label(LABEL_NAME, 1));
 
     ChangeInfo c = getWithLabels(r);
     assertThat(c.submittable).isTrue();
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNotNull();
     assertThat(q.recommended).isNull();
@@ -232,13 +224,12 @@
 
   @Test
   public void customLabelMaxWithBlock_MaxVoteNegativeVoteBlock() throws Exception {
-    label.setFunction(MAX_WITH_BLOCK);
-    saveLabelConfig();
+    saveLabelConfig(LABEL.toBuilder().setFunction(MAX_WITH_BLOCK));
     PushOneCommit.Result r = createChange();
-    revision(r).review(new ReviewInput().label(label.getName(), 1));
-    revision(r).review(new ReviewInput().label(label.getName(), -1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, 1));
+    revision(r).review(new ReviewInput().label(LABEL_NAME, -1));
     ChangeInfo c = getWithLabels(r);
-    LabelInfo q = c.labels.get(label.getName());
+    LabelInfo q = c.labels.get(LABEL_NAME);
     assertThat(q.all).hasSize(1);
     assertThat(q.approved).isNull();
     assertThat(q.recommended).isNull();
@@ -249,10 +240,9 @@
 
   @Test
   public void customLabel_DisallowPostSubmit() throws Exception {
-    label.setFunction(NO_OP);
-    label.setAllowPostSubmit(false);
-    P.setFunction(NO_OP);
-    saveLabelConfig();
+    saveLabelConfig(
+        LABEL.toBuilder().setFunction(NO_OP).setAllowPostSubmit(false),
+        P.toBuilder().setFunction(NO_OP));
 
     PushOneCommit.Result r = createChange();
     revision(r).review(ReviewInput.approve());
@@ -260,20 +250,20 @@
 
     ChangeInfo info = getWithLabels(r);
     assertPermitted(info, "Code-Review", 2);
-    assertPermitted(info, P.getName(), 0, 1);
-    assertPermitted(info, label.getName());
+    assertPermitted(info, P_LABEL_NAME, 0, 1);
+    assertPermitted(info, LABEL_NAME);
 
     ReviewInput postSubmitReview1 = new ReviewInput();
     postSubmitReview1.label(P.getName(), P.getMax().getValue());
     revision(r).review(postSubmitReview1);
 
     ReviewInput postSubmitReview2 = new ReviewInput();
-    postSubmitReview2.label(label.getName(), label.getMax().getValue());
+    postSubmitReview2.label(LABEL.getName(), LABEL.getMax().getValue());
     ResourceConflictException thrown =
         assertThrows(ResourceConflictException.class, () -> revision(r).review(postSubmitReview2));
     assertThat(thrown)
         .hasMessageThat()
-        .contains("Voting on labels disallowed after submit: " + label.getName());
+        .contains("Voting on labels disallowed after submit: " + LABEL_NAME);
   }
 
   @Test
@@ -331,10 +321,10 @@
 
   @Test
   public void customLabel_withBranch() throws Exception {
-    label.setRefPatterns(Arrays.asList("master"));
-    saveLabelConfig();
-    ProjectConfig cfg = projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
-    assertThat(cfg.getLabelSections().get(label.getName()).getRefPatterns()).contains("master");
+    saveLabelConfig(LABEL.toBuilder().setRefPatterns(ImmutableList.of("master")));
+    CachedProjectConfig cfg =
+        projectCache.get(project).orElseThrow(illegalState(project)).getConfig();
+    assertThat(cfg.getLabelSections().get(LABEL_NAME).getRefPatterns()).contains("master");
   }
 
   private void assertLabelStatus(String changeId, String testLabel) throws Exception {
@@ -348,10 +338,11 @@
     assertThat(labelInfo.blocking).isNull();
   }
 
-  private void saveLabelConfig() throws Exception {
+  private void saveLabelConfig(LabelType.Builder... builders) throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().getLabelSections().put(label.getName(), label);
-      u.getConfig().getLabelSections().put(P.getName(), P);
+      for (LabelType.Builder b : builders) {
+        u.getConfig().upsertLabelType(b.build());
+      }
       u.save();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
new file mode 100644
index 0000000..84fa460
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import javax.inject.Inject;
+import org.junit.Test;
+
+public class ProjectCacheIT extends AbstractDaemonTest {
+  @Inject private PluginConfigFactory pluginConfigFactory;
+
+  @Test
+  public void pluginConfig_cachedValueEqualsConfigValue() throws Exception {
+    GroupReference group = GroupReference.create(AccountGroup.uuid("uuid"), "local-group-name");
+    try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updatePluginConfig(
+              "important-plugin",
+              cfg -> {
+                cfg.setGroupReference("group-config-name", group);
+                cfg.setString("key", "my-plugin-value");
+              });
+      u.save();
+    }
+
+    PluginConfig pluginConfig = projectCache.get(project).get().getPluginConfig("important-plugin");
+    assertThat(pluginConfig.getString("key")).isEqualTo("my-plugin-value");
+
+    assertThat(pluginConfig.getGroupReference("group-config-name")).isPresent();
+    assertThat(pluginConfig.getGroupReference("group-config-name")).hasValue(group);
+  }
+
+  @Test
+  public void pluginConfig_inheritedCachedValueEqualsConfigValue() throws Exception {
+    GroupReference group = GroupReference.create(AccountGroup.uuid("uuid"), "local-group-name");
+    try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(allProjects)) {
+      u.getConfig()
+          .updatePluginConfig(
+              "important-plugin",
+              cfg -> {
+                cfg.setGroupReference("group-config-name", group);
+                cfg.setString("key", "my-plugin-value");
+              });
+      u.save();
+    }
+
+    PluginConfig pluginConfig =
+        pluginConfigFactory.getFromProjectConfigWithInheritance(project, "important-plugin");
+    assertThat(pluginConfig.getString("key")).isEqualTo("my-plugin-value");
+
+    assertThat(pluginConfig.getGroupReference("group-config-name")).isPresent();
+    assertThat(pluginConfig.getGroupReference("group-config-name")).hasValue(group);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 7a80cbd..33276e7 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -25,15 +25,15 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.NotifyConfig;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.mail.Address;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
-import com.google.gerrit.server.git.NotifyConfig;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import java.util.EnumSet;
@@ -50,15 +50,15 @@
   @Test
   public void newPatchSetsNotifyConfig() throws Exception {
     Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
+    NotifyConfig.Builder nc = NotifyConfig.builder();
+    nc.addAddress(addr);
     nc.setName("new-patch-set");
     nc.setHeader(NotifyConfig.Header.CC);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+    nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
     nc.setFilter("message:sekret");
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("watch", nc);
+      u.getConfig().putNotifyConfig("watch", nc.build());
       u.save();
     }
 
@@ -91,14 +91,14 @@
   @Test
   public void noNotificationForPrivateChangesForWatchersInNotifyConfig() throws Exception {
     Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
+    NotifyConfig.Builder nc = NotifyConfig.builder();
+    nc.addAddress(addr);
     nc.setName("team");
     nc.setHeader(NotifyConfig.Header.TO);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
+    nc.setNotify(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("team", nc);
+      u.getConfig().putNotifyConfig("team", nc.build());
       u.save();
     }
 
@@ -123,14 +123,14 @@
   public void noNotificationForChangeThatIsTurnedPrivateForWatchersInNotifyConfig()
       throws Exception {
     Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
+    NotifyConfig.Builder nc = NotifyConfig.builder();
+    nc.addAddress(addr);
     nc.setName("team");
     nc.setHeader(NotifyConfig.Header.TO);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+    nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("team", nc);
+      u.getConfig().putNotifyConfig("team", nc.build());
       u.save();
     }
 
@@ -152,14 +152,14 @@
   @Test
   public void noNotificationForWipChangesForWatchersInNotifyConfig() throws Exception {
     Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
+    NotifyConfig.Builder nc = NotifyConfig.builder();
+    nc.addAddress(addr);
     nc.setName("team");
     nc.setHeader(NotifyConfig.Header.TO);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
+    nc.setNotify(EnumSet.of(NotifyType.NEW_CHANGES, NotifyType.ALL_COMMENTS));
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("team", nc);
+      u.getConfig().putNotifyConfig("team", nc.build());
       u.save();
     }
 
@@ -183,14 +183,14 @@
   @Test
   public void noNotificationForChangeThatIsTurnedWipForWatchersInNotifyConfig() throws Exception {
     Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig nc = new NotifyConfig();
-    nc.addEmail(addr);
+    NotifyConfig.Builder nc = NotifyConfig.builder();
+    nc.addAddress(addr);
     nc.setName("team");
     nc.setHeader(NotifyConfig.Header.TO);
-    nc.setTypes(EnumSet.of(NotifyType.NEW_PATCHSETS));
+    nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
 
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("team", nc);
+      u.getConfig().putNotifyConfig("team", nc.build());
       u.save();
     }
 
@@ -279,7 +279,7 @@
     sender.clear();
 
     // watch project as user2
-    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2", null);
+    TestAccount user2 = accountCreator.create("user2", "user2@example.com", "User2", null);
     requestScopeOperations.setApiUser(user2.id());
     watch(watchedProject);
 
@@ -391,7 +391,7 @@
     sender.clear();
 
     // watch project as user2
-    TestAccount user2 = accountCreator.create("user2", "user2@test.com", "User2", null);
+    TestAccount user2 = accountCreator.create("user2", "user2@example.com", "User2", null);
     requestScopeOperations.setApiUser(user2.id());
     watch(anyProject);
 
@@ -528,7 +528,7 @@
     // watch project as user that can view all private change
     TestAccount userThatCanViewPrivateChanges =
         accountCreator.create(
-            "user2", "user2@test.com", "User2", null, groupThatCanViewPrivateChanges.name);
+            "user2", "user2@example.com", "User2", null, groupThatCanViewPrivateChanges.name);
     requestScopeOperations.setApiUser(userThatCanViewPrivateChanges.id());
     watch(watchedProject);
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
index 11d39b4..127f34b 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -25,9 +25,9 @@
 import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.projects.BranchApi;
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
index 1c820af..d3b40cc 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
@@ -20,9 +20,9 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
 import com.google.inject.Inject;
 import java.util.Map;
@@ -89,7 +89,7 @@
       if (localLabelSections.isEmpty()) {
         localLabelSections.putAll(projectCache.getAllProjects().getConfig().getLabelSections());
       }
-      localLabelSections.get(labelName).setIgnoreSelfApproval(newState);
+      u.getConfig().updateLabelType(labelName, lt -> lt.setIgnoreSelfApproval(newState));
       u.save();
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
index 92cc396..6079388 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
@@ -19,8 +19,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.PrologOptions;
 import com.google.gerrit.server.rules.PrologRuleEvaluator;
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index e5432d1..5cbc767 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.acceptance.server.rules;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -29,7 +30,6 @@
 import java.util.Collection;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
 import org.junit.Test;
 
 /**
@@ -44,14 +44,6 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
 
-  @Before
-  public void setUp() {
-    // We don't want caches to interfere with our tests. If we didn't, the cache would take
-    // precedence over the index, which would never be called.
-    baseConfig.setString("cache", "changes", "memoryLimit", "0");
-    baseConfig.setString("cache", "projects", "memoryLimit", "0");
-  }
-
   @Test
   public void testUnresolvedCommentsCountPredicate() throws Exception {
     modifySubmitRules("gerrit:unresolved_comments_count(0)");
@@ -85,11 +77,40 @@
     assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
   }
 
+  @Test
+  public void testFileNamesPredicateWithANewFile() throws Exception {
+    modifySubmitRules("gerrit:files([file('a.txt', 'A', 'REGULAR')])");
+    assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
+  @Test
+  public void testFileNamesPredicateWithADeletedFile() throws Exception {
+    modifySubmitRules("gerrit:files([file('a.txt', 'D', 'REGULAR')])");
+    assertThat(statusForRuleRemoveFile()).isEqualTo(SubmitRecord.Status.OK);
+  }
+
   private SubmitRecord.Status statusForRule() throws Exception {
     String oldHead = projectOperations.project(project).getHead("master").name();
     PushOneCommit.Result result1 =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
     testRepo.reset(oldHead);
+    return getStatus(result1);
+  }
+
+  private SubmitRecord.Status statusForRuleRemoveFile() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    // create a.txt
+    commitBuilder().add("a.txt", "4").message("subject").create();
+    pushHead(testRepo, "refs/heads/master", false);
+
+    // This implictly removes a.txt
+    PushOneCommit.Result result =
+        pushFactory.create(user.newIdent(), testRepo).rm("refs/for/master");
+    testRepo.reset(oldHead);
+    return getStatus(result);
+  }
+
+  private SubmitRecord.Status getStatus(PushOneCommit.Result result1) throws Exception {
     ChangeData cd = result1.getChange();
 
     Collection<SubmitRecord> records;
@@ -119,5 +140,6 @@
           .message("Modify rules.pl")
           .create();
     }
+    projectCache.evict(project);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
index 5a31bfd..58c2517 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
@@ -65,7 +65,7 @@
     session.exec(
         String.format("gerrit set-reviewers -%s %s %s", add ? "a" : "r", user.email(), id));
     session.assertSuccess();
-    ImmutableSet<Account.Id> reviewers = change.getChange().getReviewers().all();
+    ImmutableSet<Account.Id> reviewers = change.getChange().reviewers().all();
     if (add) {
       assertThat(reviewers).contains(user.id());
     } else {
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
index 96864d9..a003f9d 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -29,9 +29,9 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import java.sql.Timestamp;
-import java.util.Objects;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -616,28 +616,11 @@
   }
 
   private static Correspondence<AccountInfo, Account.Id> getAccountToIdCorrespondence() {
-    return Correspondence.from(
-        (actualAccount, expectedId) -> {
-          Account.Id accountId =
-              Optional.ofNullable(actualAccount)
-                  .map(account -> account._accountId)
-                  .map(Account::id)
-                  .orElse(null);
-          return Objects.equals(accountId, expectedId);
-        },
-        "has ID");
+    return NullAwareCorrespondence.transforming(
+        account -> Account.id(account._accountId), "has ID");
   }
 
   private static Correspondence<GroupInfo, AccountGroup.UUID> getGroupToUuidCorrespondence() {
-    return Correspondence.from(
-        (actualGroup, expectedUuid) -> {
-          AccountGroup.UUID groupUuid =
-              Optional.ofNullable(actualGroup)
-                  .map(group -> group.id)
-                  .map(AccountGroup::uuid)
-                  .orElse(null);
-          return Objects.equals(groupUuid, expectedUuid);
-        },
-        "has UUID");
+    return NullAwareCorrespondence.transforming(group -> AccountGroup.uuid(group.id), "has UUID");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 65c7b5c..76b8826 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -30,7 +30,6 @@
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static java.util.stream.Collectors.toList;
@@ -39,12 +38,11 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.junit.TestRepository;
@@ -98,23 +96,6 @@
   }
 
   @Test
-  public void mutatingResultOfGetProjectConfigDoesNotMutateGlobalCachedValue() throws Exception {
-    Project.NameKey key = projectOperations.newProject().create();
-    ProjectConfig projectConfig = projectOperations.project(key).getProjectConfig();
-    ProjectState cachedProjectState1 = projectCache.get(key).orElseThrow(illegalState(project));
-    ProjectConfig cachedProjectConfig1 = cachedProjectState1.getConfig();
-    assertThat(cachedProjectConfig1).isNotSameInstanceAs(projectConfig);
-    assertThat(cachedProjectConfig1.getProject().getDescription()).isEmpty();
-    assertThat(projectConfig.getProject().getDescription()).isEmpty();
-    projectConfig.getProject().setDescription("my fancy project");
-
-    ProjectConfig cachedProjectConfig2 =
-        projectCache.get(key).orElseThrow(illegalState(project)).getConfig();
-    assertThat(cachedProjectConfig2).isNotSameInstanceAs(projectConfig);
-    assertThat(cachedProjectConfig2.getProject().getDescription()).isEmpty();
-  }
-
-  @Test
   public void getProjectConfigNoRefsMetaConfig() throws Exception {
     Project.NameKey key = projectOperations.newProject().create();
     deleteRefsMetaConfig(key);
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
index 8fc1677..e0d0d25 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
@@ -26,7 +26,7 @@
 import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_BATCH_CHANGES_LIMIT;
 import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
 import static com.google.gerrit.common.data.GlobalCapability.QUERY_LIMIT;
-import static com.google.gerrit.common.data.Permission.ABANDON;
+import static com.google.gerrit.entities.Permission.ABANDON;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
diff --git a/javatests/com/google/gerrit/common/data/AccessSectionTest.java b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
deleted file mode 100644
index e775cbc..0000000
--- a/javatests/com/google/gerrit/common/data/AccessSectionTest.java
+++ /dev/null
@@ -1,249 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import com.google.common.collect.ImmutableList;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Locale;
-import org.junit.Before;
-import org.junit.Test;
-
-public class AccessSectionTest {
-  private static final String REF_PATTERN = "refs/heads/master";
-
-  private AccessSection accessSection;
-
-  @Before
-  public void setup() {
-    this.accessSection = new AccessSection(REF_PATTERN);
-  }
-
-  @Test
-  public void getName() {
-    assertThat(accessSection.getName()).isEqualTo(REF_PATTERN);
-  }
-
-  @Test
-  public void getEmptyPermissions() {
-    assertThat(accessSection.getPermissions()).isNotNull();
-    assertThat(accessSection.getPermissions()).isEmpty();
-  }
-
-  @Test
-  public void setAndGetPermissions() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission)
-        .inOrder();
-
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-    accessSection.setPermissions(ImmutableList.of(submitPermission));
-    assertThat(accessSection.getPermissions()).containsExactly(submitPermission);
-    assertThrows(NullPointerException.class, () -> accessSection.setPermissions(null));
-  }
-
-  @Test
-  public void cannotSetDuplicatePermissions() {
-    assertThrows(
-        IllegalArgumentException.class,
-        () ->
-            accessSection.setPermissions(
-                ImmutableList.of(
-                    new Permission(Permission.ABANDON), new Permission(Permission.ABANDON))));
-  }
-
-  @Test
-  public void cannotSetPermissionsWithConflictingNames() {
-    Permission abandonPermissionLowerCase =
-        new Permission(Permission.ABANDON.toLowerCase(Locale.US));
-    Permission abandonPermissionUpperCase =
-        new Permission(Permission.ABANDON.toUpperCase(Locale.US));
-
-    assertThrows(
-        IllegalArgumentException.class,
-        () ->
-            accessSection.setPermissions(
-                ImmutableList.of(abandonPermissionLowerCase, abandonPermissionUpperCase)));
-  }
-
-  @Test
-  public void getNonExistingPermission() {
-    assertThat(accessSection.getPermission("non-existing")).isNull();
-    assertThat(accessSection.getPermission("non-existing", false)).isNull();
-  }
-
-  @Test
-  public void getPermission() {
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-    accessSection.setPermissions(ImmutableList.of(submitPermission));
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
-    assertThrows(NullPointerException.class, () -> accessSection.getPermission(null));
-  }
-
-  @Test
-  public void getPermissionWithOtherCase() {
-    Permission submitPermissionLowerCase = new Permission(Permission.SUBMIT.toLowerCase(Locale.US));
-    accessSection.setPermissions(ImmutableList.of(submitPermissionLowerCase));
-    assertThat(accessSection.getPermission(Permission.SUBMIT.toUpperCase(Locale.US)))
-        .isEqualTo(submitPermissionLowerCase);
-  }
-
-  @Test
-  public void createMissingPermissionOnGet() {
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-
-    assertThat(accessSection.getPermission(Permission.SUBMIT, true))
-        .isEqualTo(new Permission(Permission.SUBMIT));
-
-    assertThrows(NullPointerException.class, () -> accessSection.getPermission(null, true));
-  }
-
-  @Test
-  public void addPermission() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-
-    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-    accessSection.addPermission(submitPermission);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission, submitPermission)
-        .inOrder();
-    assertThrows(NullPointerException.class, () -> accessSection.addPermission(null));
-  }
-
-  @Test
-  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-
-    List<Permission> permissions = new ArrayList<>();
-    permissions.add(abandonPermission);
-    permissions.add(rebasePermission);
-    accessSection.setPermissions(permissions);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-    permissions.add(submitPermission);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-  }
-
-  @Test
-  public void removePermission() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-
-    accessSection.setPermissions(
-        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
-
-    accessSection.remove(submitPermission);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission)
-        .inOrder();
-    assertThrows(NullPointerException.class, () -> accessSection.remove(null));
-  }
-
-  @Test
-  public void removePermissionByName() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-
-    accessSection.setPermissions(
-        ImmutableList.of(abandonPermission, rebasePermission, submitPermission));
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNotNull();
-
-    accessSection.removePermission(Permission.SUBMIT);
-    assertThat(accessSection.getPermission(Permission.SUBMIT)).isNull();
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission)
-        .inOrder();
-
-    assertThrows(NullPointerException.class, () -> accessSection.removePermission(null));
-  }
-
-  @Test
-  public void removePermissionByNameOtherCase() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-
-    String submitLowerCase = Permission.SUBMIT.toLowerCase(Locale.US);
-    String submitUpperCase = Permission.SUBMIT.toUpperCase(Locale.US);
-    Permission submitPermissionLowerCase = new Permission(submitLowerCase);
-
-    accessSection.setPermissions(
-        ImmutableList.of(abandonPermission, rebasePermission, submitPermissionLowerCase));
-    assertThat(accessSection.getPermission(submitLowerCase)).isNotNull();
-    assertThat(accessSection.getPermission(submitUpperCase)).isNotNull();
-
-    accessSection.removePermission(submitUpperCase);
-    assertThat(accessSection.getPermission(submitLowerCase)).isNull();
-    assertThat(accessSection.getPermission(submitUpperCase)).isNull();
-    assertThat(accessSection.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission)
-        .inOrder();
-  }
-
-  @Test
-  public void mergeAccessSections() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-    Permission submitPermission = new Permission(Permission.SUBMIT);
-
-    AccessSection accessSection1 = new AccessSection("refs/heads/foo");
-    accessSection1.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
-
-    AccessSection accessSection2 = new AccessSection("refs/heads/bar");
-    accessSection2.setPermissions(ImmutableList.of(rebasePermission, submitPermission));
-
-    accessSection1.mergeFrom(accessSection2);
-    assertThat(accessSection1.getPermissions())
-        .containsExactly(abandonPermission, rebasePermission, submitPermission)
-        .inOrder();
-    assertThrows(NullPointerException.class, () -> accessSection.mergeFrom(null));
-  }
-
-  @Test
-  public void testEquals() {
-    Permission abandonPermission = new Permission(Permission.ABANDON);
-    Permission rebasePermission = new Permission(Permission.REBASE);
-
-    accessSection.setPermissions(ImmutableList.of(abandonPermission, rebasePermission));
-
-    AccessSection accessSectionSamePermissionsOtherRef = new AccessSection("refs/heads/other");
-    accessSectionSamePermissionsOtherRef.setPermissions(
-        ImmutableList.of(abandonPermission, rebasePermission));
-    assertThat(accessSection.equals(accessSectionSamePermissionsOtherRef)).isFalse();
-
-    AccessSection accessSectionOther = new AccessSection(REF_PATTERN);
-    accessSectionOther.setPermissions(ImmutableList.of(abandonPermission));
-    assertThat(accessSection.equals(accessSectionOther)).isFalse();
-
-    accessSectionOther.addPermission(rebasePermission);
-    assertThat(accessSection.equals(accessSectionOther)).isTrue();
-  }
-}
diff --git a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
index 25b55c7..593b635 100644
--- a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
+++ b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
@@ -19,6 +19,8 @@
 
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroup.UUID;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.GroupReference;
 import org.junit.Test;
 
 public class GroupReferenceTest {
@@ -58,7 +60,7 @@
   public void create() {
     AccountGroup.UUID uuid = AccountGroup.uuid("uuid");
     String name = "foo";
-    GroupReference groupReference = new GroupReference(uuid, name);
+    GroupReference groupReference = GroupReference.create(uuid, name);
     assertThat(groupReference.getUUID()).isEqualTo(uuid);
     assertThat(groupReference.getName()).isEqualTo(name);
   }
@@ -68,7 +70,7 @@
     // GroupReferences where the UUID is null are used to represent groups from project.config that
     // cannot be resolved.
     String name = "foo";
-    GroupReference groupReference = new GroupReference(name);
+    GroupReference groupReference = GroupReference.create(name);
     assertThat(groupReference.getUUID()).isNull();
     assertThat(groupReference.getName()).isEqualTo(name);
   }
@@ -76,7 +78,7 @@
   @Test
   public void cannotCreateWithoutName() {
     assertThrows(
-        NullPointerException.class, () -> new GroupReference(AccountGroup.uuid("uuid"), null));
+        NullPointerException.class, () -> GroupReference.create(AccountGroup.uuid("uuid"), null));
   }
 
   @Test
@@ -98,40 +100,9 @@
   }
 
   @Test
-  public void getAndSetUuid() {
-    AccountGroup.UUID uuid = AccountGroup.uuid("uuid-foo");
-    String name = "foo";
-    GroupReference groupReference = new GroupReference(uuid, name);
-    assertThat(groupReference.getUUID()).isEqualTo(uuid);
-
-    AccountGroup.UUID uuid2 = AccountGroup.uuid("uuid-bar");
-    groupReference.setUUID(uuid2);
-    assertThat(groupReference.getUUID()).isEqualTo(uuid2);
-
-    // GroupReferences where the UUID is null are used to represent groups from project.config that
-    // cannot be resolved.
-    groupReference.setUUID(null);
-    assertThat(groupReference.getUUID()).isNull();
-  }
-
-  @Test
-  public void getAndSetName() {
-    AccountGroup.UUID uuid = AccountGroup.uuid("uuid-foo");
-    String name = "foo";
-    GroupReference groupReference = new GroupReference(uuid, name);
-    assertThat(groupReference.getName()).isEqualTo(name);
-
-    String name2 = "bar";
-    groupReference.setName(name2);
-    assertThat(groupReference.getName()).isEqualTo(name2);
-
-    assertThrows(NullPointerException.class, () -> groupReference.setName(null));
-  }
-
-  @Test
   public void toConfigValue() {
     String name = "foo";
-    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-foo"), name);
+    GroupReference groupReference = GroupReference.create(AccountGroup.uuid("uuid-foo"), name);
     assertThat(groupReference.toConfigValue()).isEqualTo("group " + name);
   }
 
@@ -142,9 +113,9 @@
     String name1 = "foo";
     String name2 = "bar";
 
-    GroupReference groupReference1 = new GroupReference(uuid1, name1);
-    GroupReference groupReference2 = new GroupReference(uuid1, name2);
-    GroupReference groupReference3 = new GroupReference(uuid2, name1);
+    GroupReference groupReference1 = GroupReference.create(uuid1, name1);
+    GroupReference groupReference2 = GroupReference.create(uuid1, name2);
+    GroupReference groupReference3 = GroupReference.create(uuid2, name1);
 
     assertThat(groupReference1.equals(groupReference2)).isTrue();
     assertThat(groupReference1.equals(groupReference3)).isFalse();
@@ -154,10 +125,10 @@
   @Test
   public void testHashcode() {
     AccountGroup.UUID uuid1 = AccountGroup.uuid("uuid1");
-    assertThat(new GroupReference(uuid1, "foo").hashCode())
-        .isEqualTo(new GroupReference(uuid1, "bar").hashCode());
+    assertThat(GroupReference.create(uuid1, "foo").hashCode())
+        .isEqualTo(GroupReference.create(uuid1, "bar").hashCode());
 
     // Check that the following calls don't fail with an exception.
-    new GroupReference("bar").hashCode();
+    GroupReference.create("bar").hashCode();
   }
 }
diff --git a/javatests/com/google/gerrit/common/data/LabelFunctionTest.java b/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
deleted file mode 100644
index 6f5232b..0000000
--- a/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
+++ /dev/null
@@ -1,145 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelId;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.List;
-import org.junit.Test;
-
-public class LabelFunctionTest {
-  private static final String LABEL_NAME = "Verified";
-  private static final LabelId LABEL_ID = LabelId.create(LABEL_NAME);
-  private static final Change.Id CHANGE_ID = Change.id(100);
-  private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
-  private static final LabelType VERIFIED_LABEL = makeLabel();
-  private static final PatchSetApproval APPROVAL_2 = makeApproval(2);
-  private static final PatchSetApproval APPROVAL_1 = makeApproval(1);
-  private static final PatchSetApproval APPROVAL_0 = makeApproval(0);
-  private static final PatchSetApproval APPROVAL_M1 = makeApproval(-1);
-  private static final PatchSetApproval APPROVAL_M2 = makeApproval(-2);
-
-  @Test
-  public void checkLabelNameIsCorrect() {
-    for (LabelFunction function : LabelFunction.values()) {
-      SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
-      assertThat(myLabel.label).isEqualTo("Verified");
-    }
-  }
-
-  @Test
-  public void checkFunctionDoesNothing() {
-    checkNothingHappens(LabelFunction.NO_BLOCK);
-    checkNothingHappens(LabelFunction.NO_OP);
-    checkNothingHappens(LabelFunction.PATCH_SET_LOCK);
-    checkNothingHappens(LabelFunction.ANY_WITH_BLOCK);
-
-    checkLabelIsRequired(LabelFunction.MAX_WITH_BLOCK);
-    checkLabelIsRequired(LabelFunction.MAX_NO_BLOCK);
-  }
-
-  @Test
-  public void checkBlockWorks() {
-    checkBlockWorks(LabelFunction.ANY_WITH_BLOCK);
-    checkBlockWorks(LabelFunction.MAX_WITH_BLOCK);
-  }
-
-  @Test
-  public void checkMaxWorks() {
-    checkMaxIsEnforced(LabelFunction.MAX_NO_BLOCK);
-    checkMaxIsEnforced(LabelFunction.MAX_WITH_BLOCK);
-
-    checkMaxValidatesTheLabel(LabelFunction.MAX_NO_BLOCK);
-    checkMaxValidatesTheLabel(LabelFunction.MAX_WITH_BLOCK);
-  }
-
-  @Test
-  public void checkMaxNoBlockIgnoresMin() {
-    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_M2, APPROVAL_2, APPROVAL_M2);
-
-    SubmitRecord.Label myLabel = LabelFunction.MAX_NO_BLOCK.check(VERIFIED_LABEL, approvals);
-
-    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
-  }
-
-  private static LabelType makeLabel() {
-    List<LabelValue> values = new ArrayList<>();
-    // The label text is irrelevant here, only the numerical value is used
-    values.add(new LabelValue((short) -2, "Great job, please fix compilation."));
-    values.add(new LabelValue((short) -1, "Really good, please make some minor changes."));
-    values.add(new LabelValue((short) 0, "No vote."));
-    values.add(new LabelValue((short) 1, "Closest thing perfection."));
-    values.add(new LabelValue((short) 2, "Perfect!"));
-    return new LabelType(LABEL_NAME, values);
-  }
-
-  private static PatchSetApproval makeApproval(int value) {
-    return PatchSetApproval.builder()
-        .key(PatchSetApproval.key(PS_ID, Account.id(10000 + value), LABEL_ID))
-        .value(value)
-        .granted(Date.from(Instant.now()))
-        .build();
-  }
-
-  private static void checkBlockWorks(LabelFunction function) {
-    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_M2, APPROVAL_2);
-
-    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
-
-    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.REJECT);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_M2.accountId());
-  }
-
-  private static void checkNothingHappens(LabelFunction function) {
-    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
-
-    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.MAY);
-    assertThat(myLabel.appliedBy).isNull();
-  }
-
-  private static void checkLabelIsRequired(LabelFunction function) {
-    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
-
-    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.NEED);
-    assertThat(myLabel.appliedBy).isNull();
-  }
-
-  private static void checkMaxIsEnforced(LabelFunction function) {
-    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_0);
-
-    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
-
-    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.NEED);
-  }
-
-  private static void checkMaxValidatesTheLabel(LabelFunction function) {
-    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_2, APPROVAL_M1);
-
-    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
-
-    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
-    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
-  }
-}
diff --git a/javatests/com/google/gerrit/common/data/LabelTypeTest.java b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
deleted file mode 100644
index 6c3befb..0000000
--- a/javatests/com/google/gerrit/common/data/LabelTypeTest.java
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import org.junit.Test;
-
-public class LabelTypeTest {
-  @Test
-  public void sortLabelValues() {
-    LabelValue v0 = new LabelValue((short) 0, "Zero");
-    LabelValue v1 = new LabelValue((short) 1, "One");
-    LabelValue v2 = new LabelValue((short) 2, "Two");
-    LabelType types = new LabelType("Label", ImmutableList.of(v2, v0, v1));
-    assertThat(types.getValues()).containsExactly(v0, v1, v2).inOrder();
-  }
-
-  @Test
-  public void insertMissingLabelValues() {
-    LabelValue v0 = new LabelValue((short) 0, "Zero");
-    LabelValue v2 = new LabelValue((short) 2, "Two");
-    LabelValue v5 = new LabelValue((short) 5, "Five");
-    LabelType types = new LabelType("Label", ImmutableList.of(v2, v5, v0));
-    assertThat(types.getValues())
-        .containsExactly(
-            v0,
-            new LabelValue((short) 1, ""),
-            v2,
-            new LabelValue((short) 3, ""),
-            new LabelValue((short) 4, ""),
-            v5)
-        .inOrder();
-  }
-}
diff --git a/javatests/com/google/gerrit/common/data/PermissionRuleTest.java b/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
deleted file mode 100644
index d815dbc..0000000
--- a/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
+++ /dev/null
@@ -1,395 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.entities.AccountGroup;
-import org.junit.Before;
-import org.junit.Test;
-
-public class PermissionRuleTest {
-  private GroupReference groupReference;
-  private PermissionRule permissionRule;
-
-  @Before
-  public void setup() {
-    this.groupReference = new GroupReference(AccountGroup.uuid("uuid"), "group");
-    this.permissionRule = new PermissionRule(groupReference);
-  }
-
-  @Test
-  public void getAndSetAction() {
-    assertThat(permissionRule.getAction()).isEqualTo(Action.ALLOW);
-
-    permissionRule.setAction(Action.DENY);
-    assertThat(permissionRule.getAction()).isEqualTo(Action.DENY);
-  }
-
-  @Test
-  public void cannotSetActionToNull() {
-    assertThrows(NullPointerException.class, () -> permissionRule.setAction(null));
-  }
-
-  @Test
-  public void setDeny() {
-    assertThat(permissionRule.isDeny()).isFalse();
-
-    permissionRule.setDeny();
-    assertThat(permissionRule.isDeny()).isTrue();
-  }
-
-  @Test
-  public void setBlock() {
-    assertThat(permissionRule.isBlock()).isFalse();
-
-    permissionRule.setBlock();
-    assertThat(permissionRule.isBlock()).isTrue();
-  }
-
-  @Test
-  public void setForce() {
-    assertThat(permissionRule.getForce()).isFalse();
-
-    permissionRule.setForce(true);
-    assertThat(permissionRule.getForce()).isTrue();
-
-    permissionRule.setForce(false);
-    assertThat(permissionRule.getForce()).isFalse();
-  }
-
-  @Test
-  public void setMin() {
-    assertThat(permissionRule.getMin()).isEqualTo(0);
-
-    permissionRule.setMin(-2);
-    assertThat(permissionRule.getMin()).isEqualTo(-2);
-
-    permissionRule.setMin(2);
-    assertThat(permissionRule.getMin()).isEqualTo(2);
-  }
-
-  @Test
-  public void setMax() {
-    assertThat(permissionRule.getMax()).isEqualTo(0);
-
-    permissionRule.setMax(2);
-    assertThat(permissionRule.getMax()).isEqualTo(2);
-
-    permissionRule.setMax(-2);
-    assertThat(permissionRule.getMax()).isEqualTo(-2);
-  }
-
-  @Test
-  public void setRange() {
-    assertThat(permissionRule.getMin()).isEqualTo(0);
-    assertThat(permissionRule.getMax()).isEqualTo(0);
-
-    permissionRule.setRange(-2, 2);
-    assertThat(permissionRule.getMin()).isEqualTo(-2);
-    assertThat(permissionRule.getMax()).isEqualTo(2);
-
-    permissionRule.setRange(2, -2);
-    assertThat(permissionRule.getMin()).isEqualTo(-2);
-    assertThat(permissionRule.getMax()).isEqualTo(2);
-
-    permissionRule.setRange(1, 1);
-    assertThat(permissionRule.getMin()).isEqualTo(1);
-    assertThat(permissionRule.getMax()).isEqualTo(1);
-  }
-
-  @Test
-  public void hasRange() {
-    assertThat(permissionRule.hasRange()).isFalse();
-
-    permissionRule.setMin(-1);
-    assertThat(permissionRule.hasRange()).isTrue();
-
-    permissionRule.setMax(1);
-    assertThat(permissionRule.hasRange()).isTrue();
-  }
-
-  @Test
-  public void getGroup() {
-    assertThat(permissionRule.getGroup()).isEqualTo(groupReference);
-  }
-
-  @Test
-  public void setGroup() {
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    assertThat(groupReference2).isNotEqualTo(groupReference);
-
-    assertThat(permissionRule.getGroup()).isEqualTo(groupReference);
-
-    permissionRule.setGroup(groupReference2);
-    assertThat(permissionRule.getGroup()).isEqualTo(groupReference2);
-  }
-
-  @Test
-  public void mergeFromAnyBlock() {
-    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
-    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
-
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
-
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isBlock()).isFalse();
-    assertThat(permissionRule2.isBlock()).isFalse();
-
-    permissionRule2.setBlock();
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isBlock()).isTrue();
-    assertThat(permissionRule2.isBlock()).isTrue();
-
-    permissionRule2.setDeny();
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isBlock()).isTrue();
-    assertThat(permissionRule2.isBlock()).isFalse();
-
-    permissionRule2.setAction(Action.BATCH);
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isBlock()).isTrue();
-    assertThat(permissionRule2.isBlock()).isFalse();
-  }
-
-  @Test
-  public void mergeFromAnyDeny() {
-    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
-    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
-
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
-
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isDeny()).isFalse();
-    assertThat(permissionRule2.isDeny()).isFalse();
-
-    permissionRule2.setDeny();
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isDeny()).isTrue();
-    assertThat(permissionRule2.isDeny()).isTrue();
-
-    permissionRule2.setAction(Action.BATCH);
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.isDeny()).isTrue();
-    assertThat(permissionRule2.isDeny()).isFalse();
-  }
-
-  @Test
-  public void mergeFromAnyBatch() {
-    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
-    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
-
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
-
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getAction()).isNotEqualTo(Action.BATCH);
-    assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
-
-    permissionRule2.setAction(Action.BATCH);
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
-    assertThat(permissionRule2.getAction()).isEqualTo(Action.BATCH);
-
-    permissionRule2.setAction(Action.ALLOW);
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
-    assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
-  }
-
-  @Test
-  public void mergeFromAnyForce() {
-    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
-    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
-
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
-
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getForce()).isFalse();
-    assertThat(permissionRule2.getForce()).isFalse();
-
-    permissionRule2.setForce(true);
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getForce()).isTrue();
-    assertThat(permissionRule2.getForce()).isTrue();
-
-    permissionRule2.setForce(false);
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getForce()).isTrue();
-    assertThat(permissionRule2.getForce()).isFalse();
-  }
-
-  @Test
-  public void mergeFromMergeRange() {
-    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
-    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
-    permissionRule1.setRange(-1, 2);
-
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
-    permissionRule2.setRange(-2, 1);
-
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getMin()).isEqualTo(-2);
-    assertThat(permissionRule1.getMax()).isEqualTo(2);
-    assertThat(permissionRule2.getMin()).isEqualTo(-2);
-    assertThat(permissionRule2.getMax()).isEqualTo(1);
-  }
-
-  @Test
-  public void mergeFromGroupNotChanged() {
-    GroupReference groupReference1 = new GroupReference(AccountGroup.uuid("uuid1"), "group1");
-    PermissionRule permissionRule1 = new PermissionRule(groupReference1);
-
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRule2 = new PermissionRule(groupReference2);
-
-    permissionRule1.mergeFrom(permissionRule2);
-    assertThat(permissionRule1.getGroup()).isEqualTo(groupReference1);
-    assertThat(permissionRule2.getGroup()).isEqualTo(groupReference2);
-  }
-
-  @Test
-  public void asString() {
-    assertThat(permissionRule.asString(true)).isEqualTo("group " + groupReference.getName());
-
-    permissionRule.setDeny();
-    assertThat(permissionRule.asString(true)).isEqualTo("deny group " + groupReference.getName());
-
-    permissionRule.setBlock();
-    assertThat(permissionRule.asString(true)).isEqualTo("block group " + groupReference.getName());
-
-    permissionRule.setAction(Action.BATCH);
-    assertThat(permissionRule.asString(true)).isEqualTo("batch group " + groupReference.getName());
-
-    permissionRule.setAction(Action.INTERACTIVE);
-    assertThat(permissionRule.asString(true))
-        .isEqualTo("interactive group " + groupReference.getName());
-
-    permissionRule.setForce(true);
-    assertThat(permissionRule.asString(true))
-        .isEqualTo("interactive +force group " + groupReference.getName());
-
-    permissionRule.setAction(Action.ALLOW);
-    assertThat(permissionRule.asString(true)).isEqualTo("+force group " + groupReference.getName());
-
-    permissionRule.setMax(1);
-    assertThat(permissionRule.asString(true))
-        .isEqualTo("+force +0..+1 group " + groupReference.getName());
-
-    permissionRule.setMin(-1);
-    assertThat(permissionRule.asString(true))
-        .isEqualTo("+force -1..+1 group " + groupReference.getName());
-
-    assertThat(permissionRule.asString(false))
-        .isEqualTo("+force group " + groupReference.getName());
-  }
-
-  @Test
-  public void fromString() {
-    PermissionRule permissionRule = PermissionRule.fromString("group A", true);
-    assertPermissionRule(permissionRule, "A", Action.ALLOW, false, 0, 0);
-
-    permissionRule = PermissionRule.fromString("deny group A", true);
-    assertPermissionRule(permissionRule, "A", Action.DENY, false, 0, 0);
-
-    permissionRule = PermissionRule.fromString("block group A", true);
-    assertPermissionRule(permissionRule, "A", Action.BLOCK, false, 0, 0);
-
-    permissionRule = PermissionRule.fromString("batch group A", true);
-    assertPermissionRule(permissionRule, "A", Action.BATCH, false, 0, 0);
-
-    permissionRule = PermissionRule.fromString("interactive group A", true);
-    assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, false, 0, 0);
-
-    permissionRule = PermissionRule.fromString("interactive +force group A", true);
-    assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, true, 0, 0);
-
-    permissionRule = PermissionRule.fromString("+force group A", true);
-    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
-
-    permissionRule = PermissionRule.fromString("+force +0..+1 group A", true);
-    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 1);
-
-    permissionRule = PermissionRule.fromString("+force -1..+1 group A", true);
-    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, -1, 1);
-
-    permissionRule = PermissionRule.fromString("+force group A", false);
-    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
-  }
-
-  @Test
-  public void parseInt() {
-    assertThat(PermissionRule.parseInt("0")).isEqualTo(0);
-    assertThat(PermissionRule.parseInt("+0")).isEqualTo(0);
-    assertThat(PermissionRule.parseInt("-0")).isEqualTo(0);
-    assertThat(PermissionRule.parseInt("1")).isEqualTo(1);
-    assertThat(PermissionRule.parseInt("+1")).isEqualTo(1);
-    assertThat(PermissionRule.parseInt("-1")).isEqualTo(-1);
-  }
-
-  @Test
-  public void testEquals() {
-    GroupReference groupReference2 = new GroupReference(AccountGroup.uuid("uuid2"), "group2");
-    PermissionRule permissionRuleOther = new PermissionRule(groupReference2);
-    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
-
-    permissionRuleOther.setGroup(groupReference);
-    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
-
-    permissionRule.setDeny();
-    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
-
-    permissionRuleOther.setDeny();
-    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
-
-    permissionRule.setForce(true);
-    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
-
-    permissionRuleOther.setForce(true);
-    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
-
-    permissionRule.setMin(-1);
-    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
-
-    permissionRuleOther.setMin(-1);
-    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
-
-    permissionRule.setMax(1);
-    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
-
-    permissionRuleOther.setMax(1);
-    assertThat(permissionRule.equals(permissionRuleOther)).isTrue();
-  }
-
-  private void assertPermissionRule(
-      PermissionRule permissionRule,
-      String expectedGroupName,
-      Action expectedAction,
-      boolean expectedForce,
-      int expectedMin,
-      int expectedMax) {
-    assertThat(permissionRule.getGroup().getName()).isEqualTo(expectedGroupName);
-    assertThat(permissionRule.getAction()).isEqualTo(expectedAction);
-    assertThat(permissionRule.getForce()).isEqualTo(expectedForce);
-    assertThat(permissionRule.getMin()).isEqualTo(expectedMin);
-    assertThat(permissionRule.getMax()).isEqualTo(expectedMax);
-  }
-}
diff --git a/javatests/com/google/gerrit/common/data/PermissionTest.java b/javatests/com/google/gerrit/common/data/PermissionTest.java
deleted file mode 100644
index 1012eff..0000000
--- a/javatests/com/google/gerrit/common/data/PermissionTest.java
+++ /dev/null
@@ -1,325 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.AccountGroup;
-import java.util.ArrayList;
-import java.util.List;
-import org.junit.Before;
-import org.junit.Test;
-
-public class PermissionTest {
-  private static final String PERMISSION_NAME = "foo";
-
-  private Permission permission;
-
-  @Before
-  public void setup() {
-    this.permission = new Permission(PERMISSION_NAME);
-  }
-
-  @Test
-  public void isPermission() {
-    assertThat(Permission.isPermission(Permission.ABANDON)).isTrue();
-    assertThat(Permission.isPermission("no-permission")).isFalse();
-
-    assertThat(Permission.isPermission(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.isPermission(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.isPermission("Code-Review")).isFalse();
-  }
-
-  @Test
-  public void hasRange() {
-    assertThat(Permission.hasRange(Permission.ABANDON)).isFalse();
-    assertThat(Permission.hasRange("no-permission")).isFalse();
-
-    assertThat(Permission.hasRange(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.hasRange(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.hasRange("Code-Review")).isFalse();
-  }
-
-  @Test
-  public void isLabel() {
-    assertThat(Permission.isLabel(Permission.ABANDON)).isFalse();
-    assertThat(Permission.isLabel("no-permission")).isFalse();
-
-    assertThat(Permission.isLabel(Permission.LABEL + "Code-Review")).isTrue();
-    assertThat(Permission.isLabel(Permission.LABEL_AS + "Code-Review")).isFalse();
-    assertThat(Permission.isLabel("Code-Review")).isFalse();
-  }
-
-  @Test
-  public void isLabelAs() {
-    assertThat(Permission.isLabelAs(Permission.ABANDON)).isFalse();
-    assertThat(Permission.isLabelAs("no-permission")).isFalse();
-
-    assertThat(Permission.isLabelAs(Permission.LABEL + "Code-Review")).isFalse();
-    assertThat(Permission.isLabelAs(Permission.LABEL_AS + "Code-Review")).isTrue();
-    assertThat(Permission.isLabelAs("Code-Review")).isFalse();
-  }
-
-  @Test
-  public void forLabel() {
-    assertThat(Permission.forLabel("Code-Review")).isEqualTo(Permission.LABEL + "Code-Review");
-  }
-
-  @Test
-  public void forLabelAs() {
-    assertThat(Permission.forLabelAs("Code-Review")).isEqualTo(Permission.LABEL_AS + "Code-Review");
-  }
-
-  @Test
-  public void extractLabel() {
-    assertThat(Permission.extractLabel(Permission.LABEL + "Code-Review")).isEqualTo("Code-Review");
-    assertThat(Permission.extractLabel(Permission.LABEL_AS + "Code-Review"))
-        .isEqualTo("Code-Review");
-    assertThat(Permission.extractLabel("Code-Review")).isNull();
-    assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
-  }
-
-  @Test
-  public void canBeOnAllProjects() {
-    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.ABANDON)).isTrue();
-    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)).isFalse();
-    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL + "Code-Review"))
-        .isTrue();
-    assertThat(
-            Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL_AS + "Code-Review"))
-        .isTrue();
-
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + "Code-Review"))
-        .isTrue();
-    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL_AS + "Code-Review"))
-        .isTrue();
-  }
-
-  @Test
-  public void getName() {
-    assertThat(permission.getName()).isEqualTo(PERMISSION_NAME);
-  }
-
-  @Test
-  public void getLabel() {
-    assertThat(new Permission(Permission.LABEL + "Code-Review").getLabel())
-        .isEqualTo("Code-Review");
-    assertThat(new Permission(Permission.LABEL_AS + "Code-Review").getLabel())
-        .isEqualTo("Code-Review");
-    assertThat(new Permission("Code-Review").getLabel()).isNull();
-    assertThat(new Permission(Permission.ABANDON).getLabel()).isNull();
-  }
-
-  @Test
-  public void exclusiveGroup() {
-    assertThat(permission.getExclusiveGroup()).isFalse();
-
-    permission.setExclusiveGroup(true);
-    assertThat(permission.getExclusiveGroup()).isTrue();
-
-    permission.setExclusiveGroup(false);
-    assertThat(permission.getExclusiveGroup()).isFalse();
-  }
-
-  @Test
-  public void noExclusiveGroupOnOwnerPermission() {
-    Permission permission = new Permission(Permission.OWNER);
-    assertThat(permission.getExclusiveGroup()).isFalse();
-
-    permission.setExclusiveGroup(true);
-    assertThat(permission.getExclusiveGroup()).isFalse();
-  }
-
-  @Test
-  public void getEmptyRules() {
-    assertThat(permission.getRules()).isNotNull();
-    assertThat(permission.getRules()).isEmpty();
-  }
-
-  @Test
-  public void setAndGetRules() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
-
-    PermissionRule permissionRule3 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-3"), "group3"));
-    permission.setRules(ImmutableList.of(permissionRule3));
-    assertThat(permission.getRules()).containsExactly(permissionRule3);
-  }
-
-  @Test
-  public void cannotAddPermissionByModifyingListThatWasProvidedToAccessSection() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
-
-    List<PermissionRule> rules = new ArrayList<>();
-    rules.add(permissionRule1);
-    rules.add(permissionRule2);
-    permission.setRules(rules);
-    assertThat(permission.getRule(groupReference3)).isNull();
-
-    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
-    rules.add(permissionRule3);
-    assertThat(permission.getRule(groupReference3)).isNull();
-  }
-
-  @Test
-  public void getNonExistingRule() {
-    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
-    assertThat(permission.getRule(groupReference)).isNull();
-    assertThat(permission.getRule(groupReference, false)).isNull();
-  }
-
-  @Test
-  public void getRule() {
-    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
-    PermissionRule permissionRule = new PermissionRule(groupReference);
-    permission.setRules(ImmutableList.of(permissionRule));
-    assertThat(permission.getRule(groupReference)).isEqualTo(permissionRule);
-  }
-
-  @Test
-  public void createMissingRuleOnGet() {
-    GroupReference groupReference = new GroupReference(AccountGroup.uuid("uuid-1"), "group1");
-    assertThat(permission.getRule(groupReference)).isNull();
-
-    assertThat(permission.getRule(groupReference, true))
-        .isEqualTo(new PermissionRule(groupReference));
-  }
-
-  @Test
-  public void addRule() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
-    assertThat(permission.getRule(groupReference3)).isNull();
-
-    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
-    permission.add(permissionRule3);
-    assertThat(permission.getRule(groupReference3)).isEqualTo(permissionRule3);
-    assertThat(permission.getRules())
-        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
-        .inOrder();
-  }
-
-  @Test
-  public void removeRule() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
-    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
-
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
-    assertThat(permission.getRule(groupReference3)).isNotNull();
-
-    permission.remove(permissionRule3);
-    assertThat(permission.getRule(groupReference3)).isNull();
-    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
-  }
-
-  @Test
-  public void removeRuleByGroupReference() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-    GroupReference groupReference3 = new GroupReference(AccountGroup.uuid("uuid-3"), "group3");
-    PermissionRule permissionRule3 = new PermissionRule(groupReference3);
-
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2, permissionRule3));
-    assertThat(permission.getRule(groupReference3)).isNotNull();
-
-    permission.removeRule(groupReference3);
-    assertThat(permission.getRule(groupReference3)).isNull();
-    assertThat(permission.getRules()).containsExactly(permissionRule1, permissionRule2).inOrder();
-  }
-
-  @Test
-  public void clearRules() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-    assertThat(permission.getRules()).isNotEmpty();
-
-    permission.clearRules();
-    assertThat(permission.getRules()).isEmpty();
-  }
-
-  @Test
-  public void mergePermissions() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-    PermissionRule permissionRule3 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-3"), "group3"));
-
-    Permission permission1 = new Permission("foo");
-    permission1.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-
-    Permission permission2 = new Permission("bar");
-    permission2.setRules(ImmutableList.of(permissionRule2, permissionRule3));
-
-    permission1.mergeFrom(permission2);
-    assertThat(permission1.getRules())
-        .containsExactly(permissionRule1, permissionRule2, permissionRule3)
-        .inOrder();
-  }
-
-  @Test
-  public void testEquals() {
-    PermissionRule permissionRule1 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-1"), "group1"));
-    PermissionRule permissionRule2 =
-        new PermissionRule(new GroupReference(AccountGroup.uuid("uuid-2"), "group2"));
-
-    permission.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-
-    Permission permissionSameRulesOtherName = new Permission("bar");
-    permissionSameRulesOtherName.setRules(ImmutableList.of(permissionRule1, permissionRule2));
-    assertThat(permission.equals(permissionSameRulesOtherName)).isFalse();
-
-    Permission permissionSameRulesSameNameOtherExclusiveGroup = new Permission("foo");
-    permissionSameRulesSameNameOtherExclusiveGroup.setRules(
-        ImmutableList.of(permissionRule1, permissionRule2));
-    permissionSameRulesSameNameOtherExclusiveGroup.setExclusiveGroup(true);
-    assertThat(permission.equals(permissionSameRulesSameNameOtherExclusiveGroup)).isFalse();
-
-    Permission permissionOther = new Permission(PERMISSION_NAME);
-    permissionOther.setRules(ImmutableList.of(permissionRule1));
-    assertThat(permission.equals(permissionOther)).isFalse();
-
-    permissionOther.add(permissionRule2);
-    assertThat(permission.equals(permissionOther)).isTrue();
-  }
-}
diff --git a/javatests/com/google/gerrit/common/data/SubmitRecordTest.java b/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
deleted file mode 100644
index 5386b87..0000000
--- a/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import org.junit.Test;
-
-public class SubmitRecordTest {
-  private static final SubmitRecord OK_RECORD;
-  private static final SubmitRecord FORCED_RECORD;
-  private static final SubmitRecord NOT_READY_RECORD;
-
-  static {
-    OK_RECORD = new SubmitRecord();
-    OK_RECORD.status = SubmitRecord.Status.OK;
-
-    FORCED_RECORD = new SubmitRecord();
-    FORCED_RECORD.status = SubmitRecord.Status.FORCED;
-
-    NOT_READY_RECORD = new SubmitRecord();
-    NOT_READY_RECORD.status = SubmitRecord.Status.NOT_READY;
-  }
-
-  @Test
-  public void okIfAllOkay() {
-    Collection<SubmitRecord> submitRecords = new ArrayList<>();
-    submitRecords.add(OK_RECORD);
-
-    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
-  }
-
-  @Test
-  public void okWhenEmpty() {
-    Collection<SubmitRecord> submitRecords = new ArrayList<>();
-
-    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
-  }
-
-  @Test
-  public void okWhenForced() {
-    Collection<SubmitRecord> submitRecords = new ArrayList<>();
-    submitRecords.add(FORCED_RECORD);
-
-    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
-  }
-
-  @Test
-  public void emptyResultIfInvalid() {
-    Collection<SubmitRecord> submitRecords = new ArrayList<>();
-    submitRecords.add(NOT_READY_RECORD);
-    submitRecords.add(OK_RECORD);
-
-    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isFalse();
-  }
-}
diff --git a/javatests/com/google/gerrit/entities/AccessSectionTest.java b/javatests/com/google/gerrit/entities/AccessSectionTest.java
new file mode 100644
index 0000000..06860b0
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/AccessSectionTest.java
@@ -0,0 +1,242 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AccessSectionTest {
+  private static final String REF_PATTERN = "refs/heads/master";
+
+  private AccessSection.Builder accessSection;
+
+  @Before
+  public void setup() {
+    this.accessSection = AccessSection.builder(REF_PATTERN);
+  }
+
+  @Test
+  public void getName() {
+    assertThat(accessSection.getName()).isEqualTo(REF_PATTERN);
+  }
+
+  @Test
+  public void getEmptyPermissions() {
+    AccessSection builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermissions()).isNotNull();
+    assertThat(builtAccessSection.getPermissions()).isEmpty();
+  }
+
+  @Test
+  public void setAndGetPermissions() {
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+    accessSection.modifyPermissions(
+        permissions -> {
+          permissions.clear();
+          permissions.add(abandonPermission);
+          permissions.add(rebasePermission);
+        });
+
+    AccessSection builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermissions()).hasSize(2);
+    assertThat(builtAccessSection.getPermission(abandonPermission.getName())).isNotNull();
+    assertThat(builtAccessSection.getPermission(rebasePermission.getName())).isNotNull();
+
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+    accessSection.modifyPermissions(
+        p -> {
+          p.clear();
+          p.add(submitPermission);
+        });
+    builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermissions()).hasSize(1);
+    assertThat(builtAccessSection.getPermission(submitPermission.getName())).isNotNull();
+    assertThrows(NullPointerException.class, () -> accessSection.setPermissions(null));
+  }
+
+  @Test
+  public void cannotSetDuplicatePermissions() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            accessSection
+                .addPermission(Permission.builder(Permission.ABANDON))
+                .addPermission(Permission.builder(Permission.ABANDON))
+                .build());
+  }
+
+  @Test
+  public void cannotSetPermissionsWithConflictingNames() {
+    Permission.Builder abandonPermissionLowerCase =
+        Permission.builder(Permission.ABANDON.toLowerCase(Locale.US));
+    Permission.Builder abandonPermissionUpperCase =
+        Permission.builder(Permission.ABANDON.toUpperCase(Locale.US));
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            accessSection
+                .addPermission(abandonPermissionLowerCase)
+                .addPermission(abandonPermissionUpperCase)
+                .build());
+  }
+
+  @Test
+  public void getNonExistingPermission() {
+    assertThat(accessSection.build().getPermission("non-existing")).isNull();
+    assertThat(accessSection.build().getPermission("non-existing")).isNull();
+  }
+
+  @Test
+  public void getPermission() {
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+    accessSection.addPermission(submitPermission);
+    assertThat(accessSection.upsertPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
+    assertThrows(NullPointerException.class, () -> accessSection.upsertPermission(null));
+  }
+
+  @Test
+  public void getPermissionWithOtherCase() {
+    Permission.Builder submitPermissionLowerCase =
+        Permission.builder(Permission.SUBMIT.toLowerCase(Locale.US));
+    accessSection.addPermission(submitPermissionLowerCase);
+    assertThat(accessSection.upsertPermission(Permission.SUBMIT.toUpperCase(Locale.US)))
+        .isEqualTo(submitPermissionLowerCase);
+  }
+
+  @Test
+  public void createMissingPermissionOnGet() {
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
+
+    assertThat(accessSection.upsertPermission(Permission.SUBMIT).build())
+        .isEqualTo(Permission.create(Permission.SUBMIT));
+
+    assertThrows(NullPointerException.class, () -> accessSection.upsertPermission(null));
+  }
+
+  @Test
+  public void addPermission() {
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
+
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+    accessSection.addPermission(submitPermission);
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT))
+        .isEqualTo(submitPermission.build());
+    assertThat(accessSection.build().getPermissions())
+        .containsExactly(
+            abandonPermission.build(), rebasePermission.build(), submitPermission.build())
+        .inOrder();
+    assertThrows(NullPointerException.class, () -> accessSection.addPermission(null));
+  }
+
+  @Test
+  public void removePermission() {
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+    accessSection.addPermission(submitPermission);
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNotNull();
+
+    accessSection.remove(submitPermission);
+    assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
+    assertThat(accessSection.build().getPermissions())
+        .containsExactly(abandonPermission.build(), rebasePermission.build())
+        .inOrder();
+    assertThrows(NullPointerException.class, () -> accessSection.remove(null));
+  }
+
+  @Test
+  public void removePermissionByName() {
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+    Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+    accessSection.addPermission(submitPermission);
+    AccessSection builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermission(Permission.SUBMIT)).isNotNull();
+
+    accessSection.removePermission(Permission.SUBMIT);
+    builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermission(Permission.SUBMIT)).isNull();
+    assertThat(builtAccessSection.getPermissions())
+        .containsExactly(abandonPermission.build(), rebasePermission.build())
+        .inOrder();
+
+    assertThrows(NullPointerException.class, () -> accessSection.removePermission(null));
+  }
+
+  @Test
+  public void removePermissionByNameOtherCase() {
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+
+    String submitLowerCase = Permission.SUBMIT.toLowerCase(Locale.US);
+    String submitUpperCase = Permission.SUBMIT.toUpperCase(Locale.US);
+    Permission.Builder submitPermissionLowerCase = Permission.builder(submitLowerCase);
+
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+    accessSection.addPermission(submitPermissionLowerCase);
+    AccessSection builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermission(submitLowerCase)).isNotNull();
+    assertThat(builtAccessSection.getPermission(submitUpperCase)).isNotNull();
+
+    accessSection.removePermission(submitUpperCase);
+    builtAccessSection = accessSection.build();
+    assertThat(builtAccessSection.getPermission(submitLowerCase)).isNull();
+    assertThat(builtAccessSection.getPermission(submitUpperCase)).isNull();
+    assertThat(builtAccessSection.getPermissions())
+        .containsExactly(abandonPermission.build(), rebasePermission.build())
+        .inOrder();
+  }
+
+  @Test
+  public void testEquals() {
+    Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+    Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+
+    accessSection.addPermission(abandonPermission);
+    accessSection.addPermission(rebasePermission);
+
+    AccessSection builtAccessSection = accessSection.build();
+    AccessSection.Builder accessSectionSamePermissionsOtherRef =
+        AccessSection.builder("refs/heads/other");
+    accessSectionSamePermissionsOtherRef.addPermission(abandonPermission);
+    accessSectionSamePermissionsOtherRef.addPermission(rebasePermission);
+    assertThat(builtAccessSection.equals(accessSectionSamePermissionsOtherRef.build())).isFalse();
+
+    AccessSection.Builder accessSectionOther = AccessSection.builder(REF_PATTERN);
+    accessSectionOther.addPermission(abandonPermission);
+    assertThat(builtAccessSection.equals(accessSectionOther.build())).isFalse();
+
+    accessSectionOther.addPermission(rebasePermission);
+    assertThat(builtAccessSection.equals(accessSectionOther.build())).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/LabelFunctionTest.java b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
new file mode 100644
index 0000000..3941564
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
@@ -0,0 +1,140 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import org.junit.Test;
+
+public class LabelFunctionTest {
+  private static final String LABEL_NAME = "Verified";
+  private static final LabelId LABEL_ID = LabelId.create(LABEL_NAME);
+  private static final Change.Id CHANGE_ID = Change.id(100);
+  private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
+  private static final LabelType VERIFIED_LABEL = makeLabel();
+  private static final PatchSetApproval APPROVAL_2 = makeApproval(2);
+  private static final PatchSetApproval APPROVAL_1 = makeApproval(1);
+  private static final PatchSetApproval APPROVAL_0 = makeApproval(0);
+  private static final PatchSetApproval APPROVAL_M1 = makeApproval(-1);
+  private static final PatchSetApproval APPROVAL_M2 = makeApproval(-2);
+
+  @Test
+  public void checkLabelNameIsCorrect() {
+    for (LabelFunction function : LabelFunction.values()) {
+      SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
+      assertThat(myLabel.label).isEqualTo("Verified");
+    }
+  }
+
+  @Test
+  public void checkFunctionDoesNothing() {
+    checkNothingHappens(LabelFunction.NO_BLOCK);
+    checkNothingHappens(LabelFunction.NO_OP);
+    checkNothingHappens(LabelFunction.PATCH_SET_LOCK);
+    checkNothingHappens(LabelFunction.ANY_WITH_BLOCK);
+
+    checkLabelIsRequired(LabelFunction.MAX_WITH_BLOCK);
+    checkLabelIsRequired(LabelFunction.MAX_NO_BLOCK);
+  }
+
+  @Test
+  public void checkBlockWorks() {
+    checkBlockWorks(LabelFunction.ANY_WITH_BLOCK);
+    checkBlockWorks(LabelFunction.MAX_WITH_BLOCK);
+  }
+
+  @Test
+  public void checkMaxWorks() {
+    checkMaxIsEnforced(LabelFunction.MAX_NO_BLOCK);
+    checkMaxIsEnforced(LabelFunction.MAX_WITH_BLOCK);
+
+    checkMaxValidatesTheLabel(LabelFunction.MAX_NO_BLOCK);
+    checkMaxValidatesTheLabel(LabelFunction.MAX_WITH_BLOCK);
+  }
+
+  @Test
+  public void checkMaxNoBlockIgnoresMin() {
+    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_M2, APPROVAL_2, APPROVAL_M2);
+
+    SubmitRecord.Label myLabel = LabelFunction.MAX_NO_BLOCK.check(VERIFIED_LABEL, approvals);
+
+    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
+  }
+
+  private static LabelType makeLabel() {
+    List<LabelValue> values = new ArrayList<>();
+    // The label text is irrelevant here, only the numerical value is used
+    values.add(LabelValue.create((short) -2, "Great job, please fix compilation."));
+    values.add(LabelValue.create((short) -1, "Really good, please make some minor changes."));
+    values.add(LabelValue.create((short) 0, "No vote."));
+    values.add(LabelValue.create((short) 1, "Closest thing perfection."));
+    values.add(LabelValue.create((short) 2, "Perfect!"));
+    return LabelType.create(LABEL_NAME, values);
+  }
+
+  private static PatchSetApproval makeApproval(int value) {
+    return PatchSetApproval.builder()
+        .key(PatchSetApproval.key(PS_ID, Account.id(10000 + value), LABEL_ID))
+        .value(value)
+        .granted(Date.from(Instant.now()))
+        .build();
+  }
+
+  private static void checkBlockWorks(LabelFunction function) {
+    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_M2, APPROVAL_2);
+
+    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
+
+    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.REJECT);
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_M2.accountId());
+  }
+
+  private static void checkNothingHappens(LabelFunction function) {
+    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
+
+    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.MAY);
+    assertThat(myLabel.appliedBy).isNull();
+  }
+
+  private static void checkLabelIsRequired(LabelFunction function) {
+    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
+
+    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.NEED);
+    assertThat(myLabel.appliedBy).isNull();
+  }
+
+  private static void checkMaxIsEnforced(LabelFunction function) {
+    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_0);
+
+    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
+
+    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.NEED);
+  }
+
+  private static void checkMaxValidatesTheLabel(LabelFunction function) {
+    List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_2, APPROVAL_M1);
+
+    SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
+
+    assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
+    assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/LabelTypeTest.java b/javatests/com/google/gerrit/entities/LabelTypeTest.java
new file mode 100644
index 0000000..f31f2c9
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/LabelTypeTest.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+
+public class LabelTypeTest {
+  @Test
+  public void sortLabelValues() {
+    LabelValue v0 = LabelValue.create((short) 0, "Zero");
+    LabelValue v1 = LabelValue.create((short) 1, "One");
+    LabelValue v2 = LabelValue.create((short) 2, "Two");
+    LabelType types = LabelType.create("Label", ImmutableList.of(v2, v0, v1));
+    assertThat(types.getValues()).containsExactly(v0, v1, v2).inOrder();
+  }
+
+  @Test
+  public void sortCopyValues() {
+    LabelValue v0 = LabelValue.create((short) 0, "Zero");
+    LabelValue v1 = LabelValue.create((short) 1, "One");
+    LabelValue v2 = LabelValue.create((short) 2, "Two");
+    LabelType types =
+        LabelType.builder("Label", ImmutableList.of(v2, v0, v1))
+            .setCopyValues(ImmutableList.of((short) 2, (short) 0, (short) 1))
+            .build();
+    assertThat(types.getCopyValues()).containsExactly((short) 0, (short) 1, (short) 2).inOrder();
+  }
+
+  @Test
+  public void insertMissingLabelValues() {
+    LabelValue v0 = LabelValue.create((short) 0, "Zero");
+    LabelValue v2 = LabelValue.create((short) 2, "Two");
+    LabelValue v5 = LabelValue.create((short) 5, "Five");
+    LabelType types = LabelType.create("Label", ImmutableList.of(v2, v5, v0));
+    assertThat(types.getValues())
+        .containsExactly(
+            v0,
+            LabelValue.create((short) 1, ""),
+            v2,
+            LabelValue.create((short) 3, ""),
+            LabelValue.create((short) 4, ""),
+            v5)
+        .inOrder();
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/PatchTest.java b/javatests/com/google/gerrit/entities/PatchTest.java
index 9f906a9..dce1b3e 100644
--- a/javatests/com/google/gerrit/entities/PatchTest.java
+++ b/javatests/com/google/gerrit/entities/PatchTest.java
@@ -24,6 +24,7 @@
   public void isMagic() {
     assertThat(Patch.isMagic("/COMMIT_MSG")).isTrue();
     assertThat(Patch.isMagic("/MERGE_LIST")).isTrue();
+    assertThat(Patch.isMagic("/PATCHSET_LEVEL")).isTrue();
 
     assertThat(Patch.isMagic("/COMMIT_MSG/")).isFalse();
     assertThat(Patch.isMagic("COMMIT_MSG")).isFalse();
diff --git a/javatests/com/google/gerrit/entities/PermissionRuleTest.java b/javatests/com/google/gerrit/entities/PermissionRuleTest.java
new file mode 100644
index 0000000..c2ed93f
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/PermissionRuleTest.java
@@ -0,0 +1,300 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.PermissionRule.Action;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PermissionRuleTest {
+  private GroupReference groupReference;
+  private PermissionRule permissionRule;
+
+  @Before
+  public void setup() {
+    this.groupReference = GroupReference.create(AccountGroup.uuid("uuid"), "group");
+    this.permissionRule = PermissionRule.create(groupReference);
+  }
+
+  @Test
+  public void mergeFromAnyBlock() {
+    GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isBlock()).isFalse();
+    assertThat(permissionRule2.isBlock()).isFalse();
+
+    permissionRule2 = permissionRule2.toBuilder().setBlock().build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isBlock()).isTrue();
+    assertThat(permissionRule2.isBlock()).isTrue();
+
+    permissionRule2 = permissionRule2.toBuilder().setDeny().build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isBlock()).isTrue();
+    assertThat(permissionRule2.isBlock()).isFalse();
+
+    permissionRule2 = permissionRule2.toBuilder().setAction(Action.BATCH).build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isBlock()).isTrue();
+    assertThat(permissionRule2.isBlock()).isFalse();
+  }
+
+  @Test
+  public void mergeFromAnyDeny() {
+    GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isDeny()).isFalse();
+    assertThat(permissionRule2.isDeny()).isFalse();
+
+    permissionRule2 = permissionRule2.toBuilder().setDeny().build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isDeny()).isTrue();
+    assertThat(permissionRule2.isDeny()).isTrue();
+
+    permissionRule2 = permissionRule2.toBuilder().setAction(Action.BATCH).build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.isDeny()).isTrue();
+    assertThat(permissionRule2.isDeny()).isFalse();
+  }
+
+  @Test
+  public void mergeFromAnyBatch() {
+    GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getAction()).isNotEqualTo(Action.BATCH);
+    assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
+
+    permissionRule2 = permissionRule2.toBuilder().setAction(Action.BATCH).build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
+    assertThat(permissionRule2.getAction()).isEqualTo(Action.BATCH);
+
+    permissionRule2 = permissionRule2.toBuilder().setAction(Action.ALLOW).build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
+    assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
+  }
+
+  @Test
+  public void mergeFromAnyForce() {
+    GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getForce()).isFalse();
+    assertThat(permissionRule2.getForce()).isFalse();
+
+    permissionRule2 = permissionRule2.toBuilder().setForce(true).build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getForce()).isTrue();
+    assertThat(permissionRule2.getForce()).isTrue();
+
+    permissionRule2 = permissionRule2.toBuilder().setForce(false).build();
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getForce()).isTrue();
+    assertThat(permissionRule2.getForce()).isFalse();
+  }
+
+  @Test
+  public void mergeFromMergeRange() {
+    GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 =
+        PermissionRule.builder(groupReference1).setRange(-1, 2).build();
+
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 =
+        PermissionRule.builder(groupReference2).setRange(-2, 1).build();
+
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getMin()).isEqualTo(-2);
+    assertThat(permissionRule1.getMax()).isEqualTo(2);
+    assertThat(permissionRule2.getMin()).isEqualTo(-2);
+    assertThat(permissionRule2.getMax()).isEqualTo(1);
+  }
+
+  @Test
+  public void mergeFromGroupNotChanged() {
+    GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+    PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+    permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+    assertThat(permissionRule1.getGroup()).isEqualTo(groupReference1);
+    assertThat(permissionRule2.getGroup()).isEqualTo(groupReference2);
+  }
+
+  @Test
+  public void asString() {
+    PermissionRule.Builder permissionRule = this.permissionRule.toBuilder();
+
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("group " + groupReference.getName());
+
+    permissionRule.setDeny();
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("deny group " + groupReference.getName());
+
+    permissionRule.setBlock();
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("block group " + groupReference.getName());
+
+    permissionRule.setAction(Action.BATCH);
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("batch group " + groupReference.getName());
+
+    permissionRule.setAction(Action.INTERACTIVE);
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("interactive group " + groupReference.getName());
+
+    permissionRule.setForce(true);
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("interactive +force group " + groupReference.getName());
+
+    permissionRule.setAction(Action.ALLOW);
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("+force group " + groupReference.getName());
+
+    permissionRule.setMax(1);
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("+force +0..+1 group " + groupReference.getName());
+
+    permissionRule.setMin(-1);
+    assertThat(permissionRule.build().asString(true))
+        .isEqualTo("+force -1..+1 group " + groupReference.getName());
+
+    assertThat(permissionRule.build().asString(false))
+        .isEqualTo("+force group " + groupReference.getName());
+  }
+
+  @Test
+  public void fromString() {
+    PermissionRule permissionRule = PermissionRule.fromString("group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("deny group A", true);
+    assertPermissionRule(permissionRule, "A", Action.DENY, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("block group A", true);
+    assertPermissionRule(permissionRule, "A", Action.BLOCK, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("batch group A", true);
+    assertPermissionRule(permissionRule, "A", Action.BATCH, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("interactive group A", true);
+    assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, false, 0, 0);
+
+    permissionRule = PermissionRule.fromString("interactive +force group A", true);
+    assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, true, 0, 0);
+
+    permissionRule = PermissionRule.fromString("+force group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
+
+    permissionRule = PermissionRule.fromString("+force +0..+1 group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 1);
+
+    permissionRule = PermissionRule.fromString("+force -1..+1 group A", true);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, -1, 1);
+
+    permissionRule = PermissionRule.fromString("+force group A", false);
+    assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
+  }
+
+  @Test
+  public void parseInt() {
+    assertThat(PermissionRule.parseInt("0")).isEqualTo(0);
+    assertThat(PermissionRule.parseInt("+0")).isEqualTo(0);
+    assertThat(PermissionRule.parseInt("-0")).isEqualTo(0);
+    assertThat(PermissionRule.parseInt("1")).isEqualTo(1);
+    assertThat(PermissionRule.parseInt("+1")).isEqualTo(1);
+    assertThat(PermissionRule.parseInt("-1")).isEqualTo(-1);
+  }
+
+  @Test
+  public void testEquals() {
+    GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+    PermissionRule.Builder permissionRuleOther = PermissionRule.builder(groupReference2);
+    PermissionRule.Builder permissionRule = this.permissionRule.toBuilder();
+
+    assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setGroup(groupReference);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+
+    permissionRule.setDeny();
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setDeny();
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+
+    permissionRule.setForce(true);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setForce(true);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+
+    permissionRule.setMin(-1);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setMin(-1);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+
+    permissionRule.setMax(1);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
+
+    permissionRuleOther.setMax(1);
+    assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+  }
+
+  private static boolean permissionRuleEquals(
+      PermissionRule.Builder r1, PermissionRule.Builder r2) {
+    return r1.build().equals(r2.build());
+  }
+
+  private void assertPermissionRule(
+      PermissionRule permissionRule,
+      String expectedGroupName,
+      Action expectedAction,
+      boolean expectedForce,
+      int expectedMin,
+      int expectedMax) {
+    assertThat(permissionRule.getGroup().getName()).isEqualTo(expectedGroupName);
+    assertThat(permissionRule.getAction()).isEqualTo(expectedAction);
+    assertThat(permissionRule.getForce()).isEqualTo(expectedForce);
+    assertThat(permissionRule.getMin()).isEqualTo(expectedMin);
+    assertThat(permissionRule.getMax()).isEqualTo(expectedMax);
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/PermissionTest.java b/javatests/com/google/gerrit/entities/PermissionTest.java
new file mode 100644
index 0000000..2915f79
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/PermissionTest.java
@@ -0,0 +1,291 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Before;
+import org.junit.Test;
+
+public class PermissionTest {
+  private static final String PERMISSION_NAME = "foo";
+
+  private Permission.Builder permission;
+
+  @Before
+  public void setup() {
+    this.permission = Permission.builder(PERMISSION_NAME);
+  }
+
+  @Test
+  public void isPermission() {
+    assertThat(Permission.isPermission(Permission.ABANDON)).isTrue();
+    assertThat(Permission.isPermission("no-permission")).isFalse();
+
+    assertThat(Permission.isPermission(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.isPermission(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.isPermission("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void hasRange() {
+    assertThat(Permission.hasRange(Permission.ABANDON)).isFalse();
+    assertThat(Permission.hasRange("no-permission")).isFalse();
+
+    assertThat(Permission.hasRange(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.hasRange(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.hasRange("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void isLabel() {
+    assertThat(Permission.isLabel(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isLabel("no-permission")).isFalse();
+
+    assertThat(Permission.isLabel(Permission.LABEL + "Code-Review")).isTrue();
+    assertThat(Permission.isLabel(Permission.LABEL_AS + "Code-Review")).isFalse();
+    assertThat(Permission.isLabel("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void isLabelAs() {
+    assertThat(Permission.isLabelAs(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isLabelAs("no-permission")).isFalse();
+
+    assertThat(Permission.isLabelAs(Permission.LABEL + "Code-Review")).isFalse();
+    assertThat(Permission.isLabelAs(Permission.LABEL_AS + "Code-Review")).isTrue();
+    assertThat(Permission.isLabelAs("Code-Review")).isFalse();
+  }
+
+  @Test
+  public void forLabel() {
+    assertThat(Permission.forLabel("Code-Review")).isEqualTo(Permission.LABEL + "Code-Review");
+  }
+
+  @Test
+  public void forLabelAs() {
+    assertThat(Permission.forLabelAs("Code-Review")).isEqualTo(Permission.LABEL_AS + "Code-Review");
+  }
+
+  @Test
+  public void extractLabel() {
+    assertThat(Permission.extractLabel(Permission.LABEL + "Code-Review")).isEqualTo("Code-Review");
+    assertThat(Permission.extractLabel(Permission.LABEL_AS + "Code-Review"))
+        .isEqualTo("Code-Review");
+    assertThat(Permission.extractLabel("Code-Review")).isNull();
+    assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
+  }
+
+  @Test
+  public void canBeOnAllProjects() {
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.ABANDON)).isTrue();
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)).isFalse();
+    assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL + "Code-Review"))
+        .isTrue();
+    assertThat(
+            Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL_AS + "Code-Review"))
+        .isTrue();
+
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + "Code-Review"))
+        .isTrue();
+    assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL_AS + "Code-Review"))
+        .isTrue();
+  }
+
+  @Test
+  public void getName() {
+    assertThat(permission.getName()).isEqualTo(PERMISSION_NAME);
+  }
+
+  @Test
+  public void getLabel() {
+    assertThat(Permission.create(Permission.LABEL + "Code-Review").getLabel())
+        .isEqualTo("Code-Review");
+    assertThat(Permission.create(Permission.LABEL_AS + "Code-Review").getLabel())
+        .isEqualTo("Code-Review");
+    assertThat(Permission.create("Code-Review").getLabel()).isNull();
+    assertThat(Permission.create(Permission.ABANDON).getLabel()).isNull();
+  }
+
+  @Test
+  public void exclusiveGroup() {
+    assertThat(permission.build().getExclusiveGroup()).isFalse();
+
+    permission.setExclusiveGroup(true);
+    assertThat(permission.build().getExclusiveGroup()).isTrue();
+
+    permission.setExclusiveGroup(false);
+    assertThat(permission.build().getExclusiveGroup()).isFalse();
+  }
+
+  @Test
+  public void noExclusiveGroupOnOwnerPermission() {
+    Permission permission = Permission.create(Permission.OWNER);
+    assertThat(permission.getExclusiveGroup()).isFalse();
+
+    permission = permission.toBuilder().setExclusiveGroup(true).build();
+    assertThat(permission.getExclusiveGroup()).isFalse();
+  }
+
+  @Test
+  public void getEmptyRules() {
+    assertThat(permission.getRulesBuilders()).isNotNull();
+    assertThat(permission.getRulesBuilders()).isEmpty();
+  }
+
+  @Test
+  public void setAndGetRules() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    assertThat(permission.getRulesBuilders())
+        .containsExactly(permissionRule1, permissionRule2)
+        .inOrder();
+
+    PermissionRule.Builder permissionRule3 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-3"), "group3"));
+    permission.modifyRules(
+        rules -> {
+          rules.clear();
+          rules.add(permissionRule3);
+        });
+    assertThat(permission.getRulesBuilders()).containsExactly(permissionRule3);
+  }
+
+  @Test
+  public void getNonExistingRule() {
+    GroupReference groupReference = GroupReference.create(AccountGroup.uuid("uuid-1"), "group1");
+    assertThat(permission.build().getRule(groupReference)).isNull();
+    assertThat(permission.build().getRule(groupReference)).isNull();
+  }
+
+  @Test
+  public void getRule() {
+    GroupReference groupReference = GroupReference.create(AccountGroup.uuid("uuid-1"), "group1");
+    PermissionRule.Builder permissionRule = PermissionRule.builder(groupReference);
+    permission.add(permissionRule);
+    assertThat(permission.build().getRule(groupReference)).isEqualTo(permissionRule.build());
+  }
+
+  @Test
+  public void addRule() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
+    assertThat(permission.build().getRule(groupReference3)).isNull();
+
+    PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
+    permission.add(permissionRule3);
+    assertThat(permission.build().getRule(groupReference3)).isEqualTo(permissionRule3.build());
+    assertThat(permission.build().getRules())
+        .containsExactly(permissionRule1.build(), permissionRule2.build(), permissionRule3.build())
+        .inOrder();
+  }
+
+  @Test
+  public void removeRule() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
+    PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
+
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    permission.add(permissionRule3);
+    assertThat(permission.build().getRule(groupReference3)).isNotNull();
+
+    permission.remove(permissionRule3.build());
+    assertThat(permission.build().getRule(groupReference3)).isNull();
+    assertThat(permission.build().getRules())
+        .containsExactly(permissionRule1.build(), permissionRule2.build())
+        .inOrder();
+  }
+
+  @Test
+  public void removeRuleByGroupReference() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+    GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
+    PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
+
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    permission.add(permissionRule3);
+    assertThat(permission.build().getRule(groupReference3)).isNotNull();
+
+    permission.removeRule(groupReference3);
+    assertThat(permission.build().getRule(groupReference3)).isNull();
+    assertThat(permission.build().getRules())
+        .containsExactly(permissionRule1.build(), permissionRule2.build())
+        .inOrder();
+  }
+
+  @Test
+  public void clearRules() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+    assertThat(permission.build().getRules()).isNotEmpty();
+
+    permission.clearRules();
+    assertThat(permission.build().getRules()).isEmpty();
+  }
+
+  @Test
+  public void testEquals() {
+    PermissionRule.Builder permissionRule1 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+    PermissionRule.Builder permissionRule2 =
+        PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+
+    permission.add(permissionRule1);
+    permission.add(permissionRule2);
+
+    Permission.Builder permissionSameRulesOtherName = Permission.builder("bar");
+    permissionSameRulesOtherName.add(permissionRule1);
+    permissionSameRulesOtherName.add(permissionRule2);
+    assertThat(permission.equals(permissionSameRulesOtherName)).isFalse();
+
+    Permission.Builder permissionSameRulesSameNameOtherExclusiveGroup = Permission.builder("foo");
+    permissionSameRulesSameNameOtherExclusiveGroup.add(permissionRule1);
+    permissionSameRulesSameNameOtherExclusiveGroup.add(permissionRule2);
+    permissionSameRulesSameNameOtherExclusiveGroup.setExclusiveGroup(true);
+    assertThat(permission.equals(permissionSameRulesSameNameOtherExclusiveGroup)).isFalse();
+
+    Permission.Builder permissionOther = Permission.builder(PERMISSION_NAME);
+    permissionOther.add(permissionRule1);
+    assertThat(permission.build().equals(permissionOther.build())).isFalse();
+
+    permissionOther.add(permissionRule2);
+    assertThat(permission.build().equals(permissionOther.build())).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/SubmitRecordTest.java b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
new file mode 100644
index 0000000..0e832f4
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import org.junit.Test;
+
+public class SubmitRecordTest {
+  private static final SubmitRecord OK_RECORD;
+  private static final SubmitRecord FORCED_RECORD;
+  private static final SubmitRecord NOT_READY_RECORD;
+
+  static {
+    OK_RECORD = new SubmitRecord();
+    OK_RECORD.status = SubmitRecord.Status.OK;
+
+    FORCED_RECORD = new SubmitRecord();
+    FORCED_RECORD.status = SubmitRecord.Status.FORCED;
+
+    NOT_READY_RECORD = new SubmitRecord();
+    NOT_READY_RECORD.status = SubmitRecord.Status.NOT_READY;
+  }
+
+  @Test
+  public void okIfAllOkay() {
+    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+    submitRecords.add(OK_RECORD);
+
+    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
+  }
+
+  @Test
+  public void okWhenEmpty() {
+    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+
+    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
+  }
+
+  @Test
+  public void okWhenForced() {
+    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+    submitRecords.add(FORCED_RECORD);
+
+    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
+  }
+
+  @Test
+  public void emptyResultIfInvalid() {
+    Collection<SubmitRecord> submitRecords = new ArrayList<>();
+    submitRecords.add(NOT_READY_RECORD);
+    submitRecords.add(OK_RECORD);
+
+    assertThat(SubmitRecord.allRecordsOK(submitRecords)).isFalse();
+  }
+}
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index 8577c16..76ce956 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -28,9 +28,9 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
diff --git a/javatests/com/google/gerrit/mail/AbstractParserTest.java b/javatests/com/google/gerrit/mail/AbstractParserTest.java
index f3c8671..b22b8ad 100644
--- a/javatests/com/google/gerrit/mail/AbstractParserTest.java
+++ b/javatests/com/google/gerrit/mail/AbstractParserTest.java
@@ -17,7 +17,9 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -37,7 +39,7 @@
   }
 
   protected static void assertInlineComment(
-      String message, MailComment comment, Comment inReplyTo) {
+      String message, MailComment comment, HumanComment inReplyTo) {
     assertThat(comment.fileName).isNull();
     assertThat(comment.message).isEqualTo(message);
     assertThat(comment.inReplyTo.key).isEqualTo(inReplyTo.key);
@@ -51,9 +53,9 @@
     assertThat(comment.type).isEqualTo(MailComment.CommentType.FILE_COMMENT);
   }
 
-  protected static Comment newComment(String uuid, String file, String message, int line) {
-    Comment c =
-        new Comment(
+  protected static HumanComment newComment(String uuid, String file, String message, int line) {
+    HumanComment c =
+        new HumanComment(
             new Comment.Key(uuid, file, 1),
             Account.id(0),
             new Timestamp(0L),
@@ -65,9 +67,10 @@
     return c;
   }
 
-  protected static Comment newRangeComment(String uuid, String file, String message, int line) {
-    Comment c =
-        new Comment(
+  protected static HumanComment newRangeComment(
+      String uuid, String file, String message, int line) {
+    HumanComment c =
+        new HumanComment(
             new Comment.Key(uuid, file, 1),
             Account.id(0),
             new Timestamp(0L),
@@ -91,8 +94,8 @@
   }
 
   /** Returns a List of default comments for testing. */
-  protected static List<Comment> defaultComments() {
-    List<Comment> comments = new ArrayList<>();
+  protected static List<HumanComment> defaultComments() {
+    List<HumanComment> comments = new ArrayList<>();
     comments.add(newComment("c1", "gerrit-server/test.txt", "comment", 0));
     comments.add(newComment("c2", "gerrit-server/test.txt", "comment", 2));
     comments.add(newComment("c3", "gerrit-server/test.txt", "comment", 3));
diff --git a/javatests/com/google/gerrit/mail/AddressTest.java b/javatests/com/google/gerrit/mail/AddressTest.java
index 8addcf8..232b8d1 100644
--- a/javatests/com/google/gerrit/mail/AddressTest.java
+++ b/javatests/com/google/gerrit/mail/AddressTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.gerrit.entities.Address;
 import org.junit.Test;
 
 public class AddressTest {
diff --git a/javatests/com/google/gerrit/mail/HtmlParserTest.java b/javatests/com/google/gerrit/mail/HtmlParserTest.java
index 345cb05..d661278 100644
--- a/javatests/com/google/gerrit/mail/HtmlParserTest.java
+++ b/javatests/com/google/gerrit/mail/HtmlParserTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.List;
 import org.junit.Ignore;
 import org.junit.Test;
@@ -31,7 +31,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.htmlContent(newHtmlBody("Looks good to me", null, null, null, null, null, null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
 
     assertThat(parsedComments).hasSize(1);
@@ -52,7 +52,7 @@
             null,
             null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
 
     assertThat(parsedComments).hasSize(1);
@@ -73,7 +73,7 @@
             null,
             null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -96,7 +96,7 @@
             null,
             null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -121,7 +121,7 @@
             null,
             null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -135,7 +135,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.htmlContent(newHtmlBody(null, null, null, null, null, null, null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).isEmpty();
@@ -148,7 +148,7 @@
         newHtmlBody(
             null, null, null, "Also have a comment here.", "This is a nice file", null, null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(2);
@@ -164,7 +164,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.htmlContent(newHtmlBody(htmlMessage, null, null, htmlMessage, htmlMessage, null, null));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
diff --git a/javatests/com/google/gerrit/mail/MailHeaderParserTest.java b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
index 296d1a1..7e3edab 100644
--- a/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
+++ b/javatests/com/google/gerrit/mail/MailHeaderParserTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.gerrit.entities.Address;
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/mail/TextParserTest.java b/javatests/com/google/gerrit/mail/TextParserTest.java
index 00d5b41..f1d6179 100644
--- a/javatests/com/google/gerrit/mail/TextParserTest.java
+++ b/javatests/com/google/gerrit/mail/TextParserTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import java.util.List;
 import org.junit.Test;
 
@@ -39,7 +39,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.textContent("Looks good to me\n" + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(1);
@@ -60,7 +60,7 @@
                 null)
             + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -83,7 +83,7 @@
                 null)
             + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -97,7 +97,7 @@
     MailMessage.Builder b = newMailMessageBuilder();
     b.textContent(newPlaintextBody(null, null, null, null, null, null, null) + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).isEmpty();
@@ -111,7 +111,7 @@
                 null, null, null, "Also have a comment here.", "This is a nice file", null, null)
             + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(2);
@@ -134,7 +134,7 @@
                 + quotedFooter)
             .replace("> ", ">> "));
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
@@ -157,7 +157,7 @@
                 "Comment in reply to file comment")
             + quotedFooter);
 
-    List<Comment> comments = defaultComments();
+    List<HumanComment> comments = defaultComments();
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(2);
diff --git a/javatests/com/google/gerrit/mail/data/AttachmentMessage.java b/javatests/com/google/gerrit/mail/data/AttachmentMessage.java
index aea59ba..b39e3be 100644
--- a/javatests/com/google/gerrit/mail/data/AttachmentMessage.java
+++ b/javatests/com/google/gerrit/mail/data/AttachmentMessage.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail.data;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java b/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
index 957ee6e..92ba97c 100644
--- a/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
+++ b/javatests/com/google/gerrit/mail/data/Base64HeaderMessage.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail.data;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java b/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
index e5e2ed8..7cbf9c0 100644
--- a/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
+++ b/javatests/com/google/gerrit/mail/data/HtmlMimeMessage.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail.data;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/mail/data/NonUTF8Message.java b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
index e183a37..60368eb 100644
--- a/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
+++ b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
@@ -13,7 +13,7 @@
 // limitations under the License.
 package com.google.gerrit.mail.data;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java b/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
index ac739c8..94c9d42 100644
--- a/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
+++ b/javatests/com/google/gerrit/mail/data/QuotedPrintableHeaderMessage.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail.data;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
index 3f8e62f..20d8076 100644
--- a/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
+++ b/javatests/com/google/gerrit/mail/data/SimpleTextMessage.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.mail.data;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.LocalDateTime;
 import java.time.Month;
diff --git a/javatests/com/google/gerrit/server/account/AccountCacheTest.java b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
index 5e75fe5..a2aa40b 100644
--- a/javatests/com/google/gerrit/server/account/AccountCacheTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
@@ -19,6 +19,7 @@
 import com.google.common.truth.Truth;
 import com.google.common.truth.extensions.proto.ProtoTruth;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.config.CachedPreferences;
@@ -103,7 +104,7 @@
     CachedAccountDetails original =
         CachedAccountDetails.create(
             ACCOUNT,
-            ImmutableMap.of(key, ImmutableSet.of(ProjectWatches.NotifyType.ALL_COMMENTS)),
+            ImmutableMap.of(key, ImmutableSet.of(NotifyConfig.NotifyType.ALL_COMMENTS)),
             CachedPreferences.fromString(""));
 
     byte[] serialized = SERIALIZER.serialize(original);
@@ -127,7 +128,7 @@
     CachedAccountDetails original =
         CachedAccountDetails.create(
             ACCOUNT,
-            ImmutableMap.of(key, ImmutableSet.of(ProjectWatches.NotifyType.ALL_COMMENTS)),
+            ImmutableMap.of(key, ImmutableSet.of(NotifyConfig.NotifyType.ALL_COMMENTS)),
             CachedPreferences.fromString(""));
 
     byte[] serialized = SERIALIZER.serialize(original);
diff --git a/javatests/com/google/gerrit/server/account/DestinationListTest.java b/javatests/com/google/gerrit/server/account/DestinationListTest.java
index 4188f39..6fcf75c 100644
--- a/javatests/com/google/gerrit/server/account/DestinationListTest.java
+++ b/javatests/com/google/gerrit/server/account/DestinationListTest.java
@@ -132,7 +132,7 @@
     List<ValidationError> errors = new ArrayList<>();
     new DestinationList().parseLabel(LABEL, L_BAD, errors::add);
     assertThat(errors)
-        .containsExactly(new ValidationError("destinationslabel", 1, "missing tab delimiter"));
+        .containsExactly(ValidationError.create("destinationslabel", 1, "missing tab delimiter"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/account/QueryListTest.java b/javatests/com/google/gerrit/server/account/QueryListTest.java
index 7d491c9..74ce907 100644
--- a/javatests/com/google/gerrit/server/account/QueryListTest.java
+++ b/javatests/com/google/gerrit/server/account/QueryListTest.java
@@ -101,7 +101,8 @@
   public void testParseBad() throws Exception {
     List<ValidationError> errors = new ArrayList<>();
     assertThat(QueryList.parse(L_BAD, errors::add).asText()).isNull();
-    assertThat(errors).containsExactly(new ValidationError("queries", 1, "missing tab delimiter"));
+    assertThat(errors)
+        .containsExactly(ValidationError.create("queries", 1, "missing tab delimiter"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/account/WatchConfigTest.java b/javatests/com/google/gerrit/server/account/WatchConfigTest.java
index 95dbbde..7d36b94 100644
--- a/javatests/com/google/gerrit/server/account/WatchConfigTest.java
+++ b/javatests/com/google/gerrit/server/account/WatchConfigTest.java
@@ -19,8 +19,8 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.account.ProjectWatches.NotifyType;
 import com.google.gerrit.server.account.ProjectWatches.NotifyValue;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.git.ValidationError;
diff --git a/javatests/com/google/gerrit/server/cache/h2/BUILD b/javatests/com/google/gerrit/server/cache/h2/BUILD
index 98f1b0e..438990c 100644
--- a/javatests/com/google/gerrit/server/cache/h2/BUILD
+++ b/javatests/com/google/gerrit/server/cache/h2/BUILD
@@ -6,10 +6,12 @@
     deps = [
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/util/time",
         "//lib:guava",
         "//lib:h2",
         "//lib:junit",
         "//lib/guice",
+        "//lib/mockito",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 69c2799..3ade4d0 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -16,16 +16,27 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.ValueHolder;
 import com.google.gerrit.server.cache.serialize.StringCacheSerializer;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.TypeLiteral;
+import java.time.Duration;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
+import javax.annotation.Nullable;
 import org.junit.Test;
 
 public class H2CacheTest {
@@ -38,23 +49,31 @@
   }
 
   private static H2CacheImpl<String, String> newH2CacheImpl(
-      int id, Cache<String, ValueHolder<String>> mem, int version) {
-    SqlStore<String, String> store =
-        new SqlStore<>(
-            "jdbc:h2:mem:Test_" + id,
-            KEY_TYPE,
-            StringCacheSerializer.INSTANCE,
-            StringCacheSerializer.INSTANCE,
-            version,
-            1 << 20,
-            null);
+      SqlStore<String, String> store, Cache<String, ValueHolder<String>> mem) {
     return new H2CacheImpl<>(MoreExecutors.directExecutor(), store, KEY_TYPE, mem);
   }
 
+  private static SqlStore<String, String> newStore(
+      int id,
+      int version,
+      @Nullable Duration expireAfterWrite,
+      @Nullable Duration refreshAfterWrite) {
+    return new SqlStore<>(
+        "jdbc:h2:mem:Test_" + id,
+        KEY_TYPE,
+        StringCacheSerializer.INSTANCE,
+        StringCacheSerializer.INSTANCE,
+        version,
+        1 << 20,
+        expireAfterWrite,
+        refreshAfterWrite);
+  }
+
   @Test
   public void get() throws ExecutionException {
     Cache<String, ValueHolder<String>> mem = CacheBuilder.newBuilder().build();
-    H2CacheImpl<String, String> impl = newH2CacheImpl(nextDbId(), mem, DEFAULT_VERSION);
+    H2CacheImpl<String, String> impl =
+        newH2CacheImpl(newStore(nextDbId(), DEFAULT_VERSION, null, null), mem);
 
     assertThat(impl.getIfPresent("foo")).isNull();
 
@@ -94,11 +113,12 @@
   }
 
   @Test
-  public void version() throws Exception {
+  public void version() {
     int id = nextDbId();
-    H2CacheImpl<String, String> oldImpl = newH2CacheImpl(id, disableMemCache(), DEFAULT_VERSION);
+    H2CacheImpl<String, String> oldImpl =
+        newH2CacheImpl(newStore(id, DEFAULT_VERSION, null, null), disableMemCache());
     H2CacheImpl<String, String> newImpl =
-        newH2CacheImpl(id, disableMemCache(), DEFAULT_VERSION + 1);
+        newH2CacheImpl(newStore(id, DEFAULT_VERSION + 1, null, null), disableMemCache());
 
     assertThat(oldImpl.diskStats().space()).isEqualTo(0);
     assertThat(newImpl.diskStats().space()).isEqualTo(0);
@@ -124,6 +144,58 @@
     assertThat(oldImpl.getIfPresent("key")).isNull();
   }
 
+  @Test
+  public void refreshAfterWrite_triggeredWhenConfigured() throws Exception {
+    SqlStore<String, String> store =
+        newStore(nextDbId(), DEFAULT_VERSION, null, Duration.ofMillis(10));
+
+    // This is the loader that we configure for the cache when calling .loader(...)
+    @SuppressWarnings("unchecked")
+    CacheLoader<String, String> baseLoader = mock(CacheLoader.class);
+    resetLoaderAndAnswerLoadAndRefreshCalls(baseLoader);
+
+    // We wrap baseLoader just like H2CacheFactory is wrapping it. The wrapped version will call out
+    // to the store for refreshing values.
+    H2CacheImpl.Loader<String, String> wrappedLoader =
+        new H2CacheImpl.Loader<>(MoreExecutors.directExecutor(), store, baseLoader);
+    // memCache is the in-memory variant of the cache. Its loader is wrappedLoader which will call
+    // out to the store to save or delete cached values.
+    LoadingCache<String, ValueHolder<String>> memCache =
+        CacheBuilder.newBuilder().maximumSize(10).build(wrappedLoader);
+
+    // h2Cache puts it all together
+    H2CacheImpl<String, String> h2Cache = newH2CacheImpl(store, memCache);
+
+    // Initial load and cache retrieval do not trigger refresh
+    // This works because we use a directExecutor() for refreshes
+    TimeUtil.setCurrentMillisSupplier(() -> 0);
+    assertThat(h2Cache.get("foo")).isEqualTo("load:foo");
+    verify(baseLoader).load("foo");
+    assertThat(h2Cache.get("foo")).isEqualTo("load:foo");
+    verifyNoMoreInteractions(baseLoader);
+    resetLoaderAndAnswerLoadAndRefreshCalls(baseLoader);
+
+    // Load after refresh duration returns old value, triggers refresh and returns new value
+    TimeUtil.setCurrentMillisSupplier(() -> 11);
+    assertThat(h2Cache.get("foo")).isEqualTo("load:foo");
+    assertThat(h2Cache.get("foo")).isEqualTo("reload:foo");
+    verify(baseLoader).reload("foo", "load:foo");
+    verifyNoMoreInteractions(baseLoader);
+    resetLoaderAndAnswerLoadAndRefreshCalls(baseLoader);
+
+    // Refreshed value was persisted
+    memCache.invalidateAll(); // Invalidates only the memcache, not the store.
+    assertThat(h2Cache.getIfPresent("foo")).isEqualTo("reload:foo");
+  }
+
+  @SuppressWarnings("unchecked")
+  private static void resetLoaderAndAnswerLoadAndRefreshCalls(CacheLoader<String, String> loader)
+      throws Exception {
+    reset(loader);
+    when(loader.load("foo")).thenReturn("load:foo");
+    when(loader.reload("foo", "load:foo")).thenReturn(Futures.immediateFuture("reload:foo"));
+  }
+
   private static <K, V> Cache<K, ValueHolder<V>> disableMemCache() {
     return CacheBuilder.newBuilder().maximumSize(0).build();
   }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializerTest.java
new file mode 100644
index 0000000..40a8105
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializerTest.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.AccessSectionSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.AccessSectionSerializer.serialize;
+
+import com.google.gerrit.entities.AccessSection;
+import org.junit.Test;
+
+public class AccessSectionSerializerTest {
+  static final AccessSection ALL_VALUES_SET =
+      AccessSection.builder("refs/test")
+          .addPermission(PermissionSerializerTest.ALL_VALUES_SET.toBuilder())
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    AccessSection autoValue = AccessSection.builder("refs/test").build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/AddressSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/AddressSerializerTest.java
new file mode 100644
index 0000000..4f080a7
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/AddressSerializerTest.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.AddressSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.AddressSerializer.serialize;
+
+import com.google.gerrit.entities.Address;
+import org.junit.Test;
+
+public class AddressSerializerTest {
+  @Test
+  public void roundTrip() {
+    Address autoValue = Address.create("Jane Doe", "jdoe@example.com");
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    Address autoValue = Address.create("jdoe@example.com");
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
new file mode 100644
index 0000000..7fe73d5
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -0,0 +1,22 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server/cache/serialize",
+        "//java/com/google/gerrit/server/cache/serialize/entities",
+        "//java/com/google/gerrit/server/cache/testing",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib:protobuf",
+        "//lib/truth",
+        "//lib/truth:truth-proto-extension",
+        "//proto:cache_java_proto",
+        "//proto/testing:test_java_proto",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializerTest.java
new file mode 100644
index 0000000..10b905a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializerTest.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.BranchOrderSectionSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.BranchOrderSectionSerializer.serialize;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.BranchOrderSection;
+import org.junit.Test;
+
+public class BranchOrderSectionSerializerTest {
+  static final BranchOrderSection ALL_VALUES_SET =
+      BranchOrderSection.create(ImmutableList.of("master", "stable"));
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
new file mode 100644
index 0000000..2bddf23
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.CachedProjectConfigSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.CachedProjectConfigSerializer.serialize;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.AccountsSection;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.ConfiguredMimeTypes;
+import com.google.gerrit.entities.PermissionRule;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class CachedProjectConfigSerializerTest {
+  static final CachedProjectConfig MINIMAL_VALUES_SET =
+      CachedProjectConfig.builder()
+          .setProject(ProjectSerializerTest.ALL_VALUES_SET)
+          .setMimeTypes(
+              ConfiguredMimeTypes.create(
+                  ImmutableList.of(new ConfiguredMimeTypes.ReType("type", "pattern"))))
+          .setAccountsSection(
+              AccountsSection.create(
+                  ImmutableList.of(
+                      PermissionRule.create(GroupReferenceSerializerTest.ALL_VALUES_SET))))
+          .setMaxObjectSizeLimit(123)
+          .setCheckReceivedObjects(true)
+          .build();
+
+  static final CachedProjectConfig ALL_VALUES_SET =
+      MINIMAL_VALUES_SET
+          .toBuilder()
+          .addGroup(GroupReferenceSerializerTest.ALL_VALUES_SET)
+          .addAccessSection(AccessSectionSerializerTest.ALL_VALUES_SET)
+          .setBranchOrderSection(Optional.of(BranchOrderSectionSerializerTest.ALL_VALUES_SET))
+          .addNotifySection(NotifyConfigSerializerTest.ALL_VALUES_SET)
+          .addLabelSection(LabelTypeSerializerTest.ALL_VALUES_SET)
+          .addSubscribeSection(SubscribeSectionSerializerTest.ALL_VALUES_SET)
+          .addCommentLinkSection(StoredCommentLinkInfoSerializerTest.HTML_ONLY)
+          .setRevision(Optional.of(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")))
+          .setRulesId(Optional.of(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")))
+          .setExtensionPanelSections(ImmutableMap.of("key1", ImmutableList.of("val1", "val2")))
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    assertThat(deserialize(serialize(MINIMAL_VALUES_SET))).isEqualTo(MINIMAL_VALUES_SET);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ConfiguredMimeTypeSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ConfiguredMimeTypeSerializerTest.java
new file mode 100644
index 0000000..f0e4932
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ConfiguredMimeTypeSerializerTest.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.ConfiguredMimeTypeSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.ConfiguredMimeTypeSerializer.serialize;
+
+import com.google.gerrit.entities.ConfiguredMimeTypes;
+import org.junit.Test;
+
+public class ConfiguredMimeTypeSerializerTest {
+  @Test
+  public void reType_roundTrip() {
+    ConfiguredMimeTypes.ReType value = new ConfiguredMimeTypes.ReType("type", "pattern");
+    assertThat(deserialize(serialize(value))).isEqualTo(value);
+  }
+
+  @Test
+  public void fnType_roundTrip() throws Exception {
+    ConfiguredMimeTypes.FnType value = new ConfiguredMimeTypes.FnType("type", "pattern");
+    assertThat(deserialize(serialize(value))).isEqualTo(value);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializerTest.java
new file mode 100644
index 0000000..99e3c07
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializerTest.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.ContributorAgreementSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.ContributorAgreementSerializer.serialize;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PermissionRule;
+import org.junit.Test;
+
+public class ContributorAgreementSerializerTest {
+  @Test
+  public void roundTrip() {
+    ContributorAgreement autoValue =
+        ContributorAgreement.builder("name")
+            .setDescription("desc")
+            .setAgreementUrl("url")
+            .setAutoVerify(GroupReference.create("auto-verify"))
+            .setAccepted(
+                ImmutableList.of(
+                    PermissionRule.create(GroupReference.create("accepted1")),
+                    PermissionRule.create(GroupReference.create("accepted2"))))
+            .setExcludeProjectsRegexes(ImmutableList.of("refs/*"))
+            .setMatchProjectsRegexes(ImmutableList.of("refs/heads/*"))
+            .build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    ContributorAgreement autoValue =
+        ContributorAgreement.builder("name")
+            .setAccepted(
+                ImmutableList.of(
+                    PermissionRule.create(GroupReference.create("accepted1")),
+                    PermissionRule.create(GroupReference.create("accepted2"))))
+            .build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializerTest.java
new file mode 100644
index 0000000..f366337
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializerTest.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.GroupReferenceSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.GroupReferenceSerializer.serialize;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import org.junit.Test;
+
+public class GroupReferenceSerializerTest {
+  static final GroupReference ALL_VALUES_SET =
+      GroupReference.create(AccountGroup.uuid("uuid"), "name");
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    GroupReference groupReferenceAutoValue = GroupReference.create("name");
+    assertThat(deserialize(serialize(groupReferenceAutoValue))).isEqualTo(groupReferenceAutoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
new file mode 100644
index 0000000..64e64a6
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.LabelTypeSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.LabelTypeSerializer.serialize;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import org.junit.Test;
+
+public class LabelTypeSerializerTest {
+  static final LabelType ALL_VALUES_SET =
+      LabelType.builder(
+              "name",
+              ImmutableList.of(
+                  LabelValue.create((short) 0, "no vote"),
+                  LabelValue.create((short) 1, "approved")))
+          .setCanOverride(true)
+          .setAllowPostSubmit(true)
+          .setIgnoreSelfApproval(true)
+          .setRefPatterns(ImmutableList.of("refs/heads/*", "refs/tags/*"))
+          .setDefaultValue((short) 1)
+          .setCopyAnyScore(true)
+          .setCopyMaxScore(true)
+          .setCopyMinScore(true)
+          .setCopyAllScoresOnMergeFirstParentUpdate(true)
+          .setCopyAllScoresOnTrivialRebase(true)
+          .setCopyAllScoresIfNoCodeChange(true)
+          .setCopyAllScoresIfNoChange(true)
+          .setCopyValues(ImmutableList.of((short) 0, (short) 1))
+          .setMaxNegative((short) -1)
+          .setMaxPositive((short) 1)
+          .setCanOverride(true)
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    LabelType autoValue = ALL_VALUES_SET.toBuilder().setRefPatterns(null).build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelValueSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelValueSerializerTest.java
new file mode 100644
index 0000000..7e3abbd
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelValueSerializerTest.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.LabelValueSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.LabelValueSerializer.serialize;
+
+import com.google.gerrit.entities.LabelValue;
+import org.junit.Test;
+
+public class LabelValueSerializerTest {
+  @Test
+  public void roundTrip() {
+    LabelValue autoValue = LabelValue.create((short) 123, "Approved!");
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializerTest.java
new file mode 100644
index 0000000..5052dfc
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializerTest.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.NotifyConfigSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.NotifyConfigSerializer.serialize;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.NotifyConfig;
+import org.junit.Test;
+
+public class NotifyConfigSerializerTest {
+  static final NotifyConfig ALL_VALUES_SET =
+      NotifyConfig.builder()
+          .setName("foo-bar")
+          .addAddress(Address.create("address@example.com"))
+          .addGroup(GroupReference.create("group-uuid"))
+          .setHeader(NotifyConfig.Header.CC)
+          .setFilter("filter")
+          .setNotify(ImmutableSet.of(NotifyConfig.NotifyType.ALL_COMMENTS))
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    NotifyConfig autoValue = NotifyConfig.builder().setName("foo-bar").build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializerTest.java
new file mode 100644
index 0000000..05a6121
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializerTest.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.PermissionRuleSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.PermissionRuleSerializer.serialize;
+
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PermissionRule;
+import org.junit.Test;
+
+public class PermissionRuleSerializerTest {
+  @Test
+  public void roundTrip() {
+    PermissionRule permissionRuleAutoValue =
+        PermissionRule.builder(GroupReference.create("name"))
+            .setAction(PermissionRule.Action.BATCH)
+            .setForce(true)
+            .setMax(321)
+            .setMin(123)
+            .build();
+    assertThat(deserialize(serialize(permissionRuleAutoValue))).isEqualTo(permissionRuleAutoValue);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    PermissionRule permissionRuleAutoValue = PermissionRule.create(GroupReference.create("name"));
+    assertThat(deserialize(serialize(permissionRuleAutoValue))).isEqualTo(permissionRuleAutoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionSerializerTest.java
new file mode 100644
index 0000000..81ef787
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionSerializerTest.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.PermissionSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.PermissionSerializer.serialize;
+
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import org.junit.Test;
+
+public class PermissionSerializerTest {
+  static final Permission ALL_VALUES_SET =
+      Permission.builder(Permission.ABANDON)
+          .setExclusiveGroup(true)
+          .add(PermissionRule.builder(GroupReference.create("group")))
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    Permission permission = Permission.builder(Permission.ABANDON).build();
+    assertThat(deserialize(serialize(permission))).isEqualTo(permission);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
new file mode 100644
index 0000000..29fd5ed
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.ProjectSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.ProjectSerializer.serialize;
+
+import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.client.SubmitType;
+import org.junit.Test;
+
+public class ProjectSerializerTest {
+  static final Project ALL_VALUES_SET =
+      Project.builder(Project.nameKey("test"))
+          .setDescription("desc")
+          .setSubmitType(SubmitType.FAST_FORWARD_ONLY)
+          .setState(ProjectState.HIDDEN)
+          .setParent(Project.nameKey("parent"))
+          .setMaxObjectSizeLimit("11K")
+          .setDefaultDashboard("dashboard1")
+          .setLocalDefaultDashboard("dashboard2")
+          .setConfigRefState("1337")
+          .setBooleanConfig(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.TRUE)
+          .setBooleanConfig(
+              BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+              InheritableBoolean.INHERIT)
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+
+  @Test
+  public void roundTripWithMinimalValues() {
+    Project projectAutoValue =
+        Project.builder(Project.nameKey("test"))
+            .setSubmitType(SubmitType.FAST_FORWARD_ONLY)
+            .setState(ProjectState.HIDDEN)
+            .build();
+
+    assertThat(deserialize(serialize(projectAutoValue))).isEqualTo(projectAutoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
new file mode 100644
index 0000000..3a51b70
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.StoredCommentLinkInfoSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.StoredCommentLinkInfoSerializer.serialize;
+
+import com.google.gerrit.entities.StoredCommentLinkInfo;
+import org.junit.Test;
+
+public class StoredCommentLinkInfoSerializerTest {
+  static final StoredCommentLinkInfo HTML_ONLY =
+      StoredCommentLinkInfo.builder("name")
+          .setEnabled(true)
+          .setHtml("<p>html")
+          .setMatch("*")
+          .build();
+
+  @Test
+  public void htmlOnly_roundTrip() {
+    assertThat(deserialize(serialize(HTML_ONLY))).isEqualTo(HTML_ONLY);
+  }
+
+  @Test
+  public void linkOnly_roundTrip() {
+    StoredCommentLinkInfo autoValue =
+        StoredCommentLinkInfo.builder("name")
+            .setEnabled(true)
+            .setLink("<p>html")
+            .setMatch("*")
+            .build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+
+  @Test
+  public void overrideOnly_roundTrip() {
+    StoredCommentLinkInfo autoValue =
+        StoredCommentLinkInfo.builder("name")
+            .setEnabled(true)
+            .setOverrideOnly(true)
+            .setLink("<p>html")
+            .setMatch("*")
+            .build();
+    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializerTest.java
new file mode 100644
index 0000000..1648eca
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializerTest.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.SubscribeSectionSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.SubscribeSectionSerializer.serialize;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubscribeSection;
+import org.junit.Test;
+
+public class SubscribeSectionSerializerTest {
+  static final SubscribeSection ALL_VALUES_SET =
+      SubscribeSection.builder(Project.nameKey("project"))
+          .addMultiMatchRefSpec("multi")
+          .addMultiMatchRefSpec("multi2")
+          .addMatchingRefSpec("matching1")
+          .addMatchingRefSpec("matching2")
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index c259e60..683f5a6 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.change;
 
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
-import static com.google.gerrit.common.data.Permission.forLabel;
+import static com.google.gerrit.entities.Permission.forLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
@@ -23,11 +23,11 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -98,14 +98,19 @@
 
   private void configureProject() throws Exception {
     ProjectConfig pc = loadAllProjects();
-    for (AccessSection sec : pc.getAccessSections()) {
-      for (String label : pc.getLabelSections().keySet()) {
-        sec.removePermission(forLabel(label));
-      }
+
+    for (AccessSection sec : ImmutableList.copyOf(pc.getAccessSections())) {
+      pc.upsertAccessSection(
+          sec.getName(),
+          updatedSection -> {
+            for (String label : pc.getLabelSections().keySet()) {
+              updatedSection.removePermission(forLabel(label));
+            }
+          });
     }
     LabelType lt =
         label("Verified", value(1, "Verified"), value(0, "No score"), value(-1, "Fails"));
-    pc.getLabelSections().put(lt.getName(), lt);
+    pc.upsertLabelType(lt);
     save(pc);
   }
 
diff --git a/javatests/com/google/gerrit/server/events/BUILD b/javatests/com/google/gerrit/server/events/BUILD
index eed83c8..be983a9 100644
--- a/javatests/com/google/gerrit/server/events/BUILD
+++ b/javatests/com/google/gerrit/server/events/BUILD
@@ -6,6 +6,7 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/data",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:gson",
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index c749b77..20fe387 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -17,9 +17,9 @@
 import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
 
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.server.config.AllUsersName;
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index b7fe23d..c1f3615 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -25,9 +25,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index df97e88..3b7beb9 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -25,9 +25,9 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.testing.GroupReferenceSubject;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
@@ -393,8 +393,8 @@
 
     ImmutableList<GroupReference> allGroups = GroupNameNotes.loadAllGroups(repo);
 
-    GroupReference group1 = new GroupReference(groupUuid1, groupName1.get());
-    GroupReference group2 = new GroupReference(groupUuid2, groupName2.get());
+    GroupReference group1 = GroupReference.create(groupUuid1, groupName1.get());
+    GroupReference group2 = GroupReference.create(groupUuid2, groupName2.get());
     assertThat(allGroups).containsExactly(group1, group2);
   }
 
@@ -406,8 +406,8 @@
 
     ImmutableList<GroupReference> allGroups = GroupNameNotes.loadAllGroups(repo);
 
-    GroupReference group1 = new GroupReference(groupUuid, groupName.get());
-    GroupReference group2 = new GroupReference(groupUuid, anotherGroupName.get());
+    GroupReference group1 = GroupReference.create(groupUuid, groupName.get());
+    GroupReference group2 = GroupReference.create(groupUuid, anotherGroupName.get());
     assertThat(allGroups).containsExactly(group1, group2);
   }
 
@@ -498,14 +498,14 @@
   @Test
   public void updateGroupNamesRejectsNonOneToOneGroupReferences() throws Exception {
     assertIllegalArgument(
-        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
-        new GroupReference(AccountGroup.uuid("uuid1"), "name2"));
+        GroupReference.create(AccountGroup.uuid("uuid1"), "name1"),
+        GroupReference.create(AccountGroup.uuid("uuid1"), "name2"));
     assertIllegalArgument(
-        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
-        new GroupReference(AccountGroup.uuid("uuid2"), "name1"));
+        GroupReference.create(AccountGroup.uuid("uuid1"), "name1"),
+        GroupReference.create(AccountGroup.uuid("uuid2"), "name1"));
     assertIllegalArgument(
-        new GroupReference(AccountGroup.uuid("uuid1"), "name1"),
-        new GroupReference(AccountGroup.uuid("uuid1"), "name1"));
+        GroupReference.create(AccountGroup.uuid("uuid1"), "name1"),
+        GroupReference.create(AccountGroup.uuid("uuid1"), "name1"));
   }
 
   @Test
@@ -554,7 +554,7 @@
 
   private GroupReference newGroup(String name) {
     int id = idCounter.incrementAndGet();
-    return new GroupReference(AccountGroup.uuid(name + "-" + id), name);
+    return GroupReference.create(AccountGroup.uuid(name + "-" + id), name);
   }
 
   private static PersonIdent newPersonIdent() {
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 47877b6..40a6978 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -22,10 +22,10 @@
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Table;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.util.time.TimeUtil;
diff --git a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
index 805c542..d8e29f9 100644
--- a/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
+++ b/javatests/com/google/gerrit/server/mail/AutoReplyMailFilterTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.mail.MailMessage;
 import java.time.Instant;
 import org.junit.Test;
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java b/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
deleted file mode 100644
index f4fbc78..0000000
--- a/javatests/com/google/gerrit/server/mail/send/CommentFormatterTest.java
+++ /dev/null
@@ -1,450 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.LIST;
-import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PARAGRAPH;
-import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PRE_FORMATTED;
-import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.QUOTE;
-
-import java.util.List;
-import org.junit.Test;
-
-public class CommentFormatterTest {
-  private void assertBlock(
-      List<CommentFormatter.Block> list, int index, CommentFormatter.BlockType type, String text) {
-    CommentFormatter.Block block = list.get(index);
-    assertThat(block.type).isEqualTo(type);
-    assertThat(block.text).isEqualTo(text);
-    assertThat(block.items).isNull();
-    assertThat(block.quotedBlocks).isNull();
-  }
-
-  private void assertListBlock(
-      List<CommentFormatter.Block> list, int index, int itemIndex, String text) {
-    CommentFormatter.Block block = list.get(index);
-    assertThat(block.type).isEqualTo(LIST);
-    assertThat(block.items.get(itemIndex)).isEqualTo(text);
-    assertThat(block.text).isNull();
-    assertThat(block.quotedBlocks).isNull();
-  }
-
-  private void assertQuoteBlock(List<CommentFormatter.Block> list, int index, int size) {
-    CommentFormatter.Block block = list.get(index);
-    assertThat(block.type).isEqualTo(QUOTE);
-    assertThat(block.items).isNull();
-    assertThat(block.text).isNull();
-    assertThat(block.quotedBlocks).hasSize(size);
-  }
-
-  @Test
-  public void parseNullAsEmpty() {
-    assertThat(CommentFormatter.parse(null)).isEmpty();
-  }
-
-  @Test
-  public void parseEmpty() {
-    assertThat(CommentFormatter.parse("")).isEmpty();
-  }
-
-  @Test
-  public void parseSimple() {
-    String comment = "Para1";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertBlock(result, 0, PARAGRAPH, comment);
-  }
-
-  @Test
-  public void parseMultilinePara() {
-    String comment = "Para 1\nStill para 1";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertBlock(result, 0, PARAGRAPH, comment);
-  }
-
-  @Test
-  public void parseParaBreak() {
-    String comment = "Para 1\n\nPara 2\n\nPara 3";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(3);
-    assertBlock(result, 0, PARAGRAPH, "Para 1");
-    assertBlock(result, 1, PARAGRAPH, "Para 2");
-    assertBlock(result, 2, PARAGRAPH, "Para 3");
-  }
-
-  @Test
-  public void parseQuote() {
-    String comment = "> Quote text";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertQuoteBlock(result, 0, 1);
-    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote text");
-  }
-
-  @Test
-  public void parseExcludesEmpty() {
-    String comment = "Para 1\n\n\n\nPara 2";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertBlock(result, 0, PARAGRAPH, "Para 1");
-    assertBlock(result, 1, PARAGRAPH, "Para 2");
-  }
-
-  @Test
-  public void parseQuoteLeadSpace() {
-    String comment = " > Quote text";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertQuoteBlock(result, 0, 1);
-    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote text");
-  }
-
-  @Test
-  public void parseMultiLineQuote() {
-    String comment = "> Quote line 1\n> Quote line 2\n > Quote line 3\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertQuoteBlock(result, 0, 1);
-    assertBlock(
-        result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote line 1\nQuote line 2\nQuote line 3\n");
-  }
-
-  @Test
-  public void parsePre() {
-    String comment = "    Four space indent.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertBlock(result, 0, PRE_FORMATTED, comment);
-  }
-
-  @Test
-  public void parseOneSpacePre() {
-    String comment = " One space indent.\n Another line.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertBlock(result, 0, PRE_FORMATTED, comment);
-  }
-
-  @Test
-  public void parseTabPre() {
-    String comment = "\tOne tab indent.\n\tAnother line.\n  Yet another!";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertBlock(result, 0, PRE_FORMATTED, comment);
-  }
-
-  @Test
-  public void parseIntermediateLeadingWhitespacePre() {
-    String comment = "No indent.\n\tNonzero indent.\nNo indent again.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertBlock(result, 0, PRE_FORMATTED, comment);
-  }
-
-  @Test
-  public void parseStarList() {
-    String comment = "* Item 1\n* Item 2\n* Item 3";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertListBlock(result, 0, 0, "Item 1");
-    assertListBlock(result, 0, 1, "Item 2");
-    assertListBlock(result, 0, 2, "Item 3");
-  }
-
-  @Test
-  public void parseDashList() {
-    String comment = "- Item 1\n- Item 2\n- Item 3";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertListBlock(result, 0, 0, "Item 1");
-    assertListBlock(result, 0, 1, "Item 2");
-    assertListBlock(result, 0, 2, "Item 3");
-  }
-
-  @Test
-  public void parseMixedList() {
-    String comment = "- Item 1\n* Item 2\n- Item 3\n* Item 4";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertListBlock(result, 0, 0, "Item 1");
-    assertListBlock(result, 0, 1, "Item 2");
-    assertListBlock(result, 0, 2, "Item 3");
-    assertListBlock(result, 0, 3, "Item 4");
-  }
-
-  @Test
-  public void parseMixedBlockTypes() {
-    String comment =
-        "Paragraph\nacross\na\nfew\nlines."
-            + "\n\n"
-            + "> Quote\n> across\n> not many lines."
-            + "\n\n"
-            + "Another paragraph"
-            + "\n\n"
-            + "* Series\n* of\n* list\n* items"
-            + "\n\n"
-            + "Yet another paragraph"
-            + "\n\n"
-            + "\tPreformatted text."
-            + "\n\n"
-            + "Parting words.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(7);
-    assertBlock(result, 0, PARAGRAPH, "Paragraph\nacross\na\nfew\nlines.");
-    assertQuoteBlock(result, 1, 1);
-    assertBlock(result.get(1).quotedBlocks, 0, PARAGRAPH, "Quote\nacross\nnot many lines.");
-    assertBlock(result, 2, PARAGRAPH, "Another paragraph");
-    assertListBlock(result, 3, 0, "Series");
-    assertListBlock(result, 3, 1, "of");
-    assertListBlock(result, 3, 2, "list");
-    assertListBlock(result, 3, 3, "items");
-    assertBlock(result, 4, PARAGRAPH, "Yet another paragraph");
-    assertBlock(result, 5, PRE_FORMATTED, "\tPreformatted text.");
-    assertBlock(result, 6, PARAGRAPH, "Parting words.");
-  }
-
-  @Test
-  public void bulletList1() {
-    String comment = "A\n\n* line 1\n* 2nd line";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertListBlock(result, 1, 0, "line 1");
-    assertListBlock(result, 1, 1, "2nd line");
-  }
-
-  @Test
-  public void bulletList2() {
-    String comment = "A\n\n* line 1\n* 2nd line\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(3);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertListBlock(result, 1, 0, "line 1");
-    assertListBlock(result, 1, 1, "2nd line");
-    assertBlock(result, 2, PARAGRAPH, "B");
-  }
-
-  @Test
-  public void bulletList3() {
-    String comment = "* line 1\n* 2nd line\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertListBlock(result, 0, 0, "line 1");
-    assertListBlock(result, 0, 1, "2nd line");
-    assertBlock(result, 1, PARAGRAPH, "B");
-  }
-
-  @Test
-  public void bulletList4() {
-    String comment =
-        "To see this bug, you have to:\n" //
-            + "* Be on IMAP or EAS (not on POP)\n" //
-            + "* Be very unlucky\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertBlock(result, 0, PARAGRAPH, "To see this bug, you have to:");
-    assertListBlock(result, 1, 0, "Be on IMAP or EAS (not on POP)");
-    assertListBlock(result, 1, 1, "Be very unlucky");
-  }
-
-  @Test
-  public void bulletList5() {
-    String comment =
-        "To see this bug,\n" //
-            + "you have to:\n" //
-            + "* Be on IMAP or EAS (not on POP)\n" //
-            + "* Be very unlucky\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertBlock(result, 0, PARAGRAPH, "To see this bug, you have to:");
-    assertListBlock(result, 1, 0, "Be on IMAP or EAS (not on POP)");
-    assertListBlock(result, 1, 1, "Be very unlucky");
-  }
-
-  @Test
-  public void dashList1() {
-    String comment = "A\n\n- line 1\n- 2nd line";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertListBlock(result, 1, 0, "line 1");
-    assertListBlock(result, 1, 1, "2nd line");
-  }
-
-  @Test
-  public void dashList2() {
-    String comment = "A\n\n- line 1\n- 2nd line\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(3);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertListBlock(result, 1, 0, "line 1");
-    assertListBlock(result, 1, 1, "2nd line");
-    assertBlock(result, 2, PARAGRAPH, "B");
-  }
-
-  @Test
-  public void dashList3() {
-    String comment = "- line 1\n- 2nd line\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertListBlock(result, 0, 0, "line 1");
-    assertListBlock(result, 0, 1, "2nd line");
-    assertBlock(result, 1, PARAGRAPH, "B");
-  }
-
-  @Test
-  public void preformat1() {
-    String comment = "A\n\n  This is pre\n  formatted";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
-  }
-
-  @Test
-  public void preformat2() {
-    String comment = "A\n\n  This is pre\n  formatted\n\nbut this is not";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(3);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
-    assertBlock(result, 2, PARAGRAPH, "but this is not");
-  }
-
-  @Test
-  public void preformat3() {
-    String comment = "A\n\n  Q\n    <R>\n  S\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(3);
-    assertBlock(result, 0, PARAGRAPH, "A");
-    assertBlock(result, 1, PRE_FORMATTED, "  Q\n    <R>\n  S");
-    assertBlock(result, 2, PARAGRAPH, "B");
-  }
-
-  @Test
-  public void preformat4() {
-    String comment = "  Q\n    <R>\n  S\n\nB";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertBlock(result, 0, PRE_FORMATTED, "  Q\n    <R>\n  S");
-    assertBlock(result, 1, PARAGRAPH, "B");
-  }
-
-  @Test
-  public void quote1() {
-    String comment = "> I'm happy\n > with quotes!\n\nSee above.";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertQuoteBlock(result, 0, 1);
-    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "I'm happy\nwith quotes!");
-    assertBlock(result, 1, PARAGRAPH, "See above.");
-  }
-
-  @Test
-  public void quote2() {
-    String comment = "See this said:\n\n > a quoted\n > string block\n\nOK?";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(3);
-    assertBlock(result, 0, PARAGRAPH, "See this said:");
-    assertQuoteBlock(result, 1, 1);
-    assertBlock(result.get(1).quotedBlocks, 0, PARAGRAPH, "a quoted\nstring block");
-    assertBlock(result, 2, PARAGRAPH, "OK?");
-  }
-
-  @Test
-  public void nestedQuotes1() {
-    String comment = " > > prior\n > \n > next\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(1);
-    assertQuoteBlock(result, 0, 2);
-    assertQuoteBlock(result.get(0).quotedBlocks, 0, 1);
-    assertBlock(result.get(0).quotedBlocks.get(0).quotedBlocks, 0, PARAGRAPH, "prior");
-    assertBlock(result.get(0).quotedBlocks, 1, PARAGRAPH, "next\n");
-  }
-
-  @Test
-  public void largeMixedQuote() {
-    String comment =
-        "> > Paragraph 1.\n"
-            + "> > \n"
-            + "> > > Paragraph 2.\n"
-            + "> > \n"
-            + "> > Paragraph 3.\n"
-            + "> > \n"
-            + "> >    pre line 1;\n"
-            + "> >    pre line 2;\n"
-            + "> > \n"
-            + "> > Paragraph 4.\n"
-            + "> > \n"
-            + "> > * List item 1.\n"
-            + "> > * List item 2.\n"
-            + "> > \n"
-            + "> > Paragraph 5.\n"
-            + "> \n"
-            + "> Paragraph 6.\n"
-            + "\n"
-            + "Paragraph 7.\n";
-    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
-
-    assertThat(result).hasSize(2);
-    assertQuoteBlock(result, 0, 2);
-
-    assertQuoteBlock(result.get(0).quotedBlocks, 0, 7);
-    List<CommentFormatter.Block> bigQuote = result.get(0).quotedBlocks.get(0).quotedBlocks;
-    assertBlock(bigQuote, 0, PARAGRAPH, "Paragraph 1.");
-    assertQuoteBlock(bigQuote, 1, 1);
-    assertBlock(bigQuote.get(1).quotedBlocks, 0, PARAGRAPH, "Paragraph 2.");
-    assertBlock(bigQuote, 2, PARAGRAPH, "Paragraph 3.");
-    assertBlock(bigQuote, 3, PRE_FORMATTED, "   pre line 1;\n   pre line 2;");
-    assertBlock(bigQuote, 4, PARAGRAPH, "Paragraph 4.");
-    assertListBlock(bigQuote, 5, 0, "List item 1.");
-    assertListBlock(bigQuote, 5, 1, "List item 2.");
-    assertBlock(bigQuote, 6, PARAGRAPH, "Paragraph 5.");
-    assertBlock(result.get(0).quotedBlocks, 1, PARAGRAPH, "Paragraph 6.");
-    assertBlock(result, 1, PARAGRAPH, "Paragraph 7.\n");
-  }
-}
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index a383d56..f10a281 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -22,7 +22,7 @@
 import static org.mockito.Mockito.when;
 
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.mail.Address;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -146,9 +146,9 @@
   @Test
   public void USERNoAllowDomain() {
     setFrom("USER");
-    setDomains(Arrays.asList("example.com"));
+    setDomains(Arrays.asList("example.net"));
     final String name = "A U. Thor";
-    final String email = "a.u.thor@test.com";
+    final String email = "a.u.thor@example.com";
     final Account.Id user = user(name, email);
 
     final Address r = create().from(user);
@@ -161,10 +161,10 @@
   @Test
   public void USERAllowDomainTwice() {
     setFrom("USER");
+    setDomains(Arrays.asList("example.net"));
     setDomains(Arrays.asList("example.com"));
-    setDomains(Arrays.asList("test.com"));
     final String name = "A U. Thor";
-    final String email = "a.u.thor@test.com";
+    final String email = "a.u.thor@example.com";
     final Account.Id user = user(name, email);
 
     final Address r = create().from(user);
@@ -177,10 +177,10 @@
   @Test
   public void USERAllowDomainTwiceReverse() {
     setFrom("USER");
-    setDomains(Arrays.asList("test.com"));
     setDomains(Arrays.asList("example.com"));
+    setDomains(Arrays.asList("example.net"));
     final String name = "A U. Thor";
-    final String email = "a.u.thor@test.com";
+    final String email = "a.u.thor@example.com";
     final Account.Id user = user(name, email);
 
     final Address r = create().from(user);
@@ -193,9 +193,9 @@
   @Test
   public void USERAllowTwoDomains() {
     setFrom("USER");
-    setDomains(Arrays.asList("example.com", "test.com"));
+    setDomains(Arrays.asList("example.com", "example.net"));
     final String name = "A U. Thor";
-    final String email = "a.u.thor@test.com";
+    final String email = "a.u.thor@example.com";
     final Account.Id user = user(name, email);
 
     final Address r = create().from(user);
diff --git a/javatests/com/google/gerrit/server/mail/send/HumanCommentFormatterTest.java b/javatests/com/google/gerrit/server/mail/send/HumanCommentFormatterTest.java
new file mode 100644
index 0000000..46ea8b2
--- /dev/null
+++ b/javatests/com/google/gerrit/server/mail/send/HumanCommentFormatterTest.java
@@ -0,0 +1,450 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.LIST;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PARAGRAPH;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PRE_FORMATTED;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.QUOTE;
+
+import java.util.List;
+import org.junit.Test;
+
+public class HumanCommentFormatterTest {
+  private void assertBlock(
+      List<CommentFormatter.Block> list, int index, CommentFormatter.BlockType type, String text) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(type);
+    assertThat(block.text).isEqualTo(text);
+    assertThat(block.items).isNull();
+    assertThat(block.quotedBlocks).isNull();
+  }
+
+  private void assertListBlock(
+      List<CommentFormatter.Block> list, int index, int itemIndex, String text) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(LIST);
+    assertThat(block.items.get(itemIndex)).isEqualTo(text);
+    assertThat(block.text).isNull();
+    assertThat(block.quotedBlocks).isNull();
+  }
+
+  private void assertQuoteBlock(List<CommentFormatter.Block> list, int index, int size) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(QUOTE);
+    assertThat(block.items).isNull();
+    assertThat(block.text).isNull();
+    assertThat(block.quotedBlocks).hasSize(size);
+  }
+
+  @Test
+  public void parseNullAsEmpty() {
+    assertThat(CommentFormatter.parse(null)).isEmpty();
+  }
+
+  @Test
+  public void parseEmpty() {
+    assertThat(CommentFormatter.parse("")).isEmpty();
+  }
+
+  @Test
+  public void parseSimple() {
+    String comment = "Para1";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PARAGRAPH, comment);
+  }
+
+  @Test
+  public void parseMultilinePara() {
+    String comment = "Para 1\nStill para 1";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PARAGRAPH, comment);
+  }
+
+  @Test
+  public void parseParaBreak() {
+    String comment = "Para 1\n\nPara 2\n\nPara 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "Para 1");
+    assertBlock(result, 1, PARAGRAPH, "Para 2");
+    assertBlock(result, 2, PARAGRAPH, "Para 3");
+  }
+
+  @Test
+  public void parseQuote() {
+    String comment = "> Quote text";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote text");
+  }
+
+  @Test
+  public void parseExcludesEmpty() {
+    String comment = "Para 1\n\n\n\nPara 2";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "Para 1");
+    assertBlock(result, 1, PARAGRAPH, "Para 2");
+  }
+
+  @Test
+  public void parseQuoteLeadSpace() {
+    String comment = " > Quote text";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote text");
+  }
+
+  @Test
+  public void parseMultiLineQuote() {
+    String comment = "> Quote line 1\n> Quote line 2\n > Quote line 3\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(
+        result.get(0).quotedBlocks, 0, PARAGRAPH, "Quote line 1\nQuote line 2\nQuote line 3\n");
+  }
+
+  @Test
+  public void parsePre() {
+    String comment = "    Four space indent.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void parseOneSpacePre() {
+    String comment = " One space indent.\n Another line.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void parseTabPre() {
+    String comment = "\tOne tab indent.\n\tAnother line.\n  Yet another!";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void parseIntermediateLeadingWhitespacePre() {
+    String comment = "No indent.\n\tNonzero indent.\nNo indent again.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void parseStarList() {
+    String comment = "* Item 1\n* Item 2\n* Item 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+  }
+
+  @Test
+  public void parseDashList() {
+    String comment = "- Item 1\n- Item 2\n- Item 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+  }
+
+  @Test
+  public void parseMixedList() {
+    String comment = "- Item 1\n* Item 2\n- Item 3\n* Item 4";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+    assertListBlock(result, 0, 3, "Item 4");
+  }
+
+  @Test
+  public void parseMixedBlockTypes() {
+    String comment =
+        "Paragraph\nacross\na\nfew\nlines."
+            + "\n\n"
+            + "> Quote\n> across\n> not many lines."
+            + "\n\n"
+            + "Another paragraph"
+            + "\n\n"
+            + "* Series\n* of\n* list\n* items"
+            + "\n\n"
+            + "Yet another paragraph"
+            + "\n\n"
+            + "\tPreformatted text."
+            + "\n\n"
+            + "Parting words.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(7);
+    assertBlock(result, 0, PARAGRAPH, "Paragraph\nacross\na\nfew\nlines.");
+    assertQuoteBlock(result, 1, 1);
+    assertBlock(result.get(1).quotedBlocks, 0, PARAGRAPH, "Quote\nacross\nnot many lines.");
+    assertBlock(result, 2, PARAGRAPH, "Another paragraph");
+    assertListBlock(result, 3, 0, "Series");
+    assertListBlock(result, 3, 1, "of");
+    assertListBlock(result, 3, 2, "list");
+    assertListBlock(result, 3, 3, "items");
+    assertBlock(result, 4, PARAGRAPH, "Yet another paragraph");
+    assertBlock(result, 5, PRE_FORMATTED, "\tPreformatted text.");
+    assertBlock(result, 6, PARAGRAPH, "Parting words.");
+  }
+
+  @Test
+  public void bulletList1() {
+    String comment = "A\n\n* line 1\n* 2nd line";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+  }
+
+  @Test
+  public void bulletList2() {
+    String comment = "A\n\n* line 1\n* 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void bulletList3() {
+    String comment = "* line 1\n* 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertListBlock(result, 0, 0, "line 1");
+    assertListBlock(result, 0, 1, "2nd line");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void bulletList4() {
+    String comment =
+        "To see this bug, you have to:\n" //
+            + "* Be on IMAP or EAS (not on POP)\n" //
+            + "* Be very unlucky\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "To see this bug, you have to:");
+    assertListBlock(result, 1, 0, "Be on IMAP or EAS (not on POP)");
+    assertListBlock(result, 1, 1, "Be very unlucky");
+  }
+
+  @Test
+  public void bulletList5() {
+    String comment =
+        "To see this bug,\n" //
+            + "you have to:\n" //
+            + "* Be on IMAP or EAS (not on POP)\n" //
+            + "* Be very unlucky\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "To see this bug, you have to:");
+    assertListBlock(result, 1, 0, "Be on IMAP or EAS (not on POP)");
+    assertListBlock(result, 1, 1, "Be very unlucky");
+  }
+
+  @Test
+  public void dashList1() {
+    String comment = "A\n\n- line 1\n- 2nd line";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+  }
+
+  @Test
+  public void dashList2() {
+    String comment = "A\n\n- line 1\n- 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void dashList3() {
+    String comment = "- line 1\n- 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertListBlock(result, 0, 0, "line 1");
+    assertListBlock(result, 0, 1, "2nd line");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void preformat1() {
+    String comment = "A\n\n  This is pre\n  formatted";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
+  }
+
+  @Test
+  public void preformat2() {
+    String comment = "A\n\n  This is pre\n  formatted\n\nbut this is not";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
+    assertBlock(result, 2, PARAGRAPH, "but this is not");
+  }
+
+  @Test
+  public void preformat3() {
+    String comment = "A\n\n  Q\n    <R>\n  S\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  Q\n    <R>\n  S");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void preformat4() {
+    String comment = "  Q\n    <R>\n  S\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PRE_FORMATTED, "  Q\n    <R>\n  S");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void quote1() {
+    String comment = "> I'm happy\n > with quotes!\n\nSee above.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertQuoteBlock(result, 0, 1);
+    assertBlock(result.get(0).quotedBlocks, 0, PARAGRAPH, "I'm happy\nwith quotes!");
+    assertBlock(result, 1, PARAGRAPH, "See above.");
+  }
+
+  @Test
+  public void quote2() {
+    String comment = "See this said:\n\n > a quoted\n > string block\n\nOK?";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "See this said:");
+    assertQuoteBlock(result, 1, 1);
+    assertBlock(result.get(1).quotedBlocks, 0, PARAGRAPH, "a quoted\nstring block");
+    assertBlock(result, 2, PARAGRAPH, "OK?");
+  }
+
+  @Test
+  public void nestedQuotes1() {
+    String comment = " > > prior\n > \n > next\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertQuoteBlock(result, 0, 2);
+    assertQuoteBlock(result.get(0).quotedBlocks, 0, 1);
+    assertBlock(result.get(0).quotedBlocks.get(0).quotedBlocks, 0, PARAGRAPH, "prior");
+    assertBlock(result.get(0).quotedBlocks, 1, PARAGRAPH, "next\n");
+  }
+
+  @Test
+  public void largeMixedQuote() {
+    String comment =
+        "> > Paragraph 1.\n"
+            + "> > \n"
+            + "> > > Paragraph 2.\n"
+            + "> > \n"
+            + "> > Paragraph 3.\n"
+            + "> > \n"
+            + "> >    pre line 1;\n"
+            + "> >    pre line 2;\n"
+            + "> > \n"
+            + "> > Paragraph 4.\n"
+            + "> > \n"
+            + "> > * List item 1.\n"
+            + "> > * List item 2.\n"
+            + "> > \n"
+            + "> > Paragraph 5.\n"
+            + "> \n"
+            + "> Paragraph 6.\n"
+            + "\n"
+            + "Paragraph 7.\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertQuoteBlock(result, 0, 2);
+
+    assertQuoteBlock(result.get(0).quotedBlocks, 0, 7);
+    List<CommentFormatter.Block> bigQuote = result.get(0).quotedBlocks.get(0).quotedBlocks;
+    assertBlock(bigQuote, 0, PARAGRAPH, "Paragraph 1.");
+    assertQuoteBlock(bigQuote, 1, 1);
+    assertBlock(bigQuote.get(1).quotedBlocks, 0, PARAGRAPH, "Paragraph 2.");
+    assertBlock(bigQuote, 2, PARAGRAPH, "Paragraph 3.");
+    assertBlock(bigQuote, 3, PRE_FORMATTED, "   pre line 1;\n   pre line 2;");
+    assertBlock(bigQuote, 4, PARAGRAPH, "Paragraph 4.");
+    assertListBlock(bigQuote, 5, 0, "List item 1.");
+    assertListBlock(bigQuote, 5, 1, "List item 2.");
+    assertBlock(bigQuote, 6, PARAGRAPH, "Paragraph 5.");
+    assertBlock(result.get(0).quotedBlocks, 1, PARAGRAPH, "Paragraph 6.");
+    assertBlock(result, 1, PARAGRAPH, "Paragraph 7.\n");
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 7192c55..195d213 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -18,13 +18,14 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
@@ -37,6 +38,7 @@
 import com.google.gerrit.server.account.FakeRealm;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.RobotClassifier;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
@@ -164,6 +166,7 @@
                 bind(ExecutorService.class)
                     .annotatedWith(FanOutExecutor.class)
                     .toInstance(assertableFanOutExecutor);
+                bind(RobotClassifier.class).to(RobotClassifier.NoOp.class);
               }
             });
 
@@ -244,7 +247,7 @@
     return label;
   }
 
-  protected Comment newComment(
+  protected HumanComment newComment(
       PatchSet.Id psId,
       String filename,
       String UUID,
@@ -257,8 +260,8 @@
       short side,
       ObjectId commitId,
       boolean unresolved) {
-    Comment c =
-        new Comment(
+    HumanComment c =
+        new HumanComment(
             new Comment.Key(UUID, filename, psId.get()),
             commenter.getAccountId(),
             t,
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 8ffcc8b..5bfe97c 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -519,7 +519,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil.newIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
         false);
   }
 
@@ -531,7 +531,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil.newIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
         initWorkInProgress);
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index efbaed6..dd3238f 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -25,20 +25,21 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -714,8 +715,8 @@
 
   @Test
   public void serializePublishedComments() throws Exception {
-    Comment c1 =
-        new Comment(
+    HumanComment c1 =
+        new HumanComment(
             new Comment.Key("uuid1", "file1", 1),
             Account.id(1001),
             new Timestamp(1212L),
@@ -726,8 +727,8 @@
     c1.setCommitId(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
     String c1Json = Serializer.GSON.toJson(c1);
 
-    Comment c2 =
-        new Comment(
+    HumanComment c2 =
+        new HumanComment(
             new Comment.Key("uuid2", "file2", 2),
             Account.id(1002),
             new Timestamp(3434L),
@@ -798,7 +799,7 @@
                 .put("changeMessages", new TypeLiteral<ImmutableList<ChangeMessage>>() {}.getType())
                 .put(
                     "publishedComments",
-                    new TypeLiteral<ImmutableListMultimap<ObjectId, Comment>>() {}.getType())
+                    new TypeLiteral<ImmutableListMultimap<ObjectId, HumanComment>>() {}.getType())
                 .put("updateCount", int.class)
                 .build());
   }
@@ -970,7 +971,7 @@
                 "startChar", int.class,
                 "endLine", int.class,
                 "endChar", int.class));
-    assertThatSerializedClass(Comment.class)
+    assertThatSerializedClass(HumanComment.class)
         .hasFields(
             ImmutableMap.<String, Type>builder()
                 .put("key", Comment.Key.class)
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 964187c..938fffc 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -36,20 +36,20 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.CommentRange;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -123,7 +123,7 @@
     RevCommit commit = tr.commit().message("PS2").create();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.putComment(
-        Comment.Status.PUBLISHED,
+        HumanComment.Status.PUBLISHED,
         newComment(
             c.currentPatchSetId(),
             "a.txt",
@@ -142,7 +142,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<ObjectId, Comment> comments = notes.getComments();
+    ImmutableListMultimap<ObjectId, HumanComment> comments = notes.getHumanComments();
     assertThat(comments).hasSize(1);
     assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(tag);
   }
@@ -185,7 +185,7 @@
     RevCommit commit = tr.commit().message("PS2").create();
     update = newUpdate(c, changeOwner);
     update.putComment(
-        Comment.Status.PUBLISHED,
+        HumanComment.Status.PUBLISHED,
         newComment(
             c.currentPatchSetId(),
             "a.txt",
@@ -216,7 +216,7 @@
     assertThat(approval.tag()).hasValue(integrationTag);
     assertThat(approval.value()).isEqualTo(-1);
 
-    ImmutableListMultimap<ObjectId, Comment> comments = notes.getComments();
+    ImmutableListMultimap<ObjectId, HumanComment> comments = notes.getHumanComments();
     assertThat(comments).hasSize(1);
     assertThat(comments.entries().asList().get(0).getValue().tag).isEqualTo(coverageTag);
 
@@ -704,7 +704,7 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     AttentionSetUpdate attentionSetUpdate =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
-    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -717,12 +717,12 @@
     ChangeUpdate update = newUpdate(c, changeOwner);
     AttentionSetUpdate attentionSetUpdate =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
-    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
     update.commit();
     update = newUpdate(c, changeOwner);
     attentionSetUpdate =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
-    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -739,23 +739,24 @@
     IllegalArgumentException thrown =
         assertThrows(
             IllegalArgumentException.class,
-            () -> update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate)));
+            () -> update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate)));
     assertThat(thrown).hasMessageThat().contains("must not specify timestamp for write");
   }
 
   @Test
-  public void addAttentionStatus_rejectMultiplePerUser() throws Exception {
+  public void addAttentionStatus_rejectIfSameUserTwice() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
     AttentionSetUpdate attentionSetUpdate0 =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test 0");
     AttentionSetUpdate attentionSetUpdate1 =
         AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test 1");
+
     IllegalArgumentException thrown =
         assertThrows(
             IllegalArgumentException.class,
             () ->
-                update.setAttentionSetUpdates(
+                update.addToPlannedAttentionSetUpdates(
                     ImmutableSet.of(attentionSetUpdate0, attentionSetUpdate1)));
     assertThat(thrown)
         .hasMessageThat()
@@ -771,7 +772,8 @@
     AttentionSetUpdate attentionSetUpdate1 =
         AttentionSetUpdate.createForWrite(otherUser.getAccountId(), Operation.ADD, "test");
 
-    update.setAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate0, attentionSetUpdate1));
+    update.addToPlannedAttentionSetUpdates(
+        ImmutableSet.of(attentionSetUpdate0, attentionSetUpdate1));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -1186,7 +1188,7 @@
     update.putApproval("Code-Review", (short) 1);
     update.setChangeMessage("This is a message");
     update.putComment(
-        Comment.Status.PUBLISHED,
+        HumanComment.Status.PUBLISHED,
         newComment(
             c.currentPatchSetId(),
             "a.txt",
@@ -1206,7 +1208,7 @@
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1, psId2);
     assertThat(notes.getApprovals()).isNotEmpty();
     assertThat(notes.getChangeMessages()).isNotEmpty();
-    assertThat(notes.getComments()).isNotEmpty();
+    assertThat(notes.getHumanComments()).isNotEmpty();
 
     // publish ps2
     update = newUpdate(c, changeOwner);
@@ -1222,7 +1224,7 @@
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1);
     assertThat(notes.getApprovals()).isEmpty();
     assertThat(notes.getChangeMessages()).isEmpty();
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
   }
 
   @Test
@@ -1279,14 +1281,14 @@
     Map<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
     assertThat(patchSets.get(psId1).pushCertificate()).isEmpty();
     assertThat(patchSets.get(psId2).pushCertificate()).hasValue(pushCert);
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     // comment on ps2
     update = newUpdate(c, changeOwner);
     update.setPatchSetId(psId2);
     Timestamp ts = TimeUtil.nowTs();
     update.putComment(
-        Comment.Status.PUBLISHED,
+        HumanComment.Status.PUBLISHED,
         newComment(
             psId2,
             "a.txt",
@@ -1307,7 +1309,7 @@
     patchSets = notes.getPatchSets();
     assertThat(patchSets.get(psId1).pushCertificate()).isEmpty();
     assertThat(patchSets.get(psId2).pushCertificate()).hasValue(pushCert);
-    assertThat(notes.getComments()).isNotEmpty();
+    assertThat(notes.getHumanComments()).isNotEmpty();
   }
 
   @Test
@@ -1356,7 +1358,7 @@
     PatchSet.Id psId = c.currentPatchSetId();
     RevCommit tipCommit;
     try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
-      Comment comment1 =
+      HumanComment comment1 =
           newComment(
               psId,
               "file1",
@@ -1371,7 +1373,7 @@
               ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
               false);
       update1.setPatchSetId(psId);
-      update1.putComment(Comment.Status.PUBLISHED, comment1);
+      update1.putComment(HumanComment.Status.PUBLISHED, comment1);
       updateManager.add(update1);
 
       ChangeUpdate update2 = newUpdate(c, otherUser);
@@ -1570,7 +1572,7 @@
     PatchSet.Id psId = c.currentPatchSetId();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file1",
@@ -1585,11 +1587,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1600,7 +1602,7 @@
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 0, 2, 0);
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file1",
@@ -1615,11 +1617,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1630,7 +1632,7 @@
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(0, 0, 0, 0);
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file",
@@ -1645,11 +1647,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1660,7 +1662,7 @@
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     CommentRange range = new CommentRange(1, 2, 3, 4);
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "",
@@ -1675,11 +1677,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1699,7 +1701,7 @@
     Timestamp time = TimeUtil.nowTs();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             psId1,
             "file1",
@@ -1713,7 +1715,7 @@
             (short) 0,
             commitId,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             psId1,
             "file1",
@@ -1727,7 +1729,7 @@
             (short) 0,
             commitId,
             false);
-    Comment comment3 =
+    HumanComment comment3 =
         newComment(
             psId2,
             "file1",
@@ -1744,13 +1746,13 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId2);
-    update.putComment(Comment.Status.PUBLISHED, comment3);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment3);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .isEqualTo(
             ImmutableListMultimap.of(
                 commitId, comment1,
@@ -1770,7 +1772,7 @@
     PatchSet.Id psId = c.currentPatchSetId();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file",
@@ -1786,12 +1788,12 @@
             false);
     comment.setRealAuthor(changeOwner.getAccountId());
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
-    assertThat(notes.getComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
+    assertThat(notes.getHumanComments()).isEqualTo(ImmutableListMultimap.of(commitId, comment));
   }
 
   @Test
@@ -1809,7 +1811,7 @@
     Timestamp time = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "file1",
@@ -1824,12 +1826,12 @@
             ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
 
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .isEqualTo(ImmutableListMultimap.of(comment.getCommitId(), comment));
   }
 
@@ -1847,7 +1849,7 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    Comment commentForBase =
+    HumanComment commentForBase =
         newComment(
             psId,
             "filename",
@@ -1862,11 +1864,11 @@
             commitId1,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, commentForBase);
+    update.putComment(HumanComment.Status.PUBLISHED, commentForBase);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    Comment commentForPS =
+    HumanComment commentForPS =
         newComment(
             psId,
             "filename",
@@ -1881,10 +1883,10 @@
             commitId2,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, commentForPS);
+    update.putComment(HumanComment.Status.PUBLISHED, commentForPS);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId1, commentForBase,
@@ -1905,7 +1907,7 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp timeForComment1 = TimeUtil.nowTs();
     Timestamp timeForComment2 = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             psId,
             filename,
@@ -1920,11 +1922,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             psId,
             filename,
@@ -1939,10 +1941,10 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId, comment1,
@@ -1963,7 +1965,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             psId,
             filename1,
@@ -1978,11 +1980,11 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     update = newUpdate(c, otherUser);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             psId,
             filename2,
@@ -1997,10 +1999,10 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId, comment1,
@@ -2021,7 +2023,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             filename,
@@ -2036,7 +2038,7 @@
             commitId1,
             false);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -2044,7 +2046,7 @@
 
     update = newUpdate(c, otherUser);
     now = TimeUtil.nowTs();
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps2,
             filename,
@@ -2059,10 +2061,10 @@
             commitId2,
             false);
     update.setPatchSetId(ps2);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId1, comment1,
@@ -2081,7 +2083,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             filename,
@@ -2096,22 +2098,22 @@
             commitId,
             false);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
   }
 
@@ -2131,7 +2133,7 @@
     // Write two drafts on the same side of one patch set.
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             psId,
             filename,
@@ -2145,7 +2147,7 @@
             side,
             commitId,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             psId,
             filename,
@@ -2159,8 +2161,8 @@
             side,
             commitId,
             false);
-    update.putComment(Comment.Status.DRAFT, comment1);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2170,18 +2172,18 @@
                 commitId, comment1,
                 commitId, comment2))
         .inOrder();
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     // Publish first draft.
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId))
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment2));
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
   }
 
@@ -2201,7 +2203,7 @@
     // Write two drafts, one on each side of the patchset.
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
-    Comment baseComment =
+    HumanComment baseComment =
         newComment(
             psId,
             filename,
@@ -2215,7 +2217,7 @@
             (short) 0,
             commitId1,
             false);
-    Comment psComment =
+    HumanComment psComment =
         newComment(
             psId,
             filename,
@@ -2230,8 +2232,8 @@
             commitId2,
             false);
 
-    update.putComment(Comment.Status.DRAFT, baseComment);
-    update.putComment(Comment.Status.DRAFT, psComment);
+    update.putComment(HumanComment.Status.DRAFT, baseComment);
+    update.putComment(HumanComment.Status.DRAFT, psComment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2240,19 +2242,19 @@
             ImmutableListMultimap.of(
                 commitId1, baseComment,
                 commitId2, psComment));
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     // Publish both comments.
     update = newUpdate(c, otherUser);
     update.setPatchSetId(psId);
 
-    update.putComment(Comment.Status.PUBLISHED, baseComment);
-    update.putComment(Comment.Status.PUBLISHED, psComment);
+    update.putComment(HumanComment.Status.PUBLISHED, baseComment);
+    update.putComment(HumanComment.Status.PUBLISHED, psComment);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments())
+    assertThat(notes.getHumanComments())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 commitId1, baseComment,
@@ -2271,7 +2273,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             filename,
@@ -2286,7 +2288,7 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.DRAFT, comment);
+    update.putComment(HumanComment.Status.DRAFT, comment);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2316,7 +2318,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             filename,
@@ -2331,7 +2333,7 @@
             commitId1,
             false);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
     update.commit();
 
     incrementPatchSet(c);
@@ -2339,7 +2341,7 @@
 
     update = newUpdate(c, otherUser);
     now = TimeUtil.nowTs();
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps2,
             filename,
@@ -2354,7 +2356,7 @@
             commitId2,
             false);
     update.setPatchSetId(ps2);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -2384,7 +2386,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment =
+    HumanComment comment =
         newComment(
             ps1,
             filename,
@@ -2398,7 +2400,7 @@
             side,
             commitId,
             false);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     assertThat(repo.exactRef(changeMetaRef(c.getId()))).isNotNull();
@@ -2417,7 +2419,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment draft =
+    HumanComment draft =
         newComment(
             ps1,
             filename,
@@ -2431,7 +2433,7 @@
             side,
             commitId,
             false);
-    update.putComment(Comment.Status.DRAFT, draft);
+    update.putComment(HumanComment.Status.DRAFT, draft);
     update.commit();
 
     String draftRef = refsDraftComments(c.getId(), otherUser.getAccountId());
@@ -2439,7 +2441,7 @@
     assertThat(old).isNotNull();
 
     update = newUpdate(c, otherUser);
-    Comment pub =
+    HumanComment pub =
         newComment(
             ps1,
             filename,
@@ -2453,7 +2455,7 @@
             side,
             commitId,
             false);
-    update.putComment(Comment.Status.PUBLISHED, pub);
+    update.putComment(HumanComment.Status.PUBLISHED, pub);
     update.commit();
 
     assertThat(exactRefAllUsers(draftRef)).isEqualTo(old);
@@ -2469,7 +2471,7 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "filename",
@@ -2484,10 +2486,10 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment));
   }
 
@@ -2501,7 +2503,7 @@
     Timestamp now = TimeUtil.nowTs();
     PatchSet.Id psId = c.currentPatchSetId();
 
-    Comment comment =
+    HumanComment comment =
         newComment(
             psId,
             "filename",
@@ -2516,10 +2518,10 @@
             commitId,
             false);
     update.setPatchSetId(psId);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
-    assertThat(newNotes(c).getComments())
+    assertThat(newNotes(c).getHumanComments())
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment));
   }
 
@@ -2540,7 +2542,7 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             filename,
@@ -2554,7 +2556,7 @@
             side,
             commitId1,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps2,
             filename,
@@ -2568,23 +2570,23 @@
             side,
             commitId2,
             false);
-    update.putComment(Comment.Status.DRAFT, comment1);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).hasSize(2);
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId)).isEmpty();
-    assertThat(notes.getComments()).hasSize(2);
+    assertThat(notes.getHumanComments()).hasSize(2);
   }
 
   @Test
@@ -2598,7 +2600,7 @@
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             "file1",
@@ -2612,7 +2614,7 @@
             side,
             commitId1,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps1,
             "file2",
@@ -2626,23 +2628,23 @@
             side,
             commitId1,
             false);
-    update.putComment(Comment.Status.DRAFT, comment1);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId).get(commitId1))
         .containsExactly(comment1, comment2);
-    assertThat(notes.getComments()).isEmpty();
+    assertThat(notes.getHumanComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
-    assertThat(notes.getComments().get(commitId1)).containsExactly(comment2);
+    assertThat(notes.getHumanComments().get(commitId1)).containsExactly(comment2);
   }
 
   @Test
@@ -2671,7 +2673,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     Timestamp now = TimeUtil.nowTs();
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             ps1,
             "file1",
@@ -2685,7 +2687,7 @@
             side,
             commitId1,
             false);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             ps1,
             "file2",
@@ -2699,8 +2701,8 @@
             side,
             commitId1,
             false);
-    update.putComment(Comment.Status.DRAFT, comment1);
-    update.putComment(Comment.Status.DRAFT, comment2);
+    update.putComment(HumanComment.Status.DRAFT, comment1);
+    update.putComment(HumanComment.Status.DRAFT, comment2);
     update.commit();
 
     String refName = refsDraftComments(c.getId(), otherUserId);
@@ -2708,7 +2710,7 @@
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment2);
+    update.putComment(HumanComment.Status.PUBLISHED, comment2);
     update.commit();
     assertThat(exactRefAllUsers(refName)).isNotNull();
     assertThat(exactRefAllUsers(refName)).isNotEqualTo(oldDraftId);
@@ -2730,11 +2732,11 @@
     // Zombie comment is filtered out of drafts via ChangeNotes.
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
-    assertThat(notes.getComments().get(commitId1)).containsExactly(comment2);
+    assertThat(notes.getHumanComments().get(commitId1)).containsExactly(comment2);
 
     update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    update.putComment(Comment.Status.PUBLISHED, comment1);
+    update.putComment(HumanComment.Status.PUBLISHED, comment1);
     update.commit();
 
     // Updating an unrelated comment causes the zombie comment to get fixed up.
@@ -2748,7 +2750,7 @@
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     ChangeUpdate update1 = newUpdate(c, otherUser);
-    Comment comment1 =
+    HumanComment comment1 =
         newComment(
             c.currentPatchSetId(),
             "filename",
@@ -2762,10 +2764,10 @@
             (short) 1,
             commitId,
             false);
-    update1.putComment(Comment.Status.PUBLISHED, comment1);
+    update1.putComment(HumanComment.Status.PUBLISHED, comment1);
 
     ChangeUpdate update2 = newUpdate(c, otherUser);
-    Comment comment2 =
+    HumanComment comment2 =
         newComment(
             c.currentPatchSetId(),
             "filename",
@@ -2779,7 +2781,7 @@
             (short) 1,
             commitId,
             false);
-    update2.putComment(Comment.Status.PUBLISHED, comment2);
+    update2.putComment(HumanComment.Status.PUBLISHED, comment2);
 
     try (NoteDbUpdateManager manager = updateManagerFactory.create(project)) {
       manager.add(update1);
@@ -2788,7 +2790,7 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<Comment> comments = notes.getComments().get(commitId);
+    List<HumanComment> comments = notes.getHumanComments().get(commitId);
     assertThat(comments).hasSize(2);
     assertThat(comments.get(0).message).isEqualTo("comment 1");
     assertThat(comments.get(1).message).isEqualTo("comment 2");
@@ -2815,14 +2817,14 @@
     int numMessages = notes.getChangeMessages().size();
     int numPatchSets = notes.getPatchSets().size();
     int numApprovals = notes.getApprovals().size();
-    int numComments = notes.getComments().size();
+    int numComments = notes.getHumanComments().size();
 
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setPatchSetId(PatchSet.id(c.getId(), c.currentPatchSetId().get() + 1));
     update.setChangeMessage("Should be ignored");
     update.putApproval("Code-Review", (short) 2);
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    Comment comment =
+    HumanComment comment =
         newComment(
             update.getPatchSetId(),
             "filename",
@@ -2836,14 +2838,14 @@
             (short) 1,
             ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
             false);
-    update.putComment(Comment.Status.PUBLISHED, comment);
+    update.putComment(HumanComment.Status.PUBLISHED, comment);
     update.commit();
 
     notes = newNotes(c);
     assertThat(notes.getChangeMessages()).hasSize(numMessages);
     assertThat(notes.getPatchSets()).hasSize(numPatchSets);
     assertThat(notes.getApprovals()).hasSize(numApprovals);
-    assertThat(notes.getComments()).hasSize(numComments);
+    assertThat(notes.getHumanComments()).hasSize(numComments);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
index c2620dc..2c1348c 100644
--- a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import java.sql.Timestamp;
@@ -151,7 +152,7 @@
   @Test
   public void newAdapterRoundTripOfWholeComment() {
     Comment c =
-        new Comment(
+        new HumanComment(
             new Comment.Key("uuid", "filename", 1),
             Account.id(100),
             NON_DST_TS,
@@ -165,7 +166,7 @@
     String json = gson.toJson(c);
     assertThat(json).contains("\"writtenOn\": \"" + NON_DST_STR_TRUNC + "\",");
 
-    Comment result = gson.fromJson(json, Comment.class);
+    Comment result = gson.fromJson(json, HumanComment.class);
     // Round-trip lossily truncates ms, but that's ok.
     assertThat(result.writtenOn).isEqualTo(NON_DST_TS_TRUNC);
     result.writtenOn = NON_DST_TS;
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 6a090c1..6cfd9f2d 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -20,9 +20,9 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.SubmissionId;
-import com.google.gerrit.mail.Address;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
@@ -45,7 +45,6 @@
     update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
     assertThat(update.getRefName()).isEqualTo("refs/changes/01/1/meta");
-
     RevCommit commit = parseCommit(update.getResult());
     assertBodyEquals(
         "Update patch set 1\n"
@@ -62,7 +61,8 @@
             + "Reviewer: Gerrit User 1 <1@gerrit>\n"
             + "CC: Gerrit User 2 <2@gerrit>\n"
             + "Label: Code-Review=-1\n"
-            + "Label: Verified=+1\n",
+            + "Label: Verified=+1\n"
+            + "Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Reviewer was added\"}\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
@@ -245,7 +245,8 @@
     update.commit();
 
     assertBodyEquals(
-        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n",
+        "Update patch set 1\n\nPatch-set: 1\nReviewer: Gerrit User 1 <1@gerrit>\n"
+            + "Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Reviewer was added\"}\n",
         update.getResult());
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
index bf49884..041366c 100644
--- a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.util.time.TimeUtil;
 import org.eclipse.jgit.lib.ObjectId;
@@ -31,7 +31,7 @@
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(c.currentPatchSetId());
-    update.putComment(Comment.Status.PUBLISHED, comment(c.currentPatchSetId()));
+    update.putComment(HumanComment.Status.PUBLISHED, comment(c.currentPatchSetId()));
     update.commit();
 
     assertThat(newNotes(c).getDraftComments(otherUserId)).isEmpty();
@@ -44,13 +44,13 @@
     ChangeUpdate update = newUpdate(c, otherUser);
 
     update.setPatchSetId(c.currentPatchSetId());
-    update.putComment(Comment.Status.DRAFT, comment(c.currentPatchSetId()));
+    update.putComment(HumanComment.Status.DRAFT, comment(c.currentPatchSetId()));
     update.commit();
     assertThat(newNotes(c).getDraftComments(otherUserId)).hasSize(1);
     assertableFanOutExecutor.assertInteractions(0);
 
     update = newUpdate(c, otherUser);
-    update.putComment(Comment.Status.PUBLISHED, comment(c.currentPatchSetId()));
+    update.putComment(HumanComment.Status.PUBLISHED, comment(c.currentPatchSetId()));
     update.commit();
 
     assertThat(newNotes(c).getDraftComments(otherUserId)).isEmpty();
@@ -63,7 +63,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(c.currentPatchSetId());
-    update.putComment(Comment.Status.DRAFT, comment(c.currentPatchSetId()));
+    update.putComment(HumanComment.Status.DRAFT, comment(c.currentPatchSetId()));
     update.commit();
 
     ChangeNotes notes = newNotes(c);
@@ -80,7 +80,7 @@
     assertableFanOutExecutor.assertInteractions(0);
   }
 
-  private Comment comment(PatchSet.Id psId) {
+  private HumanComment comment(PatchSet.Id psId) {
     return newComment(
         psId,
         "filename",
diff --git a/javatests/com/google/gerrit/server/patch/PatchListTest.java b/javatests/com/google/gerrit/server/patch/PatchListTest.java
index e224191a..56adefa 100644
--- a/javatests/com/google/gerrit/server/patch/PatchListTest.java
+++ b/javatests/com/google/gerrit/server/patch/PatchListTest.java
@@ -26,16 +26,30 @@
 import org.junit.Test;
 
 public class PatchListTest {
+
   @Test
   public void fileOrder() {
     String[] names = {
-      "zzz", "def/g", "/!xxx", "abc", Patch.MERGE_LIST, "qrx", Patch.COMMIT_MSG,
+      "zzz",
+      "def/g",
+      "/!xxx",
+      "abc",
+      Patch.MERGE_LIST,
+      "qrx",
+      Patch.COMMIT_MSG,
+      Patch.PATCHSET_LEVEL
     };
     String[] want = {
-      Patch.COMMIT_MSG, Patch.MERGE_LIST, "/!xxx", "abc", "def/g", "qrx", "zzz",
+      Patch.COMMIT_MSG,
+      Patch.MERGE_LIST,
+      Patch.PATCHSET_LEVEL,
+      "/!xxx",
+      "abc",
+      "def/g",
+      "qrx",
+      "zzz",
     };
-
-    Arrays.sort(names, 0, names.length, PatchList::comparePaths);
+    Arrays.sort(names, 0, names.length, PatchList.FILE_PATH_CMP);
     assertThat(names).isEqualTo(want);
   }
 
@@ -48,7 +62,7 @@
       Patch.COMMIT_MSG, "/!xxx", "abc", "def/g", "qrx", "zzz",
     };
 
-    Arrays.sort(names, 0, names.length, PatchList::comparePaths);
+    Arrays.sort(names, 0, names.length, PatchList.FILE_PATH_CMP);
     assertThat(names).isEqualTo(want);
   }
 
diff --git a/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
index 305e81b..6c5eb7a 100644
--- a/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
+++ b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.refPermission;
 
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
 import org.junit.Test;
 
 public class DefaultPermissionsMappingTest {
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 33446e4..81cb732 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -23,12 +23,12 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
-import static com.google.gerrit.common.data.Permission.EDIT_TOPIC_NAME;
-import static com.google.gerrit.common.data.Permission.LABEL;
-import static com.google.gerrit.common.data.Permission.OWNER;
-import static com.google.gerrit.common.data.Permission.PUSH;
-import static com.google.gerrit.common.data.Permission.READ;
-import static com.google.gerrit.common.data.Permission.SUBMIT;
+import static com.google.gerrit.entities.Permission.EDIT_TOPIC_NAME;
+import static com.google.gerrit.entities.Permission.LABEL;
+import static com.google.gerrit.entities.Permission.OWNER;
+import static com.google.gerrit.entities.Permission.PUSH;
+import static com.google.gerrit.entities.Permission.READ;
+import static com.google.gerrit.entities.Permission.SUBMIT;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -39,9 +39,9 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PermissionRange;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.server.CurrentUser;
@@ -207,7 +207,7 @@
         ProjectConfig allProjectsConfig = projectConfigFactory.create(allProjectsName);
         allProjectsConfig.load(md);
         LabelType cr = TestLabels.codeReview();
-        allProjectsConfig.getLabelSections().put(cr.getName(), cr);
+        allProjectsConfig.upsertLabelType(cr);
         allProjectsConfig.commit(md);
       }
     }
@@ -217,7 +217,7 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.create(localKey)) {
       ProjectConfig newLocal = projectConfigFactory.create(localKey);
       newLocal.load(md);
-      newLocal.getProject().setParentName(parentKey);
+      newLocal.updateProject(p -> p.setParent(parentKey));
       newLocal.commit(md);
     }
 
diff --git a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
index 5e94daa..1035fe7 100644
--- a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
+++ b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -17,7 +17,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
-import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.entities.Permission.READ;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static org.eclipse.jgit.lib.Constants.R_REFS;
 import static org.junit.Assert.assertFalse;
@@ -26,13 +26,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
-import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
@@ -228,6 +228,7 @@
             .getAllProjects()
             .getConfig()
             .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+            .orElseThrow(() -> new IllegalStateException("access section does not exist"))
             .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
 
     return adminPermission.getRules().stream()
diff --git a/javatests/com/google/gerrit/server/project/GroupListTest.java b/javatests/com/google/gerrit/server/project/GroupListTest.java
index 518f85d..f3295f8 100644
--- a/javatests/com/google/gerrit/server/project/GroupListTest.java
+++ b/javatests/com/google/gerrit/server/project/GroupListTest.java
@@ -23,8 +23,8 @@
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
-import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
 import java.io.IOException;
@@ -63,7 +63,7 @@
   @Test
   public void put() {
     AccountGroup.UUID uuid = AccountGroup.uuid("abc");
-    GroupReference groupReference = new GroupReference(uuid, "Hutzliputz");
+    GroupReference groupReference = GroupReference.create(uuid, "Hutzliputz");
 
     groupList.put(uuid, groupReference);
 
@@ -78,7 +78,7 @@
 
     assertEquals(2, result.size());
     AccountGroup.UUID uuid = AccountGroup.uuid("ebe31c01aec2c9ac3b3c03e87a47450829ff4310");
-    GroupReference expected = new GroupReference(uuid, "Administrators");
+    GroupReference expected = GroupReference.create(uuid, "Administrators");
 
     assertTrue(result.contains(expected));
   }
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 0dd6436..1ad5ea6 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -20,15 +20,18 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountsSection;
+import com.google.gerrit.entities.BranchOrderSection;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.StoredCommentLinkInfo;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfig;
@@ -90,8 +93,8 @@
   @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
 
   private final GroupReference developers =
-      new GroupReference(AccountGroup.uuid("X"), "Developers");
-  private final GroupReference staff = new GroupReference(AccountGroup.uuid("Y"), "Staff");
+      GroupReference.create(AccountGroup.uuid("X"), "Developers");
+  private final GroupReference staff = GroupReference.create(AccountGroup.uuid("Y"), "Staff");
 
   private SitePaths sitePaths;
   private ProjectConfig.Factory factory;
@@ -302,17 +305,22 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    AccessSection section = cfg.getAccessSection("refs/heads/*");
-    cfg.getAccountsSection()
-        .setSameGroupVisibility(Collections.singletonList(new PermissionRule(cfg.resolve(staff))));
-    Permission submit = section.getPermission(Permission.SUBMIT);
-    submit.add(new PermissionRule(cfg.resolve(staff)));
-    ContributorAgreement ca = cfg.getContributorAgreement("Individual");
-    ca.setAccepted(Collections.singletonList(new PermissionRule(cfg.resolve(staff))));
+    cfg.upsertAccessSection(
+        "refs/heads/*",
+        section -> {
+          Permission.Builder submit = section.upsertPermission(Permission.SUBMIT);
+          submit.add(PermissionRule.builder(cfg.resolve(staff)));
+        });
+    cfg.setAccountsSection(
+        AccountsSection.create(
+            Collections.singletonList(PermissionRule.create(cfg.resolve(staff)))));
+    ContributorAgreement.Builder ca = cfg.getContributorAgreement("Individual").toBuilder();
+    ca.setAccepted(ImmutableList.of(PermissionRule.create(cfg.resolve(staff))));
     ca.setAutoVerify(null);
-    ca.setMatchProjectsRegexes(null);
-    ca.setExcludeProjectsRegexes(Collections.singletonList("^/theirproject"));
+    ca.setMatchProjectsRegexes(ImmutableList.of());
+    ca.setExcludeProjectsRegexes(ImmutableList.of("^/theirproject"));
     ca.setDescription("A new description");
+    cfg.upsertContributorAgreement(ca.build());
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
@@ -361,12 +369,43 @@
   }
 
   @Test
+  public void readExistingBranchOrder() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("project.config", "[branchOrder]\n" + "\tbranch = foo\n" + "\tbranch = bar\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    assertThat(cfg.getBranchOrderSection())
+        .isEqualTo(BranchOrderSection.create(ImmutableList.of("foo", "bar")));
+  }
+
+  @Test
+  public void editBranchOrder() throws Exception {
+    RevCommit rev = tr.commit().create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    cfg.setBranchOrderSection(BranchOrderSection.create(ImmutableList.of("foo", "bar")));
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo("[branchOrder]\n" + "\tbranch = foo\n" + "\tbranch = bar\n");
+  }
+
+  @Test
   public void addCommentLink() throws Exception {
     RevCommit rev = tr.commit().create();
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    CommentLinkInfoImpl cm = new CommentLinkInfoImpl("Test", "abc.*", null, "<a>link</a>", true);
+    StoredCommentLinkInfo cm =
+        StoredCommentLinkInfo.builder("Test")
+            .setMatch("abc.*")
+            .setHtml("<a>link</a>")
+            .setEnabled(true)
+            .setOverrideOnly(false)
+            .build();
     cfg.addCommentLinkSection(cm);
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
@@ -389,9 +428,12 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    AccessSection section = cfg.getAccessSection("refs/heads/*");
-    Permission submit = section.getPermission(Permission.SUBMIT);
-    submit.add(new PermissionRule(cfg.resolve(staff)));
+    cfg.upsertAccessSection(
+        "refs/heads/*",
+        section -> {
+          Permission.Builder submit = section.upsertPermission(Permission.SUBMIT);
+          submit.add(PermissionRule.builder(cfg.resolve(staff)));
+        });
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
@@ -445,9 +487,12 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
-    pluginCfg.setString("key1", "updatedValue1");
-    pluginCfg.setStringList("key2", Arrays.asList("updatedValue2a", "updatedValue2b"));
+    cfg.updatePluginConfig(
+        "somePlugin",
+        pluginCfg -> {
+          pluginCfg.setString("key1", "updatedValue1");
+          pluginCfg.setStringList("key2", Arrays.asList("updatedValue2a", "updatedValue2b"));
+        });
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
@@ -469,7 +514,7 @@
     ProjectConfig cfg = read(rev);
     PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
     assertThat(pluginCfg.getNames()).hasSize(1);
-    assertThat(pluginCfg.getGroupReference("key1")).isEqualTo(developers);
+    assertThat(pluginCfg.getGroupReference("key1").get()).isEqualTo(developers);
   }
 
   @Test
@@ -498,11 +543,10 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
-    assertThat(pluginCfg.getNames()).hasSize(1);
-    assertThat(pluginCfg.getGroupReference("key1")).isEqualTo(developers);
-
-    pluginCfg.setGroupReference("key1", staff);
+    assertThat(cfg.getPluginConfig("somePlugin").getNames()).hasSize(1);
+    assertThat(cfg.getPluginConfig("somePlugin").getGroupReference("key1").get())
+        .isEqualTo(developers);
+    cfg.updatePluginConfig("somePlugin", pluginCfg -> pluginCfg.setGroupReference("key1", staff));
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo("[plugin \"somePlugin\"]\n\tkey1 = " + staff.toConfigValue() + "\n");
@@ -529,12 +573,11 @@
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
         .containsExactly(
-            new CommentLinkInfoImpl(
-                "bugzilla",
-                "(bug\\s+#?)(\\d+)",
-                "http://bugs.example.com/show_bug.cgi?id=$2",
-                null,
-                null));
+            StoredCommentLinkInfo.builder("bugzilla")
+                .setMatch("(bug\\s+#?)(\\d+)")
+                .setLink("http://bugs.example.com/show_bug.cgi?id=$2")
+                .setOverrideOnly(false)
+                .build());
   }
 
   @Test
@@ -543,7 +586,7 @@
         tr.commit().add("project.config", "[commentlink \"bugzilla\"]\n \tenabled = true").create();
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
-        .containsExactly(new CommentLinkInfoImpl.Enabled("bugzilla"));
+        .containsExactly(StoredCommentLinkInfo.enabled("bugzilla"));
   }
 
   @Test
@@ -554,7 +597,28 @@
             .create();
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
-        .containsExactly(new CommentLinkInfoImpl.Disabled("bugzilla"));
+        .containsExactly(StoredCommentLinkInfo.disabled("bugzilla"));
+  }
+
+  @Test
+  public void readCommentLinksNoHtmlOrLinkAndMissingEnabled() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[commentlink \"bugzilla\"]\n \tlink = http://bugs.example.com/show_bug.cgi?id=$2"
+                    + "\n \tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n")
+            .create();
+    ProjectConfig cfg = read(rev);
+    assertThat(cfg.getCommentLinkSections())
+        .containsExactly(
+            StoredCommentLinkInfo.builder("bugzilla")
+                .setMatch("(bug\\s+#?)(\\d+)")
+                .setLink("http://bugs.example.com/show_bug.cgi?id=$2")
+                .build());
+    StoredCommentLinkInfo stored = Iterables.getOnlyElement(cfg.getCommentLinkSections());
+    assertThat(StoredCommentLinkInfo.fromInfo(stored.toInfo(), stored.getEnabled()))
+        .isEqualTo(stored);
   }
 
   @Test
@@ -571,7 +635,7 @@
     assertThat(cfg.getCommentLinkSections()).isEmpty();
     assertThat(cfg.getValidationErrors())
         .containsExactly(
-            new ValidationError(
+            ValidationError.create(
                 "project.config: Invalid pattern \"(bugs{+#?)(d+)\" in commentlink.bugzilla.match: "
                     + "Illegal repetition near index 4\n"
                     + "(bugs{+#?)(d+)\n"
@@ -592,7 +656,7 @@
     assertThat(cfg.getCommentLinkSections()).isEmpty();
     assertThat(cfg.getValidationErrors())
         .containsExactly(
-            new ValidationError(
+            ValidationError.create(
                 "project.config: Error in pattern \"(bugs#?)(d+)\" in commentlink.bugzilla.match: "
                     + "Raw html replacement not allowed"));
   }
@@ -607,7 +671,7 @@
     assertThat(cfg.getCommentLinkSections()).isEmpty();
     assertThat(cfg.getValidationErrors())
         .containsExactly(
-            new ValidationError(
+            ValidationError.create(
                 "project.config: Error in pattern \"(bugs#?)(d+)\" in commentlink.bugzilla.match: "
                     + "commentlink.bugzilla must have either link or html"));
   }
@@ -659,7 +723,7 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    cfg.getAccountsSection().setSameGroupVisibility(ImmutableList.of());
+    cfg.setAccountsSection(AccountsSection.create(ImmutableList.of()));
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
@@ -682,8 +746,9 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    ContributorAgreement section = cfg.getContributorAgreement("Individual");
+    ContributorAgreement.Builder section = cfg.getContributorAgreement("Individual").toBuilder();
     section.setAccepted(ImmutableList.of());
+    cfg.upsertContributorAgreement(section.build());
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
@@ -748,8 +813,7 @@
     update(rev);
 
     ProjectConfig cfg = read(rev);
-    PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
-    pluginCfg.unset("key");
+    cfg.updatePluginConfig("somePlugin", pluginCfg -> pluginCfg.unset("key"));
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 59f2b6d..f5c9628 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -217,7 +217,7 @@
 
     AccountInfo user5 = newAccountWithEmail("user5", name("user5MixedCase@example.com"));
 
-    assertQuery("notexisting@test.com");
+    assertQuery("notexisting@example.com");
 
     assertQuery(currentUserInfo.email, currentUserInfo);
     assertQuery("email:" + currentUserInfo.email, currentUserInfo);
@@ -253,8 +253,8 @@
 
   @Test
   public void byEmailWithoutModifyAccountCapability() throws Exception {
-    String preferredEmail = name("primary@test.com");
-    String secondaryEmail = name("secondary@test.com");
+    String preferredEmail = name("primary@example.com");
+    String secondaryEmail = name("secondary@example.com");
     AccountInfo user1 = newAccountWithEmail("user1", preferredEmail);
     addEmails(user1, secondaryEmail);
 
@@ -485,11 +485,11 @@
     // sorting by account ID. Use the same fullname for all accounts so that sorting must be done by
     // preferred email.
     AccountInfo userFoo3 =
-        newAccount("user3", "foo-" + appendix, "foo3-" + appendix + "@test.com", true);
+        newAccount("user3", "foo-" + appendix, "foo3-" + appendix + "@example.com", true);
     AccountInfo userFoo1 =
-        newAccount("user1", "foo-" + appendix, "foo1-" + appendix + "@test.com", true);
+        newAccount("user1", "foo-" + appendix, "foo1-" + appendix + "@example.com", true);
     AccountInfo userFoo2 =
-        newAccount("user2", "foo-" + appendix, "foo2-" + appendix + "@test.com", true);
+        newAccount("user2", "foo-" + appendix, "foo2-" + appendix + "@example.com", true);
     assertThat(userFoo3._accountId).isLessThan(userFoo1._accountId);
     assertThat(userFoo1._accountId).isLessThan(userFoo2._accountId);
 
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 1aa0f35..36f31c7 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -44,23 +44,22 @@
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddToAttentionSetInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -1026,7 +1025,7 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       ProjectConfig cfg = projectConfigFactory.create(project);
       cfg.load(md);
-      cfg.getLabelSections().put(verified.getName(), verified);
+      cfg.upsertLabelType(verified);
       cfg.commit(md);
     }
     projectCache.evict(project);
@@ -1859,11 +1858,16 @@
     try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
       md.setMessage(String.format("Grant %s on %s", permission, ref));
       ProjectConfig config = projectConfigFactory.read(md);
-      AccessSection s = config.getAccessSection(ref, true);
-      Permission p = s.getPermission(permission, true);
-      PermissionRule rule = new PermissionRule(new GroupReference(groupUUID, groupUUID.get()));
-      rule.setForce(force);
-      p.add(rule);
+      config.upsertAccessSection(
+          ref,
+          s -> {
+            Permission.Builder p = s.upsertPermission(permission);
+            PermissionRule.Builder rule =
+                PermissionRule.builder(GroupReference.create(groupUUID, groupUUID.get()))
+                    .setForce(force);
+            p.add(rule);
+          });
+
       config.commit(md);
       projectCache.evict(config.getProject());
     }
@@ -3016,7 +3020,7 @@
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
 
-    AddToAttentionSetInput input = new AddToAttentionSetInput(userId.toString(), "some reason");
+    AttentionSetInput input = new AttentionSetInput(userId.toString(), "some reason");
     gApi.changes().id(change1.getChangeId()).addToAttentionSet(input);
 
     assertQuery("attention:" + user.getUserName().get(), change1);
@@ -3029,16 +3033,17 @@
     TestRepository<Repo> repo = createProject("repo");
     Change change = insert(repo, newChange(repo));
 
-    AddToAttentionSetInput input = new AddToAttentionSetInput(userId.toString(), "reason 1");
+    AttentionSetInput input = new AttentionSetInput(userId.toString(), "reason 1");
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
     Account.Id user2Id =
         accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
-    input = new AddToAttentionSetInput(user2Id.toString(), "reason 2");
+    input = new AttentionSetInput(user2Id.toString(), "reason 2");
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
 
     List<ChangeInfo> result = newQuery("attention:" + user2Id.toString()).get();
     assertThat(result).hasSize(1);
     ChangeInfo changeInfo = Iterables.getOnlyElement(result);
+    assertThat(changeInfo.attentionSet).isNotNull();
     assertThat(changeInfo.attentionSet.keySet()).containsExactly(userId.get(), user2Id.get());
     assertThat(changeInfo.attentionSet.get(userId.get()).reason).isEqualTo("reason 1");
     assertThat(changeInfo.attentionSet.get(user2Id.get()).reason).isEqualTo("reason 2");
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index 9f34377..daefd7c 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -1,78 +1,139 @@
-/*
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
 
 @RunWith(JUnit4.class)
 public class ListChangeCommentsTest {
-
   @Test
-  public void commentsLinkedToChangeMessages() {
-    CommentInfo c1 = getNewCommentInfo("c1", Timestamp.valueOf("2018-01-01 09:01:00"));
-    CommentInfo c2 = getNewCommentInfo("c2", Timestamp.valueOf("2018-01-01 09:01:15"));
-    CommentInfo c3 = getNewCommentInfo("c3", Timestamp.valueOf("2018-01-01 09:01:25"));
+  public void commentsLinkedToChangeMessagesIgnoreGerritAutoGenTaggedMessages() {
+    /* Comments should not be linked to Gerrit's autogenerated messages */
+    List<CommentInfo> comments = createComments("c1", "00", "c2", "10", "c3", "25");
+    List<ChangeMessage> changeMessages =
+        createChangeMessages("cm1", "00", "cm2", "16", "cm3", "30");
 
-    ChangeMessage cm1 =
-        getNewChangeMessage("cm1key", "cm1", Timestamp.valueOf("2018-01-01 00:00:00"));
-    ChangeMessage cm2 =
-        getNewChangeMessage("cm2key", "cm2", Timestamp.valueOf("2018-01-01 09:01:15"));
-    ChangeMessage cm3 =
-        getNewChangeMessage("cm3key", "cm3", Timestamp.valueOf("2018-01-01 09:01:27"));
+    changeMessages.add(
+        newChangeMessage("ignore", "cmAutoGenByGerrit", "15", ChangeMessagesUtil.TAG_MERGED));
 
-    assertThat(c1.changeMessageId).isNull();
-    assertThat(c2.changeMessageId).isNull();
-    assertThat(c3.changeMessageId).isNull();
+    CommentsUtil.linkCommentsToChangeMessages(comments, changeMessages, true);
 
-    ImmutableList<CommentInfo> comments = ImmutableList.of(c1, c2, c3);
-    ImmutableList<ChangeMessage> changeMessages = ImmutableList.of(cm1, cm2, cm3);
+    assertThat(getComment(comments, "c1").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm1").getKey().uuid());
+    /* comment 2 ignored the auto-generated message because it has a Gerrit tag */
+    assertThat(getComment(comments, "c2").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm2").getKey().uuid());
+    assertThat(getComment(comments, "c3").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm3").getKey().uuid());
 
-    CommentsUtil.linkCommentsToChangeMessages(comments, changeMessages);
-
-    assertThat(c1.changeMessageId).isEqualTo(changeMessageKey(cm2));
-    assertThat(c2.changeMessageId).isEqualTo(changeMessageKey(cm2));
-    assertThat(c3.changeMessageId).isEqualTo(changeMessageKey(cm3));
+    // Make sure no comment is linked to the auto-gen message
+    assertThat(comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet()))
+        .doesNotContain(getChangeMessage(changeMessages, "cmAutoGenByGerrit"));
   }
 
-  private static CommentInfo getNewCommentInfo(String message, Timestamp ts) {
+  @Test
+  public void commentsLinkedToChangeMessagesAllowLinkingToAutoGenTaggedMessages() {
+    /* Human comments are allowed to be linked to autogenerated messages */
+    List<CommentInfo> comments = createComments("c1", "00", "c2", "10", "c3", "25");
+    List<ChangeMessage> changeMessages =
+        createChangeMessages("cm1", "00", "cm2", "16", "cm3", "30");
+
+    changeMessages.add(
+        newChangeMessage(
+            "cmAutoGen", "cmAutoGen", "15", ChangeMessagesUtil.AUTOGENERATED_TAG_PREFIX));
+
+    CommentsUtil.linkCommentsToChangeMessages(comments, changeMessages, true);
+
+    assertThat(getComment(comments, "c1").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm1").getKey().uuid());
+    /* comment 2 did not ignore the auto-generated change message */
+    assertThat(getComment(comments, "c2").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cmAutoGen").getKey().uuid());
+    assertThat(getComment(comments, "c3").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm3").getKey().uuid());
+  }
+
+  /**
+   * Create a list of comments from the specified args args should be passed as consecutive pairs of
+   * messages and timestamps example: (m1, t1, m2, t2, ...)
+   */
+  private static List<CommentInfo> createComments(String... args) {
+    List<CommentInfo> comments = new ArrayList<>();
+    for (int i = 0; i < args.length; i += 2) {
+      String message = args[i];
+      String ts = args[i + 1];
+      comments.add(newCommentInfo(message, ts));
+    }
+    return comments;
+  }
+
+  /**
+   * Create a list of change messages from the specified args args should be passed as consecutive
+   * pairs of messages and timestamps example: (m1, t1, m2, t2, ...). the tag parameter for the
+   * created change messages will be null.
+   */
+  private static List<ChangeMessage> createChangeMessages(String... args) {
+    List<ChangeMessage> changeMessages = new ArrayList<>();
+    for (int i = 0; i < args.length; i += 2) {
+      String key = args[i] + "Key";
+      String message = args[i];
+      String ts = args[i + 1];
+      changeMessages.add(newChangeMessage(key, message, ts, null));
+    }
+    return changeMessages;
+  }
+
+  /** Create a new CommentInfo with a given message and timestamp */
+  private static CommentInfo newCommentInfo(String message, String ts) {
     CommentInfo c = new CommentInfo();
     c.message = message;
-    c.updated = ts;
+    c.updated = Timestamp.valueOf("2000-01-01 00:00:" + ts);
     return c;
   }
 
-  private static ChangeMessage getNewChangeMessage(String id, String message, Timestamp ts) {
+  /** Create a new change message with an id, message, timestamp and tag */
+  private static ChangeMessage newChangeMessage(String id, String message, String ts, String tag) {
     ChangeMessage.Key key = ChangeMessage.key(Change.id(1), id);
-    ChangeMessage cm = new ChangeMessage(key, null, ts, null);
+    ChangeMessage cm =
+        new ChangeMessage(key, null, Timestamp.valueOf("2000-01-01 00:00:" + ts), null);
     cm.setMessage(message);
+    cm.setTag(tag);
     return cm;
   }
 
-  private static String changeMessageKey(ChangeMessage changeMessage) {
-    return changeMessage.getKey().uuid();
+  /** Return the change message from the list of messages that has specific message text */
+  private static ChangeMessage getChangeMessage(List<ChangeMessage> messages, String messageText) {
+    return messages.stream().filter(m -> m.getMessage().equals(messageText)).collect(onlyElement());
+  }
+
+  /** Return the comment from the list of comments that has specific message text */
+  private CommentInfo getComment(List<CommentInfo> comments, String messageText) {
+    return comments.stream().filter(c -> c.message.equals(messageText)).collect(onlyElement());
   }
 }
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
index 9d7afbc..871c871 100644
--- a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
+++ b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
@@ -19,7 +19,7 @@
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
-import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
index d8af0e5..cf5e8fe 100644
--- a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -17,11 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import java.time.Instant;
@@ -72,12 +72,12 @@
   private static LabelType makeLabel(String labelName) {
     List<LabelValue> values = new ArrayList<>();
     // The label text is irrelevant here, only the numerical value is used
-    values.add(new LabelValue((short) -2, "-2"));
-    values.add(new LabelValue((short) -1, "-1"));
-    values.add(new LabelValue((short) 0, "No vote."));
-    values.add(new LabelValue((short) 1, "+1"));
-    values.add(new LabelValue((short) 2, "+2"));
-    return new LabelType(labelName, values);
+    values.add(LabelValue.create((short) -2, "-2"));
+    values.add(LabelValue.create((short) -1, "-1"));
+    values.add(LabelValue.create((short) 0, "No vote."));
+    values.add(LabelValue.create((short) 1, "+1"));
+    values.add(LabelValue.create((short) 2, "+2"));
+    return LabelType.create(labelName, values);
   }
 
   private static PatchSetApproval makeApproval(LabelId labelId, Account.Id accountId, int value) {
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index b65f4d2..7603631 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -23,11 +23,11 @@
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.GroupUuid;
@@ -44,12 +44,12 @@
 
 public class AllProjectsCreatorTest {
   private static final LabelType TEST_LABEL =
-      new LabelType(
+      LabelType.create(
           "Test-Label",
           ImmutableList.of(
-              new LabelValue((short) 2, "Two"),
-              new LabelValue((short) 0, "Zero"),
-              new LabelValue((short) 1, "One")));
+              LabelValue.create((short) 2, "Two"),
+              LabelValue.create((short) 0, "Zero"),
+              LabelValue.create((short) 1, "One")));
 
   private static final String TEST_LABEL_STRING =
       String.join(
@@ -102,7 +102,7 @@
 
   private GroupReference createGroupReference(String name) {
     AccountGroup.UUID groupUuid = GroupUuid.make(name, serverUser);
-    return new GroupReference(groupUuid, name);
+    return GroupReference.create(groupUuid, name);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index c92a8e0..bd673b5 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -18,10 +18,10 @@
 import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.project.ProjectConfig;
diff --git a/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
new file mode 100644
index 0000000..fb995fd
--- /dev/null
+++ b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
@@ -0,0 +1,213 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.entities.SubscribeSection;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.submit.SubscriptionGraph.DefaultFactory;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+
+public class SubscriptionGraphTest {
+  private static final String TEST_PATH = "test/path";
+  private static final Project.NameKey SUPER_PROJECT = Project.nameKey("Superproject");
+  private static final Project.NameKey SUB_PROJECT = Project.nameKey("Subproject");
+  private static final BranchNameKey SUPER_BRANCH =
+      BranchNameKey.create(SUPER_PROJECT, "refs/heads/one");
+  private static final BranchNameKey SUB_BRANCH =
+      BranchNameKey.create(SUB_PROJECT, "refs/heads/one");
+  private InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+  private MergeOpRepoManager mergeOpRepoManager;
+
+  @Mock GitModules.Factory mockGitModulesFactory = mock(GitModules.Factory.class);
+  @Mock ProjectCache mockProjectCache = mock(ProjectCache.class);
+  @Mock ProjectState mockProjectState = mock(ProjectState.class);
+
+  @Before
+  public void setUp() throws Exception {
+    when(mockProjectCache.get(any())).thenReturn(Optional.of(mockProjectState));
+    mergeOpRepoManager = new MergeOpRepoManager(repoManager, mockProjectCache, null, null);
+
+    GitModules emptyMockGitModules = mock(GitModules.class);
+    when(emptyMockGitModules.subscribedTo(any())).thenReturn(ImmutableSet.of());
+    when(mockGitModulesFactory.create(any(), any())).thenReturn(emptyMockGitModules);
+
+    TestRepository<Repository> superProject = createRepo(SUPER_PROJECT);
+    TestRepository<Repository> submoduleProject = createRepo(SUB_PROJECT);
+
+    // Make sure that SUPER_BRANCH and SUB_BRANCH can be subscribed.
+    allowSubscription(SUPER_BRANCH);
+    allowSubscription(SUB_BRANCH);
+
+    setSubscription(SUB_BRANCH, ImmutableList.of(SUPER_BRANCH));
+    setSubscription(SUPER_BRANCH, ImmutableList.of());
+    createBranch(
+        superProject, SUPER_BRANCH, superProject.commit().message("Initial commit").create());
+    createBranch(
+        submoduleProject, SUB_BRANCH, submoduleProject.commit().message("Initial commit").create());
+  }
+
+  @Test
+  public void oneSuperprojectOneSubmodule() throws Exception {
+    SubscriptionGraph.Factory factory = new DefaultFactory(mockGitModulesFactory, mockProjectCache);
+    SubscriptionGraph subscriptionGraph =
+        factory.compute(ImmutableSet.of(SUB_BRANCH), mergeOpRepoManager);
+
+    assertThat(subscriptionGraph.getAffectedSuperProjects()).containsExactly(SUPER_PROJECT);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(SUPER_PROJECT))
+        .containsExactly(SUPER_BRANCH);
+    assertThat(subscriptionGraph.getSubscriptions(SUPER_BRANCH))
+        .containsExactly(new SubmoduleSubscription(SUPER_BRANCH, SUB_BRANCH, TEST_PATH));
+    assertThat(subscriptionGraph.hasSuperproject(SUB_BRANCH)).isTrue();
+    assertThat(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches())
+        .containsExactly(SUB_BRANCH, SUPER_BRANCH)
+        .inOrder();
+  }
+
+  @Test
+  public void circularSubscription() throws Exception {
+    SubscriptionGraph.Factory factory = new DefaultFactory(mockGitModulesFactory, mockProjectCache);
+    setSubscription(SUPER_BRANCH, ImmutableList.of(SUB_BRANCH));
+    SubmoduleConflictException e =
+        assertThrows(
+            SubmoduleConflictException.class,
+            () -> factory.compute(ImmutableSet.of(SUB_BRANCH), mergeOpRepoManager));
+
+    String expectedErrorMessage =
+        "Subproject,refs/heads/one->Superproject,refs/heads/one->Subproject,refs/heads/one";
+    assertThat(e).hasMessageThat().contains(expectedErrorMessage);
+  }
+
+  @Test
+  public void multipleSuperprojectsToMultipleSubmodules() throws Exception {
+    // Create superprojects and subprojects.
+    Project.NameKey superProject1 = Project.nameKey("superproject1");
+    Project.NameKey superProject2 = Project.nameKey("superproject2");
+    Project.NameKey subProject1 = Project.nameKey("subproject1");
+    Project.NameKey subProject2 = Project.nameKey("subproject2");
+    TestRepository<Repository> superProjectRepo1 = createRepo(superProject1);
+    TestRepository<Repository> superProjectRepo2 = createRepo(superProject2);
+    TestRepository<Repository> submoduleRepo1 = createRepo(subProject1);
+    TestRepository<Repository> submoduleRepo2 = createRepo(subProject2);
+
+    // Initialize super branches.
+    BranchNameKey superBranch1 = BranchNameKey.create(superProject1, "refs/heads/one");
+    BranchNameKey superBranch2 = BranchNameKey.create(superProject2, "refs/heads/one");
+    createBranch(
+        superProjectRepo1,
+        superBranch1,
+        superProjectRepo1.commit().message("Initial commit").create());
+    createBranch(
+        superProjectRepo2,
+        superBranch2,
+        superProjectRepo2.commit().message("Initial commit").create());
+
+    // Initialize sub branches.
+    BranchNameKey submoduleBranch1 = BranchNameKey.create(subProject1, "refs/heads/one");
+    BranchNameKey submoduleBranch2 = BranchNameKey.create(subProject1, "refs/heads/two");
+    BranchNameKey submoduleBranch3 = BranchNameKey.create(subProject2, "refs/heads/one");
+    createBranch(
+        submoduleRepo1, submoduleBranch1, submoduleRepo1.commit().message("Commit1").create());
+    createBranch(
+        submoduleRepo1, submoduleBranch2, submoduleRepo1.commit().message("Commit2").create());
+    createBranch(
+        submoduleRepo2, submoduleBranch3, submoduleRepo2.commit().message("Commit1").create());
+
+    allowSubscription(submoduleBranch1);
+    allowSubscription(submoduleBranch2);
+    allowSubscription(submoduleBranch3);
+
+    // Initialize subscriptions.
+    setSubscription(submoduleBranch1, ImmutableList.of(superBranch1, superBranch2));
+    setSubscription(submoduleBranch2, ImmutableList.of(superBranch1));
+    setSubscription(submoduleBranch3, ImmutableList.of(superBranch1, superBranch2));
+
+    SubscriptionGraph.Factory factory = new DefaultFactory(mockGitModulesFactory, mockProjectCache);
+    SubscriptionGraph subscriptionGraph =
+        factory.compute(ImmutableSet.of(submoduleBranch1, submoduleBranch2), mergeOpRepoManager);
+
+    assertThat(subscriptionGraph.getAffectedSuperProjects())
+        .containsExactly(superProject1, superProject2);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(superProject1))
+        .containsExactly(superBranch1);
+    assertThat(subscriptionGraph.getAffectedSuperBranches(superProject2))
+        .containsExactly(superBranch2);
+
+    assertThat(subscriptionGraph.getSubscriptions(superBranch1))
+        .containsExactly(
+            new SubmoduleSubscription(superBranch1, submoduleBranch1, TEST_PATH),
+            new SubmoduleSubscription(superBranch1, submoduleBranch2, TEST_PATH));
+    assertThat(subscriptionGraph.getSubscriptions(superBranch2))
+        .containsExactly(new SubmoduleSubscription(superBranch2, submoduleBranch1, TEST_PATH));
+
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch1)).isTrue();
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch2)).isTrue();
+    assertThat(subscriptionGraph.hasSuperproject(submoduleBranch3)).isFalse();
+
+    assertThat(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches())
+        .containsExactly(submoduleBranch2, submoduleBranch1, superBranch2, superBranch1)
+        .inOrder();
+  }
+
+  private TestRepository<Repository> createRepo(Project.NameKey project) throws Exception {
+    Repository repo = repoManager.createRepository(project);
+    return new TestRepository<>(repo);
+  }
+
+  private void createBranch(TestRepository<Repository> repo, BranchNameKey branch, RevCommit commit)
+      throws Exception {
+    repo.update(branch.branch(), commit);
+  }
+
+  private void allowSubscription(BranchNameKey branch) {
+    SubscribeSection.Builder s = SubscribeSection.builder(branch.project());
+    s.addMultiMatchRefSpec("refs/heads/*:refs/heads/*");
+    when(mockProjectState.getSubscribeSections(branch)).thenReturn(ImmutableSet.of(s.build()));
+  }
+
+  private void setSubscription(
+      BranchNameKey submoduleBranch, List<BranchNameKey> superprojectBranches) {
+    List<SubmoduleSubscription> subscriptions =
+        superprojectBranches.stream()
+            .map(
+                (targetBranch) ->
+                    new SubmoduleSubscription(targetBranch, submoduleBranch, TEST_PATH))
+            .collect(Collectors.toList());
+    GitModules mockGitModules = mock(GitModules.class);
+    when(mockGitModules.subscribedTo(submoduleBranch)).thenReturn(subscriptions);
+    when(mockGitModulesFactory.create(submoduleBranch, mergeOpRepoManager))
+        .thenReturn(mockGitModules);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 083493d..287a7fe 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -22,12 +22,12 @@
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.ChangeInserter;
diff --git a/lib/BUILD b/lib/BUILD
index d3ef4b9..0110047 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -160,7 +160,7 @@
     name = "args4j",
     data = ["//lib:LICENSE-args4j"],
     visibility = ["//visibility:public"],
-    exports = ["@args4j-intern//jar"],
+    exports = ["@args4j//jar"],
 )
 
 java_library(
diff --git a/lib/polymer_externs/BUILD b/lib/polymer_externs/BUILD
deleted file mode 100644
index f07aa2f..0000000
--- a/lib/polymer_externs/BUILD
+++ /dev/null
@@ -1,24 +0,0 @@
-# Copyright (C) 2017 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library")
-
-package(default_visibility = ["//visibility:public"])
-
-closure_js_library(
-    name = "polymer_closure",
-    srcs = ["@polymer_closure//file"],
-    data = ["//lib:LICENSE-Apache2.0"],
-    no_closure_library = True,
-)
diff --git a/modules/jgit b/modules/jgit
index 246954e..9fe5406 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 246954e0d66a1e38282d0786f10df8da54911628
+Subproject commit 9fe54061197c42faedc9417bdc70797681aa06d6
diff --git a/package.json b/package.json
index 5b9046d..a6641b9 100644
--- a/package.json
+++ b/package.json
@@ -4,27 +4,33 @@
   "description": "Gerrit Code Review",
   "dependencies": {},
   "devDependencies": {
-    "@bazel/rollup": "^1.1.0",
-    "@bazel/typescript": "^1.0.1",
+    "@bazel/rollup": "^2.0.0",
+    "@bazel/terser": "^2.0.0",
+    "@bazel/typescript": "^2.0.0",
     "eslint": "^6.6.0",
     "eslint-config-google": "^0.13.0",
     "eslint-plugin-html": "^6.0.0",
     "eslint-plugin-import": "^2.20.1",
     "eslint-plugin-jsdoc": "^19.2.0",
     "eslint-plugin-prettier": "^3.1.3",
+    "gts": "^2.0.2",
     "polymer-cli": "^1.9.11",
     "prettier": "2.0.5",
-    "typescript": "^3.7.4",
-    "web-component-tester": "^6.5.1"
+    "terser": "^4.8.0",
+    "typescript": "3.8.2"
   },
   "scripts": {
     "clean": "git clean -fdx && bazel clean --expunge",
+    "compile:local": "tsc --project ./polygerrit-ui/app/tsconfig.json",
+    "compile:watch": "npm run compile:local -- --preserveWatchOutput --watch",
     "start": "polygerrit-ui/run-server.sh",
-    "test": "WCT_HEADLESS_MODE=1 WCT_ARGS='--verbose -l chrome' ./polygerrit-ui/app/run_test.sh",
+    "test": "./polygerrit-ui/app/run_test.sh",
     "safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
-    "polylint": "npm run safe_bazelisk test polygerrit-ui/app:polylint_test"
+    "polylint": "npm run safe_bazelisk test polygerrit-ui/app:polylint_test",
+    "test:debug": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --browsers ChromeDev --no-single-run --testFiles",
+    "test:single": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --testFiles"
   },
   "repository": {
     "type": "git",
diff --git a/plugins/BUILD b/plugins/BUILD
index a071bde..943471a 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -49,6 +49,7 @@
     "//java/com/google/gerrit/server/audit",
     "//java/com/google/gerrit/server/cache/mem",
     "//java/com/google/gerrit/server/cache/serialize",
+    "//java/com/google/gerrit/server/data",
     "//java/com/google/gerrit/server/logging",
     "//java/com/google/gerrit/server/schema",
     "//java/com/google/gerrit/server/util/time",
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index e211fb1..7357ab4 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit e211fb1bd21043e2574c438a687c8f492d538c97
+Subproject commit 7357ab473599d16ae33cc982bbd65472f08c2dd6
diff --git a/plugins/delete-project b/plugins/delete-project
index e345e6e..516fbd8 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit e345e6e79900a72981e4ad19d37c7fbdcae4818b
+Subproject commit 516fbd8aebfcc49b278b0eb985add293d753bb3f
diff --git a/plugins/download-commands b/plugins/download-commands
index e26ed31..fd650ca 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit e26ed31aaf070ff884e96b9a09d39c20437de6cb
+Subproject commit fd650ca386c382b42d30e7ad72279bfeb311aee4
diff --git a/plugins/package.json b/plugins/package.json
new file mode 100644
index 0000000..e0227d1
--- /dev/null
+++ b/plugins/package.json
@@ -0,0 +1,8 @@
+{
+    "name": "polygerrit-plugin-dependencies-placeholder",
+    "description": "Gerrit Code Review - Polygerrit plugin dependencies placeholder, expected to be overriden by plugins",
+    "browser": true,
+    "dependencies": {},
+    "license": "Apache-2.0",
+    "private": true
+}
\ No newline at end of file
diff --git a/plugins/replication b/plugins/replication
index f301819..9a07d19 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit f3018192441107e430294c7ddfc44d72e74061e8
+Subproject commit 9a07d19326cab1dccbab5696c31f89dc8cb2e8a6
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 9e7fd9b..fb0390a 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 9e7fd9b420ac9a5caa045cf82b566cc0b51c93ad
+Subproject commit fb0390a8b49f0d601e11f8a1ac0658c429727f21
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index d04c4c3..58ee52a 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit d04c4c33ad36e2e11ccc8b798357dd1e4e979a1a
+Subproject commit 58ee52a8670e38f30785bfbb648ba27c61c3a202
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
new file mode 100644
index 0000000..a63f96e
--- /dev/null
+++ b/plugins/yarn.lock
@@ -0,0 +1,3 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+# This is an empty placeholder
\ No newline at end of file
diff --git a/polygerrit-ui/.gitignore b/polygerrit-ui/.gitignore
index 7f06bef..7b33a59 100644
--- a/polygerrit-ui/.gitignore
+++ b/polygerrit-ui/.gitignore
@@ -4,4 +4,4 @@
 fonts
 bower_components
 .tmp
-.vscode
\ No newline at end of file
+.vscode
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index a029df4..7bca96d 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -32,3 +32,43 @@
         "@org_golang_x_tools//godoc/vfs/zipfs:go_default_library",
     ],
 )
+
+# Define a karma+plugins binary to run karma-mocha tests.
+# Can be reused multiple time, if there are multiple karma test rules
+sh_binary(
+    name = "karma_bin",
+    srcs = ["@ui_dev_npm//:node_modules/karma/bin/karma"],
+    data = [
+        "@ui_dev_npm//@open-wc/karma-esm",
+        "@ui_dev_npm//chai",
+        "@ui_dev_npm//karma-chrome-launcher",
+        "@ui_dev_npm//karma-mocha",
+        "@ui_dev_npm//karma-mocha-reporter",
+        "@ui_dev_npm//karma/bin:karma",
+        "@ui_dev_npm//mocha",
+    ],
+)
+
+# Run all tests in one.
+# TODO(dmfilippov): allow parallel tests for karma - either on the bazel level
+# or on the karma level. For now single sh_test is enough.
+sh_test(
+    name = "karma_test",
+    size = "enormous",
+    srcs = ["karma_test.sh"],
+    args = [
+        "$(location :karma_bin)",
+        "$(location karma.conf.js)",
+    ],
+    data = [
+        "karma.conf.js",
+        ":karma_bin",
+        "//polygerrit-ui/app:test-srcs-fg",
+    ],
+    # Should not run sandboxed.
+    tags = [
+        "karma",
+        "local",
+        "manual",
+    ],
+)
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
new file mode 100644
index 0000000..c9a5d9b
--- /dev/null
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -0,0 +1,263 @@
+# Gerrit JavaScript style guide
+
+Gerrit frontend follows [recommended eslint rules](https://eslint.org/docs/rules/)
+and [Google JavaScript Style Guide](https://google.github.io/styleguide/jsguide.html).
+Eslint is used to automate rules checking where possible. You can find exact eslint rules
+[here](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/.eslintrc.js).
+
+Gerrit JavaScript code uses ES6 modules and doesn't use goog.module files.
+
+Additionally to the rules above, Gerrit frontend uses the following rules (some of them have automated checks,
+some don't):
+
+- [Use destructuring imports only](#destructuring-imports-only)
+- [Use classes and services for storing and manipulating global state](#services-for-global-state)
+- [Pass required services in the constructor for plain classes](#pass-dependencies-in-constructor)
+- [Assign required services in a HTML/Polymer element constructor](#assign-dependencies-in-html-element-constructor)
+
+## <a name="destructuring-imports-only"></a>Use destructuring imports only
+Always use destructuring import statement and specify all required names explicitly (e.g. `import {a,b,c} from '...'`)
+where possible.
+
+**Note:** Destructuring imports are not always possible with 3rd-party libraries, because a 3rd-party library
+can expose a class/function/const/etc... as a default export. In this situation you can use default import, but please
+keep consistent naming across the whole gerrit project. The best way to keep consistency is to search across our
+codebase for the same import. If you find an exact match - always use the same name for your import. If you can't
+find exact matches - find a similar import and assign appropriate/similar name for your default import. Usually the
+name should include a library name and part of the file path.
+
+You can read more about different type of imports
+[here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import).
+
+**Good:**
+```Javascript
+// Import from the module in the same project.
+import {getDisplayName, getAccount} from './user-utils.js'
+
+// The following default import is allowed only for 3rd-party libraries.
+// Please ensure, that all imports have the same name accross gerrit project (downloadImage in this example)
+import downloadImage from 'third-party-library/images/download.js'
+```
+
+**Bad:**
+```Javascript
+import * as userUtils from './user-utils.js'
+```
+
+## <a name="services-for-global-state"></a>Use classes and services for storing and manipulating global state
+
+You must use classes and services to share global state across the gerrit frontend code. Do not put a state at the
+top level of a module.
+
+It is not easy to define precise what can be a shared global state and what is not. Below are some
+examples of what can treated as a shared global state:
+
+* Information about enabled experiments
+* Information about current user
+* Information about current change
+
+**Note:**
+
+Service name must ends with a `Service` suffix.
+
+To share global state across modules in the project, do the following:
+- put the state in a class
+- add a new service to the
+[appContext](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/services/app-context.js)
+- add a service initialization code to the
+[services/app-context-init.js](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/services/app-context-init.js) file.
+- add a service or service-mock initialization code to the
+[embed/app-context-init.js](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/embed/app-context-init.js) file.
+- recommended: add a separate service-mock for testing. Do not use the same mock for testing and for
+the shared gr-diff (i.e. in the `services/app-context-init.js`). Even if the mocks are simple and looks
+identically, keep them separate. It allows to change them independently in the future.
+
+Also see the example below if a service depends on another services.
+
+**Note 1:** Be carefull with the shared gr-diff element. If a service is not required for the shared gr-diff,
+the safest option is to provide a mock for this service in the embed/app-context-init.js file. In exceptional
+cases you can keep the service uninitialized in
+[embed/app-context-init.js](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/embed/app-context-init.js) file
+, but it is recommended to write a comment why mocking is not possible. In the future we can
+review/update rules regarding the shared gr-diff element.
+
+**Good:**
+```Javascript
+export class CounterService {
+    constructor() {
+        this._count = 0;
+    }
+    get count() {
+        return this._count;
+    }
+    inc() {
+        this._count++;
+    }
+}
+
+// app-context.js
+export const appContext = {
+    //...
+    mouseClickCounterService: null,
+    keypressCounterService: null,
+};
+
+// services/app-context-init.js
+export function initAppContext() {
+    //...
+    // Add the following line before the Object.defineProperties(appContext, registeredServices);
+    addService('mouseClickCounterService', () => new CounterService());
+    addService('keypressCounterService', () => new CounterService());
+    // If a service depends on other services, pass dependencies as shown below
+    // If circular dependencies exist, app-init-context tests fail with timeout or stack overflow
+    // (we are  going to improve it in the future)
+    addService('analyticService', () =>
+        new CounterService(appContext.mouseClickCounterService, appContext.keypressCounterService));
+    //...
+    // This following line must remains the last one in the initAppContext
+    Object.defineProperties(appContext, registeredServices);
+}
+```
+
+**Bad:**
+```Javascript
+// module counter.js
+// Incorrect: shared state declared at the top level of the counter.js module
+let count = 0;
+export function getCount() {
+    return count;
+}
+export function incCount() {
+    count++;
+}
+```
+
+## <a name="pass-dependencies-in-constructor"></a>Pass required services in the constructor for plain classes
+
+If a class/service depends on some other service (or multiple services), the class must accept all dependencies
+as parameters in the constructor.
+
+Do not use appContext anywhere else in a class.
+
+**Note:** This rule doesn't apply for HTML/Polymer elements classes. A browser creates instances of such classes
+implicitly and calls the constructor without parameters. See
+[Assign required services in a HTML/Polymer element constructor](#assign-dependencies-in-html-element-constructor)
+
+**Good:**
+```Javascript
+export class UserService {
+    constructor(restApiService) {
+        this._restApiService = restApiService;
+    }
+    getLoggedIn() {
+        // Send request to server using this._restApiService
+    }
+}
+```
+
+**Bad:**
+```Javascript
+import {appContext} from "./app-context";
+
+export class UserService {
+    constructor() {
+        // Incorrect: you must pass all dependencies to a constructor
+        this._restApiService = appContext.restApiService;
+    }
+}
+
+export class AdminService {
+    isAdmin() {
+        // Incorrect: you must pass all dependencies to a constructor
+        return appContext.restApiService.sendRequest(...);
+    }
+}
+
+```
+
+## <a name="assign-dependencies-in-html-element-constructor"></a>Assign required services in a HTML/Polymer element constructor
+If a class is a custom HTML/Polymer element, the class must assign all required services in the constructor.
+A browser creates instances of such classes implicitly, so it is impossible to pass anything as a parameter to
+the element's class constructor.
+
+Do not use appContext anywhere except the constructor of the class.
+
+**Note for legacy elements:** If a polymer element extends a LegacyElementMixin and overrides the `created()` method,
+move all code from this method to a constructor right after the call to a `super()`
+([example](#assign-dependencies-legacy-element-example)). The `created()`
+method is [deprecated](https://polymer-library.polymer-project.org/2.0/docs/about_20#lifecycle-changes) and is called
+when a super (i.e. base) class constructor is called. If you are unsure about moving the code from the `created` method
+to the class constructor, consult with the source code:
+[`LegacyElementMixin._initializeProperties`](https://github.com/Polymer/polymer/blob/v3.4.0/lib/legacy/legacy-element-mixin.js#L318)
+and
+[`PropertiesChanged.constructor`](https://github.com/Polymer/polymer/blob/v3.4.0/lib/mixins/properties-changed.js#L177)
+
+
+
+**Good:**
+```Javascript
+import {appContext} from `.../services/app-context.js`;
+
+export class MyCustomElement extends ...{
+    constructor() {
+        super(); //This is mandatory to call parent constructor
+        this._userService = appContext.userService;
+    }
+    //...
+    _getUserName() {
+        return this._userService.activeUserName();
+    }
+}
+```
+
+**Bad:**
+```Javascript
+import {appContext} from `.../services/app-context.js`;
+
+export class MyCustomElement extends ...{
+    created() {
+        // Incorrect: assign all dependencies in the constructor
+        this._userService = appContext.userService;
+    }
+    //...
+    _getUserName() {
+        // Incorrect: use appContext outside of a constructor
+        return appContext.userService.activeUserName();
+    }
+}
+```
+
+<a name="assign-dependencies-legacy-element-example"></a>
+**Legacy element:**
+
+Before:
+```Javascript
+export class MyCustomElement extends ...LegacyElementMixin(...) {
+    constructor() {
+        super();
+        someAction();
+    }
+    created() {
+        super();
+        createdAction1();
+        createdAction2();
+    }
+}
+```
+
+After:
+```Javascript
+export class MyCustomElement extends ...LegacyElementMixin(...) {
+    constructor() {
+        super();
+        // Assign services here
+        this._userService = appContext.userService;
+        // Code from the created method - put it before existing actions in constructor
+        createdAction1();
+        createdAction2();
+        // Original constructor code
+        someAction();
+    }
+    // created method is removed
+}
+```
diff --git a/polygerrit-ui/Polymer3.md b/polygerrit-ui/Polymer3.md
index 94750d8..186f0f4 100644
--- a/polygerrit-ui/Polymer3.md
+++ b/polygerrit-ui/Polymer3.md
@@ -14,6 +14,11 @@
 
 To get inspirations, check out our [samples here](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples).
 
+### Plugin dependencies
+
+Since most of Gerrit plugins are treated as sub modules and part of the Gerrit workspace when develop, dependencies of plugins are also defined and installed from Gerrit WORKSPACE, currently most of them are `bower_archives`. When moving to npm, if your plugin requires dependencies, you can have them added to your plugin's `package.json` and then link that file to `plugins/package.json` in gerrit.
+Then use `@plugins_npm//:node_modules` to make sure `rollup_bundle` knows the right place to look for. More examples from `image-diff` plugin, [change 271672](https://gerrit-review.googlesource.com/c/plugins/image-diff/+/271672).
+
 ### Related resources
 
 - [Polymer 3.0 upgrade guide](https://polymer-library.polymer-project.org/3.0/docs/upgrade)
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 1cd8096..3e95e42 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -1,5 +1,12 @@
 # Gerrit Polymer Frontend
 
+**Warning**: DON'T ADD MORE TYPESCRIPT FILES/TYPES. Gerrit Polymer Frontend
+contains several typescript files and uses typescript compiler. This is a
+preparation for the upcoming migration to typescript and we actively working on
+it. We want to avoid massive typescript-related changes until the preparation
+work is done. Thanks for your understanding!
+
+
 Follow the
 [setup instructions for Gerrit backend developers](https://gerrit-review.googlesource.com/Documentation/dev-readme.html)
 where applicable, the most important command is:
@@ -74,6 +81,20 @@
 
 More information for installing and using nodejs rules can be found here https://bazelbuild.github.io/rules_nodejs/install.html
 
+## Setup typescript support in the IDE
+
+Modern IDE should automatically handle typescript settings from the 
+`pollygerrit-ui/app/tsconfig.json` files. IDE places compiled files in the
+`.ts-out/pg` directory at the root of gerrit workspace and you can configure IDE
+to exclude the whole .ts-out directory. To do it in the IntelliJ IDEA click on
+this directory and select "Mark Directory As > Excluded" in the context menu.
+
+However, if you receive some errors from IDE, you can try to configure IDE
+manually. For example, if IntelliJ IDEA shows
+`Cannot find parent 'tsconfig.json'` error, you can try to setup typescript
+options `--project polygerrit-ui/app/tsconfig.json` in the IDE settings.
+
+
 ## Serving files locally
 
 #### Go server
@@ -148,29 +169,61 @@
 ## Running Tests
 
 For daily development you typically only want to run and debug individual tests.
-Run the local [Go proxy server](#go-server) and navigate for example to
-<http://localhost:8081/elements/shared/gr-account-entry/gr-account-entry_test.html>.
-Check "Disable cache" in the "Network" tab of Chrome's dev tools, so code
-changes are picked up on "reload".
+There are several ways to run tests.
 
-Our CI integration ensures that all tests are run when you upload a change to
-Gerrit, but you can also run all tests locally in headless mode:
-
+* Run all tests in headless mode (exactly like CI does):
 ```sh
-npm test
+npm run test
+```
+This command uses bazel rules for running frontend tests. Bazel fetches
+all nessecary dependencies and runs all required rules.
+
+* Run all tests in debug mode (the command opens Chrome browser with
+the default Karma page; you should click the "Debug" button to start testing):
+```sh
+# The following command doesn't compile code before tests
+npm run test:debug
 ```
 
-To allow the tests to run in Safari:
+* Run a single test file:
+```
+# Headless mode (doesn't compile code before run)
+npm run test:single async-foreach-behavior_test.js
 
-* In the Advanced preferences tab, check "Show Develop menu in menu bar".
-* In the Develop menu, enable the "Allow Remote Automation" option.
+# Debug mode (doesn't compile code before run)
+npm run test:debug async-foreach-behavior_test.js
+```
 
-To run Chrome tests in headless mode:
+Commands `test:debug` and `test:single` assumes that compiled code is located
+in the `./ts-out/polygerrit-ui/app` directory. It's up to you how to achieve it.
+For example, the following options are possible:
+* You can configure IDE for recompiling source code on changes
+* You can use `compile:local` command for running compiler once and
+`compile:watch` for running compiler in watch mode (`compile:...` places
+compile code exactly in the `./ts-out/polygerrit-ui/app` directory)
 
 ```sh
-WCT_HEADLESS_MODE=1 WCT_ARGS='--verbose -l chrome' ./polygerrit-ui/app/run_test.sh
+# Compile frontend once and run tests from a file:
+npm run compile:local && npm run test:single async-foreach-behavior_test.js
+
+# Watch mode:
+## Terminal 1:
+npm run compile:watch
+## Terminal 2:
+npm run test:debug async-foreach-behavior_test.js
 ```
 
+* You can run tests in IDE. :
+  - [IntelliJ: running unit tests on Karma](https://www.jetbrains.com/help/idea/running-unit-tests-on-karma.html#ws_karma_running)
+  - You should configure IDE to compile typescript before running tests.
+
+**NOTE**: Bazel plugin for IntelliJ has a bug - it recompiles typescript
+project only if .ts and/or .d.ts files have been changed. If only .js files
+were changed, the plugin doesn't run compiler. As a workaround, setup
+"Run npm script 'compile:local" action instead of the "Compile Typescript" in
+the "Before launch" section for IntelliJ. This is a temporary problem until
+typescript migration is complete.
+
 ## Style guide
 
 We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
@@ -258,4 +311,4 @@
 
 // install all dependencies and start the server
 npm start
-```
\ No newline at end of file
+```
diff --git a/polygerrit-ui/app/.eslint-ts-resolver.js b/polygerrit-ui/app/.eslint-ts-resolver.js
new file mode 100644
index 0000000..dc578f9
--- /dev/null
+++ b/polygerrit-ui/app/.eslint-ts-resolver.js
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This is a very simple resolver for the 'js imports ts' case. It is used only
+ * by eslint and must be removed after switching to typescript is finished.
+ * The resolver searches for .ts files instead of .js
+ */
+
+const path = require('path');
+const fs = require('fs');
+
+function isRelativeImport(source) {
+  return source.startsWith('./') || source.startsWith('../');
+}
+
+module.exports = {
+  interfaceVersion: 2,
+  resolve: function(source, file, config) {
+    if (!isRelativeImport(source) || !source.endsWith('.js')) {
+      return {found: false};
+    }
+    const tsSource = source.slice(0, -3) + '.ts';
+
+    const fullPath = path.resolve(path.dirname(file), tsSource);
+    if (!fs.existsSync(fullPath)) {
+      return {found: false};
+    }
+    return {found: true, path: fullPath};
+  }
+};
diff --git a/polygerrit-ui/app/.eslintignore b/polygerrit-ui/app/.eslintignore
index 6d9c8f3..16ea228 100644
--- a/polygerrit-ui/app/.eslintignore
+++ b/polygerrit-ui/app/.eslintignore
@@ -1,2 +1,3 @@
 **/node_modules
 **/rollup.config.js
+node_modules_licenses
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 42a6564..bf1fcc6 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -17,6 +17,7 @@
 
 // Do not add any bazel-specific properties in this file to keep it clean.
 // Please add such properties to the .eslintrc-bazel.js file
+const path = require('path');
 
 module.exports = {
   "extends": ["eslint:recommended", "google"],
@@ -29,14 +30,22 @@
     "es6": true
   },
   "rules": {
+    // https://eslint.org/docs/rules/no-confusing-arrow
     "no-confusing-arrow": "error",
+    // https://eslint.org/docs/rules/newline-per-chained-call
     "newline-per-chained-call": ["error", {"ignoreChainWithDepth": 2}],
+    // https://eslint.org/docs/rules/arrow-body-style
     "arrow-body-style": ["error", "as-needed",
       {"requireReturnForObjectLiteral": true}],
+    // https://eslint.org/docs/rules/arrow-parens
     "arrow-parens": ["error", "as-needed"],
+    // https://eslint.org/docs/rules/block-spacing
     "block-spacing": ["error", "always"],
+    // https://eslint.org/docs/rules/brace-style
     "brace-style": ["error", "1tbs", {"allowSingleLine": true}],
+    // https://eslint.org/docs/rules/camelcase
     "camelcase": "off",
+    // https://eslint.org/docs/rules/comma-dangle
     "comma-dangle": ["error", {
       "arrays": "always-multiline",
       "objects": "always-multiline",
@@ -44,7 +53,9 @@
       "exports": "always-multiline",
       "functions": "never"
     }],
+    // https://eslint.org/docs/rules/eol-last
     "eol-last": "off",
+    // https://eslint.org/docs/rules/indent
     "indent": ["error", 2, {
       "MemberExpression": 2,
       "FunctionDeclaration": {"body": 1, "parameters": 2},
@@ -54,8 +65,11 @@
       "ObjectExpression": 1,
       "SwitchCase": 1
     }],
+    // https://eslint.org/docs/rules/keyword-spacing
     "keyword-spacing": ["error", {"after": true, "before": true}],
+    // https://eslint.org/docs/rules/lines-between-class-members
     "lines-between-class-members": ["error", "always"],
+    // https://eslint.org/docs/rules/max-len
     "max-len": [
       "error",
       80,
@@ -65,14 +79,26 @@
         "ignorePattern": "^import .*;$"
       }
     ],
+    // https://eslint.org/docs/rules/new-cap
     "new-cap": ["error", {
-      "capIsNewExceptions": ["Polymer", "LegacyElementMixin",
-        "GestureEventListeners", "LegacyDataMixin"]
+      "capIsNewExceptions": ["Polymer", "GestureEventListeners"],
+      "capIsNewExceptionPattern": "^.*Mixin$"
     }],
-    "no-console": "off",
+    // https://eslint.org/docs/rules/no-console
+    "no-console": ["error", { allow: ["warn", "error", "info", "assert", "group", "groupEnd"] }],
+    // https://eslint.org/docs/rules/no-multiple-empty-lines
     "no-multiple-empty-lines": ["error", {"max": 1}],
+    // https://eslint.org/docs/rules/no-prototype-builtins
     "no-prototype-builtins": "off",
+    // https://eslint.org/docs/rules/no-redeclare
     "no-redeclare": "off",
+    // https://eslint.org/docs/rules/no-trailing-spaces
+    "no-trailing-spaces": "error",
+    // https://eslint.org/docs/rules/no-irregular-whitespace
+    "no-irregular-whitespace": "error",
+    // https://eslint.org/docs/rules/array-callback-return
+    "array-callback-return": ['error', { allowImplicit: true }],
+    // https://eslint.org/docs/rules/no-restricted-syntax
     "no-restricted-syntax": [
       "error",
       {
@@ -86,11 +112,17 @@
     ],
     // no-undef disables global variable.
     // "globals" declares allowed global variables.
+    // https://eslint.org/docs/rules/no-undef
     "no-undef": ["error"],
+    // https://eslint.org/docs/rules/no-useless-escape
     "no-useless-escape": "off",
+    // https://eslint.org/docs/rules/no-var
     "no-var": "error",
+    // https://eslint.org/docs/rules/operator-linebreak
     "operator-linebreak": "off",
+    // https://eslint.org/docs/rules/object-shorthand
     "object-shorthand": ["error", "always"],
+    // https://eslint.org/docs/rules/padding-line-between-statements
     "padding-line-between-statements": [
       "error",
       {
@@ -104,42 +136,76 @@
         "next": "class"
       }
     ],
+    // https://eslint.org/docs/rules/prefer-arrow-callback
     "prefer-arrow-callback": "error",
+    // https://eslint.org/docs/rules/prefer-const
     "prefer-const": "error",
+    // https://eslint.org/docs/rules/prefer-promise-reject-errors
     "prefer-promise-reject-errors": "error",
+    // https://eslint.org/docs/rules/prefer-spread
     "prefer-spread": "error",
+    // https://eslint.org/docs/rules/prefer-object-spread
+    "prefer-object-spread": "error",
+    // https://eslint.org/docs/rules/quote-props
     "quote-props": ["error", "consistent-as-needed"],
-    "semi": [2, "always"],
+    // https://eslint.org/docs/rules/semi
+    "semi": ["error", "always"],
+    // https://eslint.org/docs/rules/template-curly-spacing
     "template-curly-spacing": "error",
 
+    // https://eslint.org/docs/rules/require-jsdoc
     "require-jsdoc": 0,
+    // https://eslint.org/docs/rules/valid-jsdoc
     "valid-jsdoc": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-alignment
     "jsdoc/check-alignment": 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-examples
     "jsdoc/check-examples": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-indentation
     "jsdoc/check-indentation": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-param-names
     "jsdoc/check-param-names": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-syntax
     "jsdoc/check-syntax": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-tag-names
     "jsdoc/check-tag-names": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-types
     "jsdoc/check-types": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-implements-on-classes
     "jsdoc/implements-on-classes": 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-match-description
     "jsdoc/match-description": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-newline-after-description
     "jsdoc/newline-after-description": 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-types
     "jsdoc/no-types": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-undefined-types
     "jsdoc/no-undefined-types": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-description
     "jsdoc/require-description": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-description-complete-sentence
     "jsdoc/require-description-complete-sentence": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-example
     "jsdoc/require-example": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-hyphen-before-param-description
     "jsdoc/require-hyphen-before-param-description": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-jsdoc
     "jsdoc/require-jsdoc": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param
     "jsdoc/require-param": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-description
     "jsdoc/require-param-description": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-name
     "jsdoc/require-param-name": 2,
-    "jsdoc/require-param-type": 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns
     "jsdoc/require-returns": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-check
     "jsdoc/require-returns-check": 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-description
     "jsdoc/require-returns-description": 0,
-    "jsdoc/require-returns-type": 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-valid-types
     "jsdoc/valid-types": 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-file-overview
     "jsdoc/require-file-overview": ["error", {
       "tags": {
         "license": {
@@ -148,15 +214,21 @@
         }
       }
     }],
-    "import/named": 2,
-    "import/no-unresolved": 2,
+    // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-self-import.md
     "import/no-self-import": 2,
     // The no-cycle rule is slow, because it doesn't cache dependencies.
     // Disable it.
+    // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-cycle.md
     "import/no-cycle": 0,
+    // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-useless-path-segments.md
     "import/no-useless-path-segments": 2,
+    // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-unused-modules.md
     "import/no-unused-modules": 2,
+    // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-default-export.md
     "import/no-default-export": 2,
+    // Custom rule from the //tools/js/eslint-rules directory.
+    // See //tools/js/eslint-rules/README.md for details
+    "goog-module-id": 2,
   },
 
   // List of allowed globals in all files
@@ -165,24 +237,72 @@
     // You must not add anything new in this list!
     // Instead export variables from modules
     // TODO(dmfilippov): Remove global variables from polygerrit
-    "GrReporting": "readonly",
     // Global variables from 3rd party libraries.
     // You should not add anything in this list, always try to import
     // If import is not possible - you can extend this list
-    "Polymer": "readonly",
     "ShadyCSS": "readonly",
     "linkify": "readonly",
     "security": "readonly",
   },
   "overrides": [
     {
+      // .js-only rules
+      "files": ["**/*.js"],
+      "rules": {
+        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-type
+        "jsdoc/require-param-type": 2,
+        // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-type
+        "jsdoc/require-returns-type": 2,
+        // The rule is required for .js files only, because typescript compiler
+        // always checks import.
+        "import/no-unresolved": 2,
+        "import/named": 2,
+      },
+      "globals": {
+        "goog": "readonly",
+      }
+    },
+    {
+      "files": ["**/*.ts"],
+      "extends": [require.resolve("gts/.eslintrc.json")],
+      "rules": {
+        // The following rules is required to match internal google rules
+        "@typescript-eslint/restrict-plus-operands": "error",
+      },
+      "parserOptions": {
+        "project": path.resolve(__dirname, "./tsconfig_eslint.json"),
+      }
+    },
+    {
+      "files": ["**/*.ts"],
+      "excludedFiles": "*.d.ts",
+      "rules": {
+        // Custom rule from the //tools/js/eslint-rules directory.
+        // See //tools/js/eslint-rules/README.md for details
+        "ts-imports-js": 2,
+      }
+    },
+    {
+      "files": ["**/*.d.ts"],
+      "rules": {
+        // See details in the //tools/js/eslint-rules/report-ts-error.js file.
+        "report-ts-error": "error",
+      }
+    },
+    {
       "files": ["*.html", "test.js", "test-infra.js"],
       "rules": {
         "jsdoc/require-file-overview": "off"
       },
     },
     {
-      "files": ["*.html", "common-test-setup.js"],
+      "files": [
+        "*.html",
+        "common-test-setup.js",
+        "common-test-setup-karma.js",
+        "*_test.js",
+        "a11y-test-utils.js",
+      ],
       // Additional global variables allowed in tests
       "globals": {
         // Global variables from 3rd party test libraries/frameworks.
@@ -190,6 +310,7 @@
         // variables from these libraries and import is not possible
         "MockInteractions": "readonly",
         "_": "readonly",
+        "axs": "readonly",
         "a11ySuite": "readonly",
         "assert": "readonly",
         "expect": "readonly",
@@ -201,8 +322,11 @@
         "stub": "readonly",
         "suite": "readonly",
         "suiteSetup": "readonly",
+        "suiteTeardown": "readonly",
         "teardown": "readonly",
         "test": "readonly",
+        "fixtureFromElement": "readonly",
+        "fixtureFromTemplate": "readonly",
       }
     },
     {
@@ -212,14 +336,15 @@
       }
     },
     {
-      "files": ["samples/**/*.js", "**/test/plugin.html"],
+      "files": ["samples/**/*.js"],
       "globals": {
         // Settings for samples. You can add globals here if you want to use it
         "Gerrit": "readonly",
+        "Polymer": "readonly",
       }
     },
     {
-      "files": ["test/functional/**/*.js", "wct.conf.js"],
+      "files": ["test/functional/**/*.js"],
       // Settings for functional tests. These scripts are node scripts.
       // Turn off "no-undef" to allow any global variable
       "env": {
@@ -232,12 +357,6 @@
       }
     },
     {
-      "files": "test/index.html",
-      "globals": {
-        "WCT": "readonly",
-      }
-    },
-    {
       "files": ["*_html.js", "gr-icons.js", "*-theme.js", "*-styles.js"],
       "rules": {
         "max-len": "off"
@@ -260,6 +379,10 @@
     "prettier"
   ],
   "settings": {
-    "html/report-bad-indent": "error"
+    "html/report-bad-indent": "error",
+    "import/resolver": {
+      "node": {},
+      [path.resolve(__dirname, './.eslint-ts-resolver.js')]: {},
+    },
   },
 };
diff --git a/polygerrit-ui/app/.prettierrc.js b/polygerrit-ui/app/.prettierrc.js
new file mode 100644
index 0000000..fbb87c6
--- /dev/null
+++ b/polygerrit-ui/app/.prettierrc.js
@@ -0,0 +1,27 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+module.exports = {
+  "overrides": [
+    {
+      "files": ["**/*.ts"],
+      "options": {
+          ...require('gts/.prettierrc.json')
+      }
+    }
+  ]
+};
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 5a3f140..41c3f17 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -1,24 +1,82 @@
-load(":rules.bzl", "polygerrit_bundle", "wct_suite")
+load(":rules.bzl", "compile_ts", "polygerrit_bundle")
 load("//tools/js:eslint.bzl", "eslint")
 
 package(default_visibility = ["//visibility:public"])
 
-polygerrit_bundle(
-    name = "polygerrit_ui",
+# This list must be in sync with the "include" list in the tsconfig.json file
+src_dirs = [
+    "constants",
+    "elements",
+    "embed",
+    "gr-diff",
+    "mixins",
+    "samples",
+    "scripts",
+    "services",
+    "styles",
+    "types",
+    "utils",
+]
+
+compiled_pg_srcs = compile_ts(
+    name = "compile_pg",
+    srcs = glob(
+        [src_dir + "/**/*" + ext for src_dir in src_dirs for ext in [
+            ".js",
+            ".ts",
+        ]],
+        exclude = [
+            "**/*_test.js",
+        ],
+    ),
+    # The same outdir also appears in the following files:
+    # polylint_test.sh
+    ts_outdir = "_pg_ts_out",
+)
+
+compiled_pg_srcs_with_tests = compile_ts(
+    name = "compile_pg_with_tests",
     srcs = glob(
         [
             "**/*.js",
+            "**/*.ts",
         ],
         exclude = [
             "node_modules/**",
             "node_modules_licenses/**",
-            "test/**",
-            "**/*_test.html",
-            "**/*_test.js",
+            "template_test_srcs/**",
+            "rollup.config.js",
         ],
     ),
+    # The same outdir also appears in the following files:
+    # wct_test.sh
+    # karma.conf.js
+    ts_outdir = "_pg_with_tests_out",
+)
+
+polygerrit_bundle(
+    name = "polygerrit_ui",
+    srcs = compiled_pg_srcs,
     outs = ["polygerrit_ui.zip"],
-    entry_point = "elements/gr-app.html",
+    entry_point = "_pg_ts_out/elements/gr-app.js",
+)
+
+filegroup(
+    name = "eslint_src_code",
+    srcs = glob(
+        [
+            "**/*.html",
+            "**/*.js",
+            "**/*.ts",
+        ],
+        exclude = [
+            "node_modules/**",
+            "node_modules_licenses/**",
+        ],
+    ) + [
+        "@ui_dev_npm//:node_modules",
+        "@ui_npm//:node_modules",
+    ],
 )
 
 filegroup(
@@ -26,62 +84,44 @@
     srcs = glob(
         [
             "**/*.html",
-            "**/*.js",
         ],
         exclude = [
             "node_modules/**",
             "node_modules_licenses/**",
         ],
-    ),
-)
-
-filegroup(
-    name = "pg_code_without_test",
-    srcs = glob(
-        [
-            "**/*.html",
-            "**/*.js",
-        ],
-        exclude = [
-            "node_modules/**",
-            "node_modules_licenses/**",
-            "**/*_test.html",
-            "test/**",
-            "samples/**",
-            "**/*_test.js",
-        ],
-    ),
+    ) + compiled_pg_srcs_with_tests,
 )
 
 # Workaround for https://github.com/bazelbuild/bazel/issues/1305
 filegroup(
     name = "test-srcs-fg",
     srcs = [
-        "test/common-test-setup.js",
-        "test/index.html",
+        "rollup.config.js",
         ":pg_code",
         "@ui_dev_npm//:node_modules",
         "@ui_npm//:node_modules",
     ],
 )
 
-wct_suite(
-    name = "wct",
-    srcs = [":test-srcs-fg"],
-    split_count = 4,
-)
-
 # Define the eslinter for polygerrit-ui app
 # The eslint macro creates 2 rules: lint_test and lint_bin
 eslint(
     name = "lint",
-    srcs = [":test-srcs-fg"],
+    srcs = [":eslint_src_code"],
     config = ".eslintrc-bazel.js",
-    # The .eslintrc-bazel.js extends the .eslintrc.js config, pass it as a dependency
-    data = [".eslintrc.js"],
+    data = [
+        # The .eslintrc-bazel.js extends the .eslintrc.js config, pass it as a dependency
+        ".eslintrc.js",
+        ".prettierrc.js",
+        ".eslint-ts-resolver.js",
+        "tsconfig_eslint.json",
+        # tsconfig_eslint.json extends tsconfig.json, pass it as a dependency
+        "tsconfig.json",
+    ],
     extensions = [
         ".html",
         ".js",
+        ".ts",
     ],
     ignore = ".eslintignore",
     plugins = [
@@ -90,16 +130,18 @@
         "@npm//eslint-plugin-import",
         "@npm//eslint-plugin-jsdoc",
         "@npm//eslint-plugin-prettier",
+        "@npm//gts",
     ],
 )
 
-# Workaround for https://github.com/bazelbuild/bazel/issues/1305
 filegroup(
     name = "polylint-fg",
     srcs = [
-        ":pg_code_without_test",
+        # Workaround for https://github.com/bazelbuild/bazel/issues/1305
         "@ui_npm//:node_modules",
-    ],
+    ] +
+    # Polylinter can't check .ts files, run it on compiled srcs
+    compiled_pg_srcs,
 )
 
 sh_test(
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js
deleted file mode 100644
index 1d384bc..0000000
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** @polymerBehavior AsyncForeachBehavior */
-export const AsyncForeachBehavior = {
-  /**
-   * @template T
-   * @param {!Array<T>} array
-   * @param {!Function} fn An iteratee function to be passed each element of
-   *     the array in order. Must return a promise, and the following
-   *     iteration will not begin until resolution of the promise returned by
-   *     the previous iteration.
-   *
-   *     An optional second argument to fn is a callback that will halt the
-   *     loop if called.
-   * @return {!Promise<undefined>}
-   */
-  asyncForeach(array, fn) {
-    if (!array.length) { return Promise.resolve(); }
-    let stop = false;
-    const stopCallback = () => { stop = true; };
-    return fn(array[0], stopCallback).then(exit => {
-      if (stop) { return Promise.resolve(); }
-      return this.asyncForeach(array.slice(1), fn);
-    });
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.AsyncForeachBehavior = AsyncForeachBehavior;
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
deleted file mode 100644
index 1d50cc4..0000000
--- a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
+++ /dev/null
@@ -1,56 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>async-foreach-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../test/common-test-setup.js';
-import {AsyncForeachBehavior} from './async-foreach-behavior.js';
-suite('async-foreach-behavior tests', () => {
-  test('loops over each item', () => {
-    const fn = sinon.stub().returns(Promise.resolve());
-    return AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
-        .then(() => {
-          assert.isTrue(fn.calledThrice);
-          assert.equal(fn.getCall(0).args[0], 1);
-          assert.equal(fn.getCall(1).args[0], 2);
-          assert.equal(fn.getCall(2).args[0], 3);
-        });
-  });
-
-  test('halts on stop condition', () => {
-    const stub = sinon.stub();
-    const fn = (e, stop) => {
-      stub(e);
-      stop();
-      return Promise.resolve();
-    };
-    return AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
-        .then(() => {
-          assert.isTrue(stub.calledOnce);
-          assert.equal(stub.lastCall.args[0], 1);
-        });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.js b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.js
deleted file mode 100644
index 4deb089..0000000
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** @polymerBehavior BaseUrlBehavior */
-export const BaseUrlBehavior = {
-  /** @return {string} */
-  getBaseUrl() {
-    return window.CANONICAL_PATH || '';
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.BaseUrlBehavior = BaseUrlBehavior;
-
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
deleted file mode 100644
index 61d7bac..0000000
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
+++ /dev/null
@@ -1,74 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>base-url-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../test/common-test-setup.js';
-/** @type {string} */
-window.CANONICAL_PATH = '/r';
-</script>
-<test-fixture id="basic">
-  <template>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<test-fixture id="within-overlay">
-  <template>
-    <gr-overlay>
-      <test-element></test-element>
-    </gr-overlay>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {BaseUrlBehavior} from './base-url-behavior.js';
-suite('base-url-behavior tests', () => {
-  let element;
-  // eslint-disable-next-line no-unused-vars
-  let overlay;
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'test-element',
-      behaviors: [
-        BaseUrlBehavior,
-      ],
-    });
-  });
-
-  setup(() => {
-    element = fixture('basic');
-    overlay = fixture('within-overlay');
-  });
-
-  test('getBaseUrl', () => {
-    assert.deepEqual(element.getBaseUrl(), '/r');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.js b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.js
deleted file mode 100644
index add1df4..0000000
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.js
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {BaseUrlBehavior} from '../base-url-behavior/base-url-behavior.js';
-
-const PROBE_PATH = '/Documentation/index.html';
-const DOCS_BASE_PATH = '/Documentation';
-
-let cachedPromise;
-
-/** @polymerBehavior DocsUrlBehavior */
-export const DocsUrlBehavior = [{
-
-  /**
-   * Get the docs base URL from either the server config or by probing.
-   *
-   * @param {Object} config The server config.
-   * @param {!Object} restApi A REST API instance
-   * @return {!Promise<string>} A promise that resolves with the docs base
-   *     URL.
-   */
-  getDocsBaseUrl(config, restApi) {
-    if (!cachedPromise) {
-      cachedPromise = new Promise(resolve => {
-        if (config && config.gerrit && config.gerrit.doc_url) {
-          resolve(config.gerrit.doc_url);
-        } else {
-          restApi.probePath(this.getBaseUrl() + PROBE_PATH).then(ok => {
-            resolve(ok ? (this.getBaseUrl() + DOCS_BASE_PATH) : null);
-          });
-        }
-      });
-    }
-    return cachedPromise;
-  },
-
-  /** For testing only. */
-  _clearDocsBaseUrlCache() {
-    cachedPromise = undefined;
-  },
-},
-BaseUrlBehavior,
-];
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.DocsUrlBehavior = DocsUrlBehavior;
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
deleted file mode 100644
index 0efd80f..0000000
--- a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
+++ /dev/null
@@ -1,99 +0,0 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<!-- Polymer included for the html import polyfill. -->
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<title>docs-url-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <docs-url-behavior-element></docs-url-behavior-element>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {DocsUrlBehavior} from './docs-url-behavior.js';
-suite('docs-url-behavior tests', () => {
-  let element;
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'docs-url-behavior-element',
-      behaviors: [DocsUrlBehavior],
-    });
-  });
-
-  setup(() => {
-    element = fixture('basic');
-    element._clearDocsBaseUrlCache();
-  });
-
-  test('null config', () => {
-    const mockRestApi = {
-      probePath: sinon.stub().returns(Promise.resolve(true)),
-    };
-    return element.getDocsBaseUrl(null, mockRestApi)
-        .then(docsBaseUrl => {
-          assert.isTrue(
-              mockRestApi.probePath.calledWith('/Documentation/index.html'));
-          assert.equal(docsBaseUrl, '/Documentation');
-        });
-  });
-
-  test('no doc config', () => {
-    const mockRestApi = {
-      probePath: sinon.stub().returns(Promise.resolve(true)),
-    };
-    const config = {gerrit: {}};
-    return element.getDocsBaseUrl(config, mockRestApi)
-        .then(docsBaseUrl => {
-          assert.isTrue(
-              mockRestApi.probePath.calledWith('/Documentation/index.html'));
-          assert.equal(docsBaseUrl, '/Documentation');
-        });
-  });
-
-  test('has doc config', () => {
-    const mockRestApi = {
-      probePath: sinon.stub().returns(Promise.resolve(true)),
-    };
-    const config = {gerrit: {doc_url: 'foobar'}};
-    return element.getDocsBaseUrl(config, mockRestApi)
-        .then(docsBaseUrl => {
-          assert.isFalse(mockRestApi.probePath.called);
-          assert.equal(docsBaseUrl, 'foobar');
-        });
-  });
-
-  test('no probe', () => {
-    const mockRestApi = {
-      probePath: sinon.stub().returns(Promise.resolve(false)),
-    };
-    return element.getDocsBaseUrl(null, mockRestApi)
-        .then(docsBaseUrl => {
-          assert.isTrue(
-              mockRestApi.probePath.calledWith('/Documentation/index.html'));
-          assert.isNotOk(docsBaseUrl);
-        });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
deleted file mode 100644
index b8d54a4..0000000
--- a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-export const DomUtilBehavior = {
-  /**
-   * Are any ancestors of the element (or the element itself) members of the
-   * given class.
-   *
-   * @param {!Element} element
-   * @param {string} className
-   * @param {Element=} opt_stopElement If provided, stop traversing the
-   *     ancestry when the stop element is reached. The stop element's class
-   *     is not checked.
-   * @return {boolean}
-   */
-  descendedFromClass(element, className, opt_stopElement) {
-    let isDescendant = element.classList.contains(className);
-    while (!isDescendant && element.parentElement &&
-        (!opt_stopElement || element.parentElement !== opt_stopElement)) {
-      isDescendant = element.classList.contains(className);
-      element = element.parentElement;
-    }
-    return isDescendant;
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.DomUtilBehavior = DomUtilBehavior;
diff --git a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html b/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
deleted file mode 100644
index 1e842c1..0000000
--- a/polygerrit-ui/app/behaviors/dom-util-behavior/dom-util-behavior_test.html
+++ /dev/null
@@ -1,72 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>dom-util-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<test-fixture id="nested-structure">
-  <template>
-    <test-element></test-element>
-    <div>
-      <div class="a">
-        <div class="b">
-          <div class="c"></div>
-        </div>
-      </div>
-    </div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {DomUtilBehavior} from './dom-util-behavior.js';
-suite('dom-util-behavior tests', () => {
-  let element;
-  let divs;
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'test-element',
-      behaviors: [DomUtilBehavior],
-    });
-  });
-
-  setup(() => {
-    const testDom = fixture('nested-structure');
-    element = testDom[0];
-    divs = testDom[1];
-  });
-
-  test('descendedFromClass', () => {
-    // .c is a child of .a and not vice versa.
-    assert.isTrue(element.descendedFromClass(divs.querySelector('.c'), 'a'));
-    assert.isFalse(element.descendedFromClass(divs.querySelector('.a'), 'c'));
-
-    // Stops at stop element.
-    assert.isFalse(element.descendedFromClass(divs.querySelector('.c'), 'a',
-        divs.querySelector('.b')));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js b/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js
deleted file mode 100644
index 88c8835..0000000
--- a/polygerrit-ui/app/behaviors/fire-behavior/fire-behavior.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** @polymerBehavior Gerrit.FireBehavior */
-export const FireBehavior = {
-  /**
-   * Dispatches a custom event with an optional detail value.
-   *
-   * @param {string} type Name of event type.
-   * @param {*=} detail Detail value containing event-specific
-   *   payload.
-   * @param {{ bubbles: (boolean|undefined), cancelable: (boolean|undefined),
-   *     composed: (boolean|undefined) }=}
-   *  options Object specifying options.  These may include:
-   *  `bubbles` (boolean, defaults to `true`),
-   *  `cancelable` (boolean, defaults to false), and
-   *  `composed` (boolean, defaults to true).
-   * @return {!Event} The new event that was fired.
-   * @override
-   */
-  fire(type, detail, options) {
-    console.warn('\'fire\' is deprecated, please use dispatchEvent instead!');
-    options = options || {};
-    detail = (detail === null || detail === undefined) ? {} : detail;
-    const event = new Event(type, {
-      bubbles: options.bubbles === undefined ? true : options.bubbles,
-      cancelable: Boolean(options.cancelable),
-      composed: options.composed === undefined ? true: options.composed,
-    });
-    event.detail = detail;
-    this.dispatchEvent(event);
-    return event;
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.FireBehavior = FireBehavior;
diff --git a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.js b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.js
deleted file mode 100644
index 7b48ecc..0000000
--- a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.js
+++ /dev/null
@@ -1,159 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** @polymerBehavior Gerrit.AccessBehavior */
-export const AccessBehavior = {
-  properties: {
-    permissionValues: {
-      type: Object,
-      readOnly: true,
-      value: {
-        abandon: {
-          id: 'abandon',
-          name: 'Abandon',
-        },
-        addPatchSet: {
-          id: 'addPatchSet',
-          name: 'Add Patch Set',
-        },
-        create: {
-          id: 'create',
-          name: 'Create Reference',
-        },
-        createTag: {
-          id: 'createTag',
-          name: 'Create Annotated Tag',
-        },
-        createSignedTag: {
-          id: 'createSignedTag',
-          name: 'Create Signed Tag',
-        },
-        delete: {
-          id: 'delete',
-          name: 'Delete Reference',
-        },
-        deleteChanges: {
-          id: 'deleteChanges',
-          name: 'Delete Changes',
-        },
-        deleteOwnChanges: {
-          id: 'deleteOwnChanges',
-          name: 'Delete Own Changes',
-        },
-        editAssignee: {
-          id: 'editAssignee',
-          name: 'Edit Assignee',
-        },
-        editHashtags: {
-          id: 'editHashtags',
-          name: 'Edit Hashtags',
-        },
-        editTopicName: {
-          id: 'editTopicName',
-          name: 'Edit Topic Name',
-        },
-        forgeAuthor: {
-          id: 'forgeAuthor',
-          name: 'Forge Author Identity',
-        },
-        forgeCommitter: {
-          id: 'forgeCommitter',
-          name: 'Forge Committer Identity',
-        },
-        forgeServerAsCommitter: {
-          id: 'forgeServerAsCommitter',
-          name: 'Forge Server Identity',
-        },
-        owner: {
-          id: 'owner',
-          name: 'Owner',
-        },
-        publishDrafts: {
-          id: 'publishDrafts',
-          name: 'Publish Drafts',
-        },
-        push: {
-          id: 'push',
-          name: 'Push',
-        },
-        pushMerge: {
-          id: 'pushMerge',
-          name: 'Push Merge Commit',
-        },
-        read: {
-          id: 'read',
-          name: 'Read',
-        },
-        rebase: {
-          id: 'rebase',
-          name: 'Rebase',
-        },
-        revert: {
-          id: 'revert',
-          name: 'Revert',
-        },
-        removeReviewer: {
-          id: 'removeReviewer',
-          name: 'Remove Reviewer',
-        },
-        submit: {
-          id: 'submit',
-          name: 'Submit',
-        },
-        submitAs: {
-          id: 'submitAs',
-          name: 'Submit (On Behalf Of)',
-        },
-        toggleWipState: {
-          id: 'toggleWipState',
-          name: 'Toggle Work In Progress State',
-        },
-        viewPrivateChanges: {
-          id: 'viewPrivateChanges',
-          name: 'View Private Changes',
-        },
-      },
-    },
-  },
-
-  /**
-   * @param {!Object} obj
-   * @return {!Array} returns a sorted array sorted by the id of the original
-   *    object.
-   */
-  toSortedArray(obj) {
-    if (!obj) { return []; }
-    return Object.keys(obj)
-        .map(key => {
-          return {
-            id: key,
-            value: obj[key],
-          };
-        })
-        .sort((a, b) =>
-          // Since IDs are strings, use localeCompare.
-          a.id.localeCompare(b.id)
-        );
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.AccessBehavior = AccessBehavior;
diff --git a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
deleted file mode 100644
index c5f3f94..0000000
--- a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
+++ /dev/null
@@ -1,72 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>keyboard-shortcut-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<test-fixture id="basic">
-  <template>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {AccessBehavior} from './gr-access-behavior.js';
-suite('gr-access-behavior tests', () => {
-  let element;
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'test-element',
-      behaviors: [AccessBehavior],
-    });
-  });
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('toSortedArray', () => {
-    const rules = {
-      'global:Project-Owners': {
-        action: 'ALLOW', force: false,
-      },
-      '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-        action: 'ALLOW', force: false,
-      },
-    };
-    const expectedResult = [
-      {id: '4c97682e6ce6b7247f3381b6f1789356666de7f', value: {
-        action: 'ALLOW', force: false,
-      }},
-      {id: 'global:Project-Owners', value: {
-        action: 'ALLOW', force: false,
-      }},
-    ];
-    assert.deepEqual(element.toSortedArray(rules), expectedResult);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js
deleted file mode 100644
index 3c48fbe..0000000
--- a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js
+++ /dev/null
@@ -1,210 +0,0 @@
-import {GerritNav} from '../../elements/core/gr-navigation/gr-navigation.js';
-
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-const ADMIN_LINKS = [{
-  name: 'Repositories',
-  noBaseUrl: true,
-  url: '/admin/repos',
-  view: 'gr-repo-list',
-  viewableToAll: true,
-}, {
-  name: 'Groups',
-  section: 'Groups',
-  noBaseUrl: true,
-  url: '/admin/groups',
-  view: 'gr-admin-group-list',
-}, {
-  name: 'Plugins',
-  capability: 'viewPlugins',
-  section: 'Plugins',
-  noBaseUrl: true,
-  url: '/admin/plugins',
-  view: 'gr-plugin-list',
-}];
-
-window.Gerrit = window.Gerrit || {};
-
-/** @polymerBehavior Gerrit.AdminNavBehavior */
-export const AdminNavBehavior = {
-  /**
-   * @param {!Object} account
-   * @param {!Function} getAccountCapabilities
-   * @param {!Function} getAdminMenuLinks
-   *  Possible aguments in options:
-   *    repoName?: string
-   *    groupId?: string,
-   *    groupName?: string,
-   *    groupIsInternal?: boolean,
-   *    isAdmin?: boolean,
-   *    groupOwner?: boolean,
-   * @param {!Object=} opt_options
-   * @return {Promise<!Object>}
-   */
-  getAdminLinks(account, getAccountCapabilities, getAdminMenuLinks,
-      opt_options) {
-    if (!account) {
-      return Promise.resolve(this._filterLinks(link => link.viewableToAll,
-          getAdminMenuLinks, opt_options));
-    }
-    return getAccountCapabilities()
-        .then(capabilities => this._filterLinks(
-            link => !link.capability
-            || capabilities.hasOwnProperty(link.capability),
-            getAdminMenuLinks,
-            opt_options));
-  },
-
-  /**
-   * @param {!Function} filterFn
-   * @param {!Function} getAdminMenuLinks
-   *  Possible aguments in options:
-   *    repoName?: string
-   *    groupId?: string,
-   *    groupName?: string,
-   *    groupIsInternal?: boolean,
-   *    isAdmin?: boolean,
-   *    groupOwner?: boolean,
-   * @param {!Object|undefined} opt_options
-   * @return {Promise<!Object>}
-   */
-  _filterLinks(filterFn, getAdminMenuLinks, opt_options) {
-    let links = ADMIN_LINKS.slice(0);
-    let expandedSection;
-
-    const isExernalLink = link => link.url[0] !== '/';
-
-    // Append top-level links that are defined by plugins.
-    links.push(...getAdminMenuLinks().map(link => {
-      return {
-        url: link.url,
-        name: link.text,
-        capability: link.capability || null,
-        noBaseUrl: !isExernalLink(link),
-        view: null,
-        viewableToAll: !link.capability,
-        target: isExernalLink(link) ? '_blank' : null,
-      };
-    }));
-
-    links = links.filter(filterFn);
-
-    const filteredLinks = [];
-    const repoName = opt_options && opt_options.repoName;
-    const groupId = opt_options && opt_options.groupId;
-    const groupName = opt_options && opt_options.groupName;
-    const groupIsInternal = opt_options && opt_options.groupIsInternal;
-    const isAdmin = opt_options && opt_options.isAdmin;
-    const groupOwner = opt_options && opt_options.groupOwner;
-
-    // Don't bother to get sub-navigation items if only the top level links
-    // are needed. This is used by the main header dropdown.
-    if (!repoName && !groupId) { return {links, expandedSection}; }
-
-    // Otherwise determine the full set of links and return both the full
-    // set in addition to the subsection that should be displayed if it
-    // exists.
-    for (const link of links) {
-      const linkCopy = Object.assign({}, link);
-      if (linkCopy.name === 'Repositories' && repoName) {
-        linkCopy.subsection = this.getRepoSubsections(repoName);
-        expandedSection = linkCopy.subsection;
-      } else if (linkCopy.name === 'Groups' && groupId && groupName) {
-        linkCopy.subsection = this.getGroupSubsections(groupId, groupName,
-            groupIsInternal, isAdmin, groupOwner);
-        expandedSection = linkCopy.subsection;
-      }
-      filteredLinks.push(linkCopy);
-    }
-    return {links: filteredLinks, expandedSection};
-  },
-
-  getGroupSubsections(groupId, groupName, groupIsInternal, isAdmin,
-      groupOwner) {
-    const subsection = {
-      name: groupName,
-      view: GerritNav.View.GROUP,
-      url: GerritNav.getUrlForGroup(groupId),
-      children: [],
-    };
-    if (groupIsInternal) {
-      subsection.children.push({
-        name: 'Members',
-        detailType: GerritNav.GroupDetailView.MEMBERS,
-        view: GerritNav.View.GROUP,
-        url: GerritNav.getUrlForGroupMembers(groupId),
-      });
-    }
-    if (groupIsInternal && (isAdmin || groupOwner)) {
-      subsection.children.push(
-          {
-            name: 'Audit Log',
-            detailType: GerritNav.GroupDetailView.LOG,
-            view: GerritNav.View.GROUP,
-            url: GerritNav.getUrlForGroupLog(groupId),
-          }
-      );
-    }
-    return subsection;
-  },
-
-  getRepoSubsections(repoName) {
-    return {
-      name: repoName,
-      view: GerritNav.View.REPO,
-      url: GerritNav.getUrlForRepo(repoName),
-      children: [{
-        name: 'Access',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.ACCESS,
-        url: GerritNav.getUrlForRepoAccess(repoName),
-      },
-      {
-        name: 'Commands',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.COMMANDS,
-        url: GerritNav.getUrlForRepoCommands(repoName),
-      },
-      {
-        name: 'Branches',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.BRANCHES,
-        url: GerritNav.getUrlForRepoBranches(repoName),
-      },
-      {
-        name: 'Tags',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.TAGS,
-        url: GerritNav.getUrlForRepoTags(repoName),
-      },
-      {
-        name: 'Dashboards',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.DASHBOARDS,
-        url: GerritNav.getUrlForRepoDashboards(repoName),
-      }],
-    };
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.AdminNavBehavior = AdminNavBehavior;
diff --git a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html b/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
deleted file mode 100644
index 3f58499..0000000
--- a/polygerrit-ui/app/behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html
+++ /dev/null
@@ -1,369 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>keyboard-shortcut-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<test-fixture id="basic">
-  <template>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {AdminNavBehavior} from './gr-admin-nav-behavior.js';
-suite('gr-admin-nav-behavior tests', () => {
-  let element;
-  let sandbox;
-  let capabilityStub;
-  let menuLinkStub;
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'test-element',
-      behaviors: [
-        AdminNavBehavior,
-      ],
-    });
-  });
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    capabilityStub = sinon.stub();
-    menuLinkStub = sinon.stub().returns([]);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  const testAdminLinks = (account, options, expected, done) => {
-    element.getAdminLinks(account,
-        capabilityStub,
-        menuLinkStub,
-        options)
-        .then(res => {
-          assert.equal(expected.totalLength, res.links.length);
-          assert.equal(res.links[0].name, 'Repositories');
-          // Repos
-          if (expected.groupListShown) {
-            assert.equal(res.links[1].name, 'Groups');
-          }
-
-          if (expected.pluginListShown) {
-            assert.equal(res.links[2].name, 'Plugins');
-            assert.isNotOk(res.links[2].subsection);
-          }
-
-          if (expected.projectPageShown) {
-            assert.isOk(res.links[0].subsection);
-            assert.equal(res.links[0].subsection.children.length, 5);
-          } else {
-            assert.isNotOk(res.links[0].subsection);
-          }
-          // Groups
-          if (expected.groupPageShown) {
-            assert.isOk(res.links[1].subsection);
-            assert.equal(res.links[1].subsection.children.length,
-                expected.groupSubpageLength);
-          } else if ( expected.totalLength > 1) {
-            assert.isNotOk(res.links[1].subsection);
-          }
-
-          if (expected.pluginGeneratedLinks) {
-            for (const link of expected.pluginGeneratedLinks) {
-              const linkMatch = res.links
-                  .find(l => (l.url === link.url && l.name === link.text));
-              assert.isTrue(!!linkMatch);
-
-              // External links should open in new tab.
-              if (link.url[0] !== '/') {
-                assert.equal(linkMatch.target, '_blank');
-              } else {
-                assert.isNotOk(linkMatch.target);
-              }
-            }
-          }
-
-          // Current section
-          if (expected.projectPageShown || expected.groupPageShown) {
-            assert.isOk(res.expandedSection);
-            assert.isOk(res.expandedSection.children);
-          } else {
-            assert.isNotOk(res.expandedSection);
-          }
-          if (expected.projectPageShown) {
-            assert.equal(res.expandedSection.name, 'my-repo');
-            assert.equal(res.expandedSection.children.length, 5);
-          } else if (expected.groupPageShown) {
-            assert.equal(res.expandedSection.name, 'my-group');
-            assert.equal(res.expandedSection.children.length,
-                expected.groupSubpageLength);
-          }
-          done();
-        });
-  };
-
-  suite('logged out', () => {
-    let account;
-    let expected;
-
-    setup(() => {
-      expected = {
-        groupListShown: false,
-        groupPageShown: false,
-        pluginListShown: false,
-      };
-    });
-
-    test('without a specific repo or group', done => {
-      let options;
-      expected = Object.assign(expected, {
-        totalLength: 1,
-        projectPageShown: false,
-      });
-      testAdminLinks(account, options, expected, done);
-    });
-
-    test('with a repo', done => {
-      const options = {repoName: 'my-repo'};
-      expected = Object.assign(expected, {
-        totalLength: 1,
-        projectPageShown: true,
-      });
-      testAdminLinks(account, options, expected, done);
-    });
-
-    test('with plugin generated links', done => {
-      let options;
-      const generatedLinks = [
-        {text: 'internal link text', url: '/internal/link/url'},
-        {text: 'external link text', url: 'http://external/link/url'},
-      ];
-      menuLinkStub.returns(generatedLinks);
-      expected = Object.assign(expected, {
-        totalLength: 3,
-        projectPageShown: false,
-        pluginGeneratedLinks: generatedLinks,
-      });
-      testAdminLinks(account, options, expected, done);
-    });
-  });
-
-  suite('no plugin capability logged in', () => {
-    const account = {
-      name: 'test-user',
-    };
-    let expected;
-
-    setup(() => {
-      expected = {
-        totalLength: 2,
-        pluginListShown: false,
-      };
-      capabilityStub.returns(Promise.resolve({}));
-    });
-
-    test('without a specific project or group', done => {
-      let options;
-      expected = Object.assign(expected, {
-        projectPageShown: false,
-        groupListShown: true,
-        groupPageShown: false,
-      });
-      testAdminLinks(account, options, expected, done);
-    });
-
-    test('with a repo', done => {
-      const account = {
-        name: 'test-user',
-      };
-      const options = {repoName: 'my-repo'};
-      expected = Object.assign(expected, {
-        projectPageShown: true,
-        groupListShown: true,
-        groupPageShown: false,
-      });
-      testAdminLinks(account, options, expected, done);
-    });
-  });
-
-  suite('view plugin capability logged in', () => {
-    const account = {
-      name: 'test-user',
-    };
-    let expected;
-
-    setup(() => {
-      capabilityStub.returns(Promise.resolve({viewPlugins: true}));
-      expected = {
-        totalLength: 3,
-        groupListShown: true,
-        pluginListShown: true,
-      };
-    });
-
-    test('without a specific repo or group', done => {
-      let options;
-      expected = Object.assign(expected, {
-        projectPageShown: false,
-        groupPageShown: false,
-      });
-      testAdminLinks(account, options, expected, done);
-    });
-
-    test('with a repo', done => {
-      const options = {repoName: 'my-repo'};
-      expected = Object.assign(expected, {
-        projectPageShown: true,
-        groupPageShown: false,
-      });
-      testAdminLinks(account, options, expected, done);
-    });
-
-    test('admin with internal group', done => {
-      const options = {
-        groupId: 'a15262',
-        groupName: 'my-group',
-        groupIsInternal: true,
-        isAdmin: true,
-        groupOwner: false,
-      };
-      expected = Object.assign(expected, {
-        projectPageShown: false,
-        groupPageShown: true,
-        groupSubpageLength: 2,
-      });
-      testAdminLinks(account, options, expected, done);
-    });
-
-    test('group owner with internal group', done => {
-      const options = {
-        groupId: 'a15262',
-        groupName: 'my-group',
-        groupIsInternal: true,
-        isAdmin: false,
-        groupOwner: true,
-      };
-      expected = Object.assign(expected, {
-        projectPageShown: false,
-        groupPageShown: true,
-        groupSubpageLength: 2,
-      });
-      testAdminLinks(account, options, expected, done);
-    });
-
-    test('non owner or admin with internal group', done => {
-      const options = {
-        groupId: 'a15262',
-        groupName: 'my-group',
-        groupIsInternal: true,
-        isAdmin: false,
-        groupOwner: false,
-      };
-      expected = Object.assign(expected, {
-        projectPageShown: false,
-        groupPageShown: true,
-        groupSubpageLength: 1,
-      });
-      testAdminLinks(account, options, expected, done);
-    });
-
-    test('admin with external group', done => {
-      const options = {
-        groupId: 'a15262',
-        groupName: 'my-group',
-        groupIsInternal: false,
-        isAdmin: true,
-        groupOwner: true,
-      };
-      expected = Object.assign(expected, {
-        projectPageShown: false,
-        groupPageShown: true,
-        groupSubpageLength: 0,
-      });
-      testAdminLinks(account, options, expected, done);
-    });
-  });
-
-  suite('view plugin screen with plugin capability', () => {
-    const account = {
-      name: 'test-user',
-    };
-    let expected;
-
-    setup(() => {
-      capabilityStub.returns(Promise.resolve({pluginCapability: true}));
-      expected = {};
-    });
-
-    test('with plugin with capabilities', done => {
-      let options;
-      const generatedLinks = [
-        {text: 'without capability', url: '/without'},
-        {text: 'with capability',
-          url: '/with',
-          capability: 'pluginCapability'},
-      ];
-      menuLinkStub.returns(generatedLinks);
-      expected = Object.assign(expected, {
-        totalLength: 4,
-        pluginGeneratedLinks: generatedLinks,
-      });
-      testAdminLinks(account, options, expected, done);
-    });
-  });
-
-  suite('view plugin screen without plugin capability', () => {
-    const account = {
-      name: 'test-user',
-    };
-    let expected;
-
-    setup(() => {
-      capabilityStub.returns(Promise.resolve({}));
-      expected = {};
-    });
-
-    test('with plugin with capabilities', done => {
-      let options;
-      const generatedLinks = [
-        {text: 'without capability', url: '/without'},
-        {text: 'with capability',
-          url: '/with',
-          capability: 'pluginCapability'},
-      ];
-      menuLinkStub.returns(generatedLinks);
-      expected = Object.assign(expected, {
-        totalLength: 3,
-        pluginGeneratedLinks: [generatedLinks[0]],
-      });
-      testAdminLinks(account, options, expected, done);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.js b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.js
deleted file mode 100644
index 6c469a5..0000000
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.js
+++ /dev/null
@@ -1,113 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** @polymerBehavior Gerrit.ChangeTableBehavior */
-export const ChangeTableBehavior = {
-  properties: {
-    columnNames: {
-      type: Array,
-      value: [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Reviewers',
-        'Comments',
-        'Repo',
-        'Branch',
-        'Updated',
-        'Size',
-      ],
-      readOnly: true,
-    },
-  },
-
-  /**
-   * Returns the complement to the given column array
-   *
-   * @param {Array} columns
-   * @return {!Array}
-   */
-  getComplementColumns(columns) {
-    return this.columnNames.filter(column => !columns.includes(column));
-  },
-
-  /**
-   * @param {string} columnToCheck
-   * @param {!Array} columnsToDisplay
-   * @return {boolean}
-   */
-  isColumnHidden(columnToCheck, columnsToDisplay) {
-    if ([columnsToDisplay, columnToCheck].some(arg => arg === undefined)) {
-      return false;
-    }
-    return !columnsToDisplay.includes(columnToCheck);
-  },
-
-  /**
-   * Is the column disabled by a server config or experiment? For example the
-   * assignee feature might be disabled and thus the corresponding column is
-   * also disabled.
-   *
-   * @param {string} column
-   * @param {Object} config
-   * @param {!Array<string>} experiments
-   * @return {boolean}
-   */
-  isColumnEnabled(column, config, experiments) {
-    if (!config || !config.change) return true;
-    if (column === 'Assignee') return !!config.change.enable_assignee;
-    if (column === 'Comments') return experiments.includes('comments-column');
-    if (column === 'Reviewers') return !!config.change.enable_attention_set;
-    return true;
-  },
-
-  /**
-   * @param {!Array<string>} columns
-   * @param {Object} config
-   * @param {!Array<string>} experiments
-   * @return {!Array<string>} enabled columns, see isColumnEnabled().
-   */
-  getEnabledColumns(columns, config, experiments) {
-    return columns.filter(
-        col => this.isColumnEnabled(col, config, experiments));
-  },
-
-  /**
-   * The Project column was renamed to Repo, but some users may have
-   * preferences that use its old name. If that column is found, rename it
-   * before use.
-   *
-   * @param {!Array<string>} columns
-   * @return {!Array<string>} If the column was renamed, returns a new array
-   *     with the corrected name. Otherwise, it returns the original param.
-   */
-  getVisibleColumns(columns) {
-    const projectIndex = columns.indexOf('Project');
-    if (projectIndex === -1) { return columns; }
-    const newColumns = columns.slice(0);
-    newColumns[projectIndex] = 'Repo';
-    return newColumns;
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.ChangeTableBehavior = ChangeTableBehavior;
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
deleted file mode 100644
index 3c5aedd..0000000
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
+++ /dev/null
@@ -1,130 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>keyboard-shortcut-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<test-fixture id="basic">
-  <template>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<test-fixture id="within-overlay">
-  <template>
-    <gr-overlay>
-      <test-element></test-element>
-    </gr-overlay>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {ChangeTableBehavior} from './gr-change-table-behavior.js';
-suite('gr-change-table-behavior tests', () => {
-  let element;
-  // eslint-disable-next-line no-unused-vars
-  let overlay;
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'test-element',
-      behaviors: [ChangeTableBehavior],
-    });
-  });
-
-  setup(() => {
-    element = fixture('basic');
-    overlay = fixture('within-overlay');
-  });
-
-  test('getComplementColumns', () => {
-    let columns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-    assert.deepEqual(element.getComplementColumns(columns), []);
-
-    columns = [
-      'Subject',
-      'Status',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Size',
-    ];
-    assert.deepEqual(element.getComplementColumns(columns),
-        ['Owner', 'Updated']);
-  });
-
-  test('isColumnHidden', () => {
-    const columnToCheck = 'Repo';
-    let columnsToDisplay = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-    assert.isFalse(element.isColumnHidden(columnToCheck, columnsToDisplay));
-
-    columnsToDisplay = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-    assert.isTrue(element.isColumnHidden(columnToCheck, columnsToDisplay));
-  });
-
-  test('getVisibleColumns maps Project to Repo', () => {
-    const columns = [
-      'Subject',
-      'Status',
-      'Owner',
-    ];
-    assert.deepEqual(element.getVisibleColumns(columns), columns.slice(0));
-    assert.deepEqual(
-        element.getVisibleColumns(columns.concat(['Project'])),
-        columns.slice(0).concat(['Repo']));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js
deleted file mode 100644
index 607499b..0000000
--- a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {GrDisplayNameUtils} from '../../scripts/gr-display-name-utils/gr-display-name-utils.js';
-
-/** @polymerBehavior Gerrit.DisplayNameBehavior */
-export const DisplayNameBehavior = {
-  // TODO(dmfilippov) replace DisplayNameBehavior with GrDisplayNameUtils
-
-  getUserName(config, account) {
-    return GrDisplayNameUtils.getUserName(config, account);
-  },
-
-  getDisplayName(config, account) {
-    return GrDisplayNameUtils.getDisplayName(config, account);
-  },
-
-  getGroupDisplayName(group) {
-    return GrDisplayNameUtils.getGroupDisplayName(group);
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.DisplayNameBehavior = DisplayNameBehavior;
diff --git a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html b/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
deleted file mode 100644
index fa72c40..0000000
--- a/polygerrit-ui/app/behaviors/gr-display-name-behavior/gr-display-name-behavior_test.html
+++ /dev/null
@@ -1,100 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-display-name-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<test-fixture id="basic">
-  <template>
-    <test-element-anon></test-element-anon>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {DisplayNameBehavior} from './gr-display-name-behavior.js';
-suite('gr-display-name-behavior tests', () => {
-  let element;
-  // eslint-disable-next-line no-unused-vars
-  const config = {
-    user: {
-      anonymous_coward_name: 'Anonymous Coward',
-    },
-  };
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'test-element-anon',
-      behaviors: [
-        DisplayNameBehavior,
-      ],
-    });
-  });
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('getUserName name only', () => {
-    const account = {
-      name: 'test-name',
-    };
-    assert.equal(element.getUserName(config, account), 'test-name');
-  });
-
-  test('getUserName username only', () => {
-    const account = {
-      username: 'test-user',
-    };
-    assert.equal(element.getUserName(config, account), 'test-user');
-  });
-
-  test('getUserName email only', () => {
-    const account = {
-      email: 'test-user@test-url.com',
-    };
-    assert.equal(element.getUserName(config, account),
-        'test-user@test-url.com');
-  });
-
-  test('getUserName returns not Anonymous Coward as the anon name', () => {
-    assert.equal(element.getUserName(config, null), 'Anonymous');
-  });
-
-  test('getUserName for the config returning the anon name', () => {
-    const config = {
-      user: {
-        anonymous_coward_name: 'Test Anon',
-      },
-    };
-    assert.equal(element.getUserName(config, null), 'Test Anon');
-  });
-
-  test('getGroupDisplayName', () => {
-    assert.equal(element.getGroupDisplayName({name: 'Some user name'}),
-        'Some user name (group)');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js
deleted file mode 100644
index 813c64a..0000000
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.js
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {BaseUrlBehavior} from '../base-url-behavior/base-url-behavior.js';
-import {URLEncodingBehavior} from '../gr-url-encoding-behavior/gr-url-encoding-behavior.js';
-
-/** @polymerBehavior ListViewBehavior */
-export const ListViewBehavior = [{
-  computeLoadingClass(loading) {
-    return loading ? 'loading' : '';
-  },
-
-  computeShownItems(items) {
-    return items.slice(0, 25);
-  },
-
-  getUrl(path, item) {
-    return this.getBaseUrl() + path + this.encodeURL(item, true);
-  },
-
-  /**
-   * @param {Object} params
-   * @return {string}
-   */
-  getFilterValue(params) {
-    if (!params) { return ''; }
-    return params.filter || '';
-  },
-
-  /**
-   * @param {Object} params
-   * @return {number}
-   */
-  getOffsetValue(params) {
-    if (params && params.offset) {
-      return params.offset;
-    }
-    return 0;
-  },
-},
-BaseUrlBehavior,
-URLEncodingBehavior,
-];
-
-// eslint-disable-next-line no-unused-vars
-function defineEmptyMixin() {
-  // This is a temporary function.
-  // Polymer linter doesn't process correctly the following code:
-  // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
-  // To workaround this issue, the mock mixin is declared in this method.
-  // In the following changes, legacy behaviors will be converted to mixins.
-
-  /**
-   * @polymer
-   * @mixinFunction
-   */
-  const ListViewMixin = base => // eslint-disable-line no-unused-vars
-    class extends base {
-      computeLoadingClass(loading) {}
-
-      computeShownItems(items) {}
-    };
-}
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.ListViewBehavior = ListViewBehavior;
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
deleted file mode 100644
index 80013bf..0000000
--- a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
+++ /dev/null
@@ -1,93 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>keyboard-shortcut-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<test-fixture id="basic">
-  <template>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {ListViewBehavior} from './gr-list-view-behavior.js';
-suite('gr-list-view-behavior tests', () => {
-  let element;
-  // eslint-disable-next-line no-unused-vars
-  let overlay;
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'test-element',
-      behaviors: [ListViewBehavior],
-    });
-  });
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('computeLoadingClass', () => {
-    assert.equal(element.computeLoadingClass(true), 'loading');
-    assert.equal(element.computeLoadingClass(false), '');
-  });
-
-  test('computeShownItems', () => {
-    const myArr = new Array(26);
-    assert.equal(element.computeShownItems(myArr).length, 25);
-  });
-
-  test('getUrl', () => {
-    assert.equal(element.getUrl('/path/to/something/', 'item'),
-        '/path/to/something/item');
-    assert.equal(element.getUrl('/path/to/something/', 'item%test'),
-        '/path/to/something/item%2525test');
-  });
-
-  test('getFilterValue', () => {
-    let params;
-    assert.equal(element.getFilterValue(params), '');
-
-    params = {filter: null};
-    assert.equal(element.getFilterValue(params), '');
-
-    params = {filter: 'test'};
-    assert.equal(element.getFilterValue(params), 'test');
-  });
-
-  test('getOffsetValue', () => {
-    let params;
-    assert.equal(element.getOffsetValue(params), 0);
-
-    params = {offset: null};
-    assert.equal(element.getOffsetValue(params), 0);
-
-    params = {offset: 1};
-    assert.equal(element.getOffsetValue(params), 1);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js
deleted file mode 100644
index fafec9d..0000000
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js
+++ /dev/null
@@ -1,301 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// Tags identifying ChangeMessages that move change into WIP state.
-const WIP_TAGS = [
-  'autogenerated:gerrit:newWipPatchSet',
-  'autogenerated:gerrit:setWorkInProgress',
-];
-
-// Tags identifying ChangeMessages that move change out of WIP state.
-const READY_TAGS = [
-  'autogenerated:gerrit:setReadyForReview',
-];
-
-/** @polymerBehavior Gerrit.PatchSetBehavior*/
-export const PatchSetBehavior = {
-  EDIT_NAME: 'edit',
-  PARENT_NAME: 'PARENT',
-
-  /**
-   * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
-   * this function checks for patchNum equality.
-   *
-   * @param {string|number} a
-   * @param {string|number|undefined} b Undefined sometimes because
-   *    computeLatestPatchNum can return undefined.
-   * @return {boolean}
-   */
-  patchNumEquals(a, b) {
-    return a + '' === b + '';
-  },
-
-  /**
-   * Whether the given patch is a numbered parent of a merge (i.e. a negative
-   * number).
-   *
-   * @param  {string|number} n
-   * @return {boolean}
-   */
-  isMergeParent(n) {
-    return (n + '')[0] === '-';
-  },
-
-  /**
-   * Given an object of revisions, get a particular revision based on patch
-   * num.
-   *
-   * @param {Object} revisions The object of revisions given by the API
-   * @param {number|string} patchNum The number index of the revision
-   * @return {Object} The correspondent revision obj from {revisions}
-   */
-  getRevisionByPatchNum(revisions, patchNum) {
-    for (const rev of Object.values(revisions || {})) {
-      if (PatchSetBehavior.patchNumEquals(rev._number, patchNum)) {
-        return rev;
-      }
-    }
-  },
-
-  /**
-   * Find change edit base revision if change edit exists.
-   *
-   * @param {!Array<!Object>} revisions The revisions array.
-   * @return {Object} change edit parent revision or null if change edit
-   *     doesn't exist.
-   */
-  findEditParentRevision(revisions) {
-    const editInfo =
-        revisions.find(info => info._number ===
-            PatchSetBehavior.EDIT_NAME);
-
-    if (!editInfo) { return null; }
-
-    return revisions.find(info => info._number === editInfo.basePatchNum) ||
-        null;
-  },
-
-  /**
-   * Find change edit base patch set number if change edit exists.
-   *
-   * @param {!Array<!Object>} revisions The revisions array.
-   * @return {number} Change edit patch set number or -1.
-   */
-  findEditParentPatchNum(revisions) {
-    const revisionInfo =
-        PatchSetBehavior.findEditParentRevision(revisions);
-    return revisionInfo ? revisionInfo._number : -1;
-  },
-
-  /**
-   * Sort given revisions array according to the patch set number, in
-   * descending order.
-   * The sort algorithm is change edit aware. Change edit has patch set number
-   * equals 'edit', but must appear after the patch set it was based on.
-   * Example: change edit is based on patch set 2, and another patch set was
-   * uploaded after change edit creation, the sorted order should be:
-   * 3, edit, 2, 1.
-   *
-   * @param {!Array<!Object>} revisions The revisions array
-   * @return {!Array<!Object>} The sorted {revisions} array
-   */
-  sortRevisions(revisions) {
-    const editParent =
-        PatchSetBehavior.findEditParentPatchNum(revisions);
-    // Map a normal patchNum to 2 * (patchNum - 1) + 1... I.e. 1 -> 1,
-    // 2 -> 3, 3 -> 5, etc.
-    // Map an edit to the patchNum of parent*2... I.e. edit on 2 -> 4.
-    const num = r => (r._number === PatchSetBehavior.EDIT_NAME ?
-      2 * editParent :
-      2 * (r._number - 1) + 1);
-    return revisions.sort((a, b) => num(b) - num(a));
-  },
-
-  /**
-   * Construct a chronological list of patch sets derived from change details.
-   * Each element of this list is an object with the following properties:
-   *
-   *   * num {number} The number identifying the patch set
-   *   * desc {!string} Optional patch set description
-   *   * wip {boolean} If true, this patch set was never subject to review.
-   *   * sha {string} hash of the commit
-   *
-   * The wip property is determined by the change's current work_in_progress
-   * property and its log of change messages.
-   *
-   * @param {!Object} change The change details
-   * @return {!Array<!Object>} Sorted list of patch set objects, as described
-   *     above
-   */
-  computeAllPatchSets(change) {
-    if (!change) { return []; }
-    let patchNums = [];
-    if (change.revisions && Object.keys(change.revisions).length) {
-      const revisions = Object.keys(change.revisions)
-          .map(sha => Object.assign({sha}, change.revisions[sha]));
-      patchNums =
-        PatchSetBehavior.sortRevisions(revisions)
-            .map(e => {
-              // TODO(kaspern): Mark which patchset an edit was made on, if an
-              // edit exists -- perhaps with a temporary description.
-              return {
-                num: e._number,
-                desc: e.description,
-                sha: e.sha,
-              };
-            });
-    }
-    return PatchSetBehavior._computeWipForPatchSets(change, patchNums);
-  },
-
-  /**
-   * Populate the wip properties of the given list of patch sets.
-   *
-   * @param {!Object} change The change details
-   * @param {!Array<!Object>} patchNums Sorted list of patch set objects, as
-   *     generated by computeAllPatchSets
-   * @return {!Array<!Object>} The given list of patch set objects, with the
-   *     wip property set on each of them
-   */
-  _computeWipForPatchSets(change, patchNums) {
-    if (!change.messages || !change.messages.length) {
-      return patchNums;
-    }
-    const psWip = {};
-    let wip = change.work_in_progress;
-    for (let i = 0; i < change.messages.length; i++) {
-      const msg = change.messages[i];
-      if (WIP_TAGS.includes(msg.tag)) {
-        wip = true;
-      } else if (READY_TAGS.includes(msg.tag)) {
-        wip = false;
-      }
-      if (psWip[msg._revision_number] !== false) {
-        psWip[msg._revision_number] = wip;
-      }
-    }
-
-    for (let i = 0; i < patchNums.length; i++) {
-      patchNums[i].wip = psWip[patchNums[i].num];
-    }
-    return patchNums;
-  },
-
-  /** @return {number|undefined} */
-  computeLatestPatchNum(allPatchSets) {
-    if (!allPatchSets || !allPatchSets.length) { return undefined; }
-    if (allPatchSets[0].num === PatchSetBehavior.EDIT_NAME) {
-      return allPatchSets[1].num;
-    }
-    return allPatchSets[0].num;
-  },
-
-  /** @return {boolean} */
-  hasEditBasedOnCurrentPatchSet(allPatchSets) {
-    if (!allPatchSets || allPatchSets.length < 2) { return false; }
-    return allPatchSets[0].num === PatchSetBehavior.EDIT_NAME;
-  },
-
-  /** @return {boolean} */
-  hasEditPatchsetLoaded(patchRangeRecord) {
-    const patchRange = patchRangeRecord.base;
-    if (!patchRange) { return false; }
-    return patchRange.patchNum === PatchSetBehavior.EDIT_NAME ||
-        patchRange.basePatchNum === PatchSetBehavior.EDIT_NAME;
-  },
-
-  /**
-   * Check whether there is no newer patch than the latest patch that was
-   * available when this change was loaded.
-   *
-   * @return {Promise<!Object>} A promise that yields true if the latest patch
-   *     has been loaded, and false if a newer patch has been uploaded in the
-   *     meantime. The promise is rejected on network error.
-   */
-  fetchChangeUpdates(change, restAPI) {
-    const knownLatest = PatchSetBehavior.computeLatestPatchNum(
-        PatchSetBehavior.computeAllPatchSets(change));
-    return restAPI.getChangeDetail(change._number)
-        .then(detail => {
-          if (!detail) {
-            const error = new Error('Unable to check for latest patchset.');
-            return Promise.reject(error);
-          }
-          const actualLatest = PatchSetBehavior.computeLatestPatchNum(
-              PatchSetBehavior.computeAllPatchSets(detail));
-          return {
-            isLatest: actualLatest <= knownLatest,
-            newStatus: change.status !== detail.status ? detail.status : null,
-            newMessages: change.messages.length < detail.messages.length,
-          };
-        });
-  },
-
-  /**
-   * @param {number|string} patchNum
-   * @param {!Array<!Object>} revisions A sorted array of revisions.
-   *
-   * @return {number} The index of the revision with the given patchNum.
-   */
-  findSortedIndex(patchNum, revisions) {
-    revisions = revisions || [];
-    const findNum = rev => rev._number + '' === patchNum + '';
-    return revisions.findIndex(findNum);
-  },
-
-  /**
-   * Convert parent indexes from patch range expressions to numbers.
-   * For example, in a patch range expression `"-3"` becomes `3`.
-   *
-   * @param {number|string} rangeBase
-   * @return {number}
-   */
-  getParentIndex(rangeBase) {
-    return -parseInt(rangeBase + '', 10);
-  },
-};
-
-// eslint-disable-next-line no-unused-vars
-function defineEmptyMixin() {
-  // This is a temporary function.
-  // Polymer linter doesn't process correctly the following code:
-  // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
-  // To workaround this issue, the mock mixin is declared in this method.
-  // In the following changes, legacy behaviors will be converted to mixins.
-
-  /**
-   * @polymer
-   * @mixinFunction
-   */
-  const PatchSetMixin = base => // eslint-disable-line no-unused-vars
-    class extends base {
-      computeLatestPatchNum(allPatchSets) {}
-
-      hasEditPatchsetLoaded(patchRangeRecord) {}
-
-      hasEditBasedOnCurrentPatchSet(allPatchSets) {}
-
-      computeAllPatchSets(change) {}
-    };
-}
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.PatchSetBehavior = PatchSetBehavior;
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
deleted file mode 100644
index f03e3ac..0000000
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
+++ /dev/null
@@ -1,324 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<!-- Polymer included for the html import polyfill. -->
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<title>gr-patch-set-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {PatchSetBehavior} from './gr-patch-set-behavior.js';
-suite('gr-patch-set-behavior tests', () => {
-  test('getRevisionByPatchNum', () => {
-    const get = PatchSetBehavior.getRevisionByPatchNum;
-    const revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.deepEqual(get(revisions, '1'), revisions[1]);
-    assert.deepEqual(get(revisions, 2), revisions[2]);
-    assert.equal(get(revisions, '3'), undefined);
-  });
-
-  test('fetchChangeUpdates on latest', done => {
-    const knownChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const mockRestApi = {
-      getChangeDetail() {
-        return Promise.resolve(knownChange);
-      },
-    };
-    PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
-        .then(result => {
-          assert.isTrue(result.isLatest);
-          assert.isNotOk(result.newStatus);
-          assert.isFalse(result.newMessages);
-          done();
-        });
-  });
-
-  test('fetchChangeUpdates not on latest', done => {
-    const knownChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const actualChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-        sha3: {description: 'patch 3', _number: 3},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const mockRestApi = {
-      getChangeDetail() {
-        return Promise.resolve(actualChange);
-      },
-    };
-    PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
-        .then(result => {
-          assert.isFalse(result.isLatest);
-          assert.isNotOk(result.newStatus);
-          assert.isFalse(result.newMessages);
-          done();
-        });
-  });
-
-  test('fetchChangeUpdates new status', done => {
-    const knownChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const actualChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'MERGED',
-      messages: [],
-    };
-    const mockRestApi = {
-      getChangeDetail() {
-        return Promise.resolve(actualChange);
-      },
-    };
-    PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
-        .then(result => {
-          assert.isTrue(result.isLatest);
-          assert.equal(result.newStatus, 'MERGED');
-          assert.isFalse(result.newMessages);
-          done();
-        });
-  });
-
-  test('fetchChangeUpdates new messages', done => {
-    const knownChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const actualChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [{message: 'blah blah'}],
-    };
-    const mockRestApi = {
-      getChangeDetail() {
-        return Promise.resolve(actualChange);
-      },
-    };
-    PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
-        .then(result => {
-          assert.isTrue(result.isLatest);
-          assert.isNotOk(result.newStatus);
-          assert.isTrue(result.newMessages);
-          done();
-        });
-  });
-
-  test('_computeWipForPatchSets', () => {
-    // Compute patch sets for a given timeline on a change. The initial WIP
-    // property of the change can be true or false. The map of tags by
-    // revision is keyed by patch set number. Each value is a list of change
-    // message tags in the order that they occurred in the timeline. These
-    // indicate actions that modify the WIP property of the change and/or
-    // create new patch sets.
-    //
-    // Returns the actual results with an assertWip method that can be used
-    // to compare against an expected value for a particular patch set.
-    const compute = (initialWip, tagsByRevision) => {
-      const change = {
-        messages: [],
-        work_in_progress: initialWip,
-      };
-      const revs = Object.keys(tagsByRevision).sort((a, b) => a - b);
-      for (const rev of revs) {
-        for (const tag of tagsByRevision[rev]) {
-          change.messages.push({
-            tag,
-            _revision_number: rev,
-          });
-        }
-      }
-      let patchNums = revs.map(rev => { return {num: rev}; });
-      patchNums = PatchSetBehavior._computeWipForPatchSets(
-          change, patchNums);
-      const actualWipsByRevision = {};
-      for (const patchNum of patchNums) {
-        actualWipsByRevision[patchNum.num] = patchNum.wip;
-      }
-      const verifier = {
-        assertWip(revision, expectedWip) {
-          const patchNum = patchNums.find(patchNum => patchNum.num == revision);
-          if (!patchNum) {
-            assert.fail('revision ' + revision + ' not found');
-          }
-          assert.equal(patchNum.wip, expectedWip,
-              'wip state for ' + revision + ' is ' +
-            patchNum.wip + '; expected ' + expectedWip);
-          return verifier;
-        },
-      };
-      return verifier;
-    };
-
-    compute(false, {1: ['upload']}).assertWip(1, false);
-    compute(true, {1: ['upload']}).assertWip(1, true);
-
-    const setWip = 'autogenerated:gerrit:setWorkInProgress';
-    const uploadInWip = 'autogenerated:gerrit:newWipPatchSet';
-    const clearWip = 'autogenerated:gerrit:setReadyForReview';
-
-    compute(false, {
-      1: ['upload', setWip],
-      2: ['upload'],
-      3: ['upload', clearWip],
-      4: ['upload', setWip],
-    }).assertWip(1, false) // Change was created with PS1 ready for review
-        .assertWip(2, true) // PS2 was uploaded during WIP
-        .assertWip(3, false) // PS3 was marked ready for review after upload
-        .assertWip(4, false); // PS4 was uploaded ready for review
-
-    compute(false, {
-      1: [uploadInWip, null, 'addReviewer'],
-      2: ['upload'],
-      3: ['upload', clearWip, setWip],
-      4: ['upload'],
-      5: ['upload', clearWip],
-      6: [uploadInWip],
-    }).assertWip(1, true) // Change was created in WIP
-        .assertWip(2, true) // PS2 was uploaded during WIP
-        .assertWip(3, false) // PS3 was marked ready for review
-        .assertWip(4, true) // PS4 was uploaded during WIP
-        .assertWip(5, false) // PS5 was marked ready for review
-        .assertWip(6, true); // PS6 was uploaded with WIP option
-  });
-
-  test('patchNumEquals', () => {
-    const equals = PatchSetBehavior.patchNumEquals;
-    assert.isFalse(equals('edit', 'PARENT'));
-    assert.isFalse(equals('edit', NaN));
-    assert.isFalse(equals(1, '2'));
-
-    assert.isTrue(equals(1, '1'));
-    assert.isTrue(equals(1, 1));
-    assert.isTrue(equals('edit', 'edit'));
-    assert.isTrue(equals('PARENT', 'PARENT'));
-  });
-
-  test('isMergeParent', () => {
-    const isParent = PatchSetBehavior.isMergeParent;
-    assert.isFalse(isParent(1));
-    assert.isFalse(isParent(4321));
-    assert.isFalse(isParent('52'));
-    assert.isFalse(isParent('edit'));
-    assert.isFalse(isParent('PARENT'));
-    assert.isFalse(isParent(0));
-
-    assert.isTrue(isParent(-23));
-    assert.isTrue(isParent(-1));
-    assert.isTrue(isParent('-42'));
-  });
-
-  test('findEditParentRevision', () => {
-    const findParent = PatchSetBehavior.findEditParentRevision;
-    let revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.strictEqual(findParent(revisions), null);
-
-    revisions = [...revisions, {_number: 'edit', basePatchNum: 3}];
-    assert.strictEqual(findParent(revisions), null);
-
-    revisions = [...revisions, {_number: 3}];
-    assert.deepEqual(findParent(revisions), {_number: 3});
-  });
-
-  test('findEditParentPatchNum', () => {
-    const findNum = PatchSetBehavior.findEditParentPatchNum;
-    let revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.equal(findNum(revisions), -1);
-
-    revisions =
-        [...revisions, {_number: 'edit', basePatchNum: 3}, {_number: 3}];
-    assert.deepEqual(findNum(revisions), 3);
-  });
-
-  test('sortRevisions', () => {
-    const sort = PatchSetBehavior.sortRevisions;
-    const revisions = [
-      {_number: 0},
-      {_number: 2},
-      {_number: 1},
-    ];
-    const sorted = [
-      {_number: 2},
-      {_number: 1},
-      {_number: 0},
-    ];
-
-    assert.deepEqual(sort(revisions), sorted);
-
-    // Edit patchset should follow directly after its basePatchNum.
-    revisions.push({_number: 'edit', basePatchNum: 2});
-    sorted.unshift({_number: 'edit', basePatchNum: 2});
-    assert.deepEqual(sort(revisions), sorted);
-
-    revisions[0].basePatchNum = 0;
-    const edit = sorted.shift();
-    edit.basePatchNum = 0;
-    // Edit patchset should be at index 2.
-    sorted.splice(2, 0, edit);
-    assert.deepEqual(sort(revisions), sorted);
-  });
-
-  test('getParentIndex', () => {
-    assert.equal(PatchSetBehavior.getParentIndex('-13'), 13);
-    assert.equal(PatchSetBehavior.getParentIndex(-4), 4);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.js b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.js
deleted file mode 100644
index 7745b42..0000000
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.js
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** @polymerBehavior Gerrit.PathListBehavior */
-export const PathListBehavior = {
-
-  COMMIT_MESSAGE_PATH: '/COMMIT_MSG',
-  MERGE_LIST_PATH: '/MERGE_LIST',
-
-  /**
-   * @param {string} a
-   * @param {string} b
-   * @return {number}
-   */
-  specialFilePathCompare(a, b) {
-    // The commit message always goes first.
-    if (a === PathListBehavior.COMMIT_MESSAGE_PATH) {
-      return -1;
-    }
-    if (b === PathListBehavior.COMMIT_MESSAGE_PATH) {
-      return 1;
-    }
-
-    // The merge list always comes next.
-    if (a === PathListBehavior.MERGE_LIST_PATH) {
-      return -1;
-    }
-    if (b === PathListBehavior.MERGE_LIST_PATH) {
-      return 1;
-    }
-
-    const aLastDotIndex = a.lastIndexOf('.');
-    const aExt = a.substr(aLastDotIndex + 1);
-    const aFile = a.substr(0, aLastDotIndex) || a;
-
-    const bLastDotIndex = b.lastIndexOf('.');
-    const bExt = b.substr(bLastDotIndex + 1);
-    const bFile = b.substr(0, bLastDotIndex) || b;
-
-    // Sort header files above others with the same base name.
-    const headerExts = ['h', 'hxx', 'hpp'];
-    if (aFile.length > 0 && aFile === bFile) {
-      if (headerExts.includes(aExt) && headerExts.includes(bExt)) {
-        return a.localeCompare(b);
-      }
-      if (headerExts.includes(aExt)) {
-        return -1;
-      }
-      if (headerExts.includes(bExt)) {
-        return 1;
-      }
-    }
-    return aFile.localeCompare(bFile) || a.localeCompare(b);
-  },
-
-  computeDisplayPath(path) {
-    if (path === PathListBehavior.COMMIT_MESSAGE_PATH) {
-      return 'Commit message';
-    } else if (path === PathListBehavior.MERGE_LIST_PATH) {
-      return 'Merge list';
-    }
-    return path;
-  },
-
-  isMagicPath(path) {
-    return !!path &&
-        (path === PathListBehavior.COMMIT_MESSAGE_PATH || path ===
-            PathListBehavior.MERGE_LIST_PATH);
-  },
-
-  computeTruncatedPath(path) {
-    return PathListBehavior.truncatePath(
-        PathListBehavior.computeDisplayPath(path));
-  },
-
-  /**
-   * Truncates URLs to display filename only
-   * Example
-   * // returns '.../text.html'
-   * util.truncatePath.('dir/text.html');
-   * Example
-   * // returns 'text.html'
-   * util.truncatePath.('text.html');
-   *
-   * @param {string} path
-   * @param {number=} opt_threshold
-   * @return {string} Returns the truncated value of a URL.
-   */
-  truncatePath(path, opt_threshold) {
-    const threshold = opt_threshold || 1;
-    const pathPieces = path.split('/');
-
-    if (pathPieces.length <= threshold) { return path; }
-
-    const index = pathPieces.length - threshold;
-    // Character is an ellipsis.
-    return `\u2026/${pathPieces.slice(index).join('/')}`;
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.PathListBehavior = PathListBehavior;
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
deleted file mode 100644
index 1b7a42a..0000000
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
+++ /dev/null
@@ -1,100 +0,0 @@
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<!-- Polymer included for the html import polyfill. -->
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<title>gr-path-list-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {PathListBehavior} from './gr-path-list-behavior.js';
-suite('gr-path-list-behavior tests', () => {
-  test('special sort', () => {
-    const sort = PathListBehavior.specialFilePathCompare;
-    const testFiles = [
-      '/a.h',
-      '/MERGE_LIST',
-      '/a.cpp',
-      '/COMMIT_MSG',
-      '/asdasd',
-      '/mrPeanutbutter.py',
-    ];
-    assert.deepEqual(
-        testFiles.sort(sort),
-        [
-          '/COMMIT_MSG',
-          '/MERGE_LIST',
-          '/a.h',
-          '/a.cpp',
-          '/asdasd',
-          '/mrPeanutbutter.py',
-        ]);
-  });
-
-  test('file display name', () => {
-    const name = PathListBehavior.computeDisplayPath;
-    assert.equal(name('/foo/bar/baz'), '/foo/bar/baz');
-    assert.equal(name('/foobarbaz'), '/foobarbaz');
-    assert.equal(name('/COMMIT_MSG'), 'Commit message');
-    assert.equal(name('/MERGE_LIST'), 'Merge list');
-  });
-
-  test('isMagicPath', () => {
-    const isMagic = PathListBehavior.isMagicPath;
-    assert.isFalse(isMagic(undefined));
-    assert.isFalse(isMagic('/foo.cc'));
-    assert.isTrue(isMagic('/COMMIT_MSG'));
-    assert.isTrue(isMagic('/MERGE_LIST'));
-  });
-
-  test('truncatePath with long path should add ellipsis', () => {
-    const truncatePath = PathListBehavior.truncatePath;
-    let path = 'level1/level2/level3/level4/file.js';
-    let shortenedPath = truncatePath(path);
-    // The expected path is truncated with an ellipsis.
-    const expectedPath = '\u2026/file.js';
-    assert.equal(shortenedPath, expectedPath);
-
-    path = 'level2/file.js';
-    shortenedPath = truncatePath(path);
-    assert.equal(shortenedPath, expectedPath);
-  });
-
-  test('truncatePath with opt_threshold', () => {
-    const truncatePath = PathListBehavior.truncatePath;
-    let path = 'level1/level2/level3/level4/file.js';
-    let shortenedPath = truncatePath(path, 2);
-    // The expected path is truncated with an ellipsis.
-    const expectedPath = '\u2026/level4/file.js';
-    assert.equal(shortenedPath, expectedPath);
-
-    path = 'level2/file.js';
-    shortenedPath = truncatePath(path, 2);
-    assert.equal(shortenedPath, path);
-  });
-
-  test('truncatePath with short path should not add ellipsis', () => {
-    const truncatePath = PathListBehavior.truncatePath;
-    const path = 'file.js';
-    const expectedPath = 'file.js';
-    const shortenedPath = truncatePath(path);
-    assert.equal(shortenedPath, expectedPath);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js b/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js
deleted file mode 100644
index 3ba2e60..0000000
--- a/polygerrit-ui/app/behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** @polymerBehavior Gerrit.RepoPluginConfig*/
-export const RepoPluginConfig = {
-  // Should be kept in sync with
-  // gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
-  ENTRY_TYPES: {
-    ARRAY: 'ARRAY',
-    BOOLEAN: 'BOOLEAN',
-    INT: 'INT',
-    LIST: 'LIST',
-    LONG: 'LONG',
-    STRING: 'STRING',
-  },
-  PLUGIN_CONFIG_CHANGED: 'plugin-config-changed',
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.RepoPluginConfig = RepoPluginConfig;
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
deleted file mode 100644
index 2a592f0..0000000
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
+++ /dev/null
@@ -1,173 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../scripts/bundled-polymer.js';
-
-import '../../elements/shared/gr-tooltip/gr-tooltip.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {getRootElement} from '../../scripts/rootElement.js';
-
-const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
-
-/** @polymerBehavior Gerrit.TooltipBehavior */
-export const TooltipBehavior = {
-
-  properties: {
-    hasTooltip: {
-      type: Boolean,
-      observer: '_setupTooltipListeners',
-    },
-    positionBelow: {
-      type: Boolean,
-      value: false,
-      reflectToAttribute: true,
-    },
-
-    _isTouchDevice: {
-      type: Boolean,
-      value() {
-        return 'ontouchstart' in document.documentElement;
-      },
-    },
-    _tooltip: Object,
-    _titleText: String,
-    _hasSetupTooltipListeners: {
-      type: Boolean,
-      value: false,
-    },
-  },
-
-  /** @override */
-  detached() {
-    // NOTE: if you define your own `detached` in your component
-    // then this won't take affect (as its not a class yet)
-    this._handleHideTooltip();
-    this.removeEventListener('mouseenter', this._mouseenterHandler);
-  },
-
-  _setupTooltipListeners() {
-    if (!this._mouseenterHandler) {
-      this._mouseenterHandler = this._handleShowTooltip.bind(this);
-    }
-
-    if (!this.hasTooltip) {
-      // if attribute set to false, remove the listener
-      this.removeEventListener('mouseenter', this._mouseenterHandler);
-      this._hasSetupTooltipListeners = false;
-      return;
-    }
-
-    if (this._hasSetupTooltipListeners) {
-      return;
-    }
-    this._hasSetupTooltipListeners = true;
-
-    this.addEventListener('mouseenter', this._mouseenterHandler);
-  },
-
-  _handleShowTooltip(e) {
-    if (this._isTouchDevice) { return; }
-
-    if (!this.hasAttribute('title') ||
-        this.getAttribute('title') === '' ||
-        this._tooltip) {
-      return;
-    }
-
-    // Store the title attribute text then set it to an empty string to
-    // prevent it from showing natively.
-    this._titleText = this.getAttribute('title');
-    this.setAttribute('title', '');
-
-    const tooltip = document.createElement('gr-tooltip');
-    tooltip.text = this._titleText;
-    tooltip.maxWidth = this.getAttribute('max-width');
-    tooltip.positionBelow = this.getAttribute('position-below');
-
-    // Set visibility to hidden before appending to the DOM so that
-    // calculations can be made based on the element’s size.
-    tooltip.style.visibility = 'hidden';
-    getRootElement().appendChild(tooltip);
-    this._positionTooltip(tooltip);
-    tooltip.style.visibility = null;
-
-    this._tooltip = tooltip;
-    this.listen(window, 'scroll', '_handleWindowScroll');
-    this.listen(this, 'mouseleave', '_handleHideTooltip');
-    this.listen(this, 'click', '_handleHideTooltip');
-  },
-
-  _handleHideTooltip(e) {
-    if (this._isTouchDevice) { return; }
-    if (!this.hasAttribute('title') ||
-        this._titleText == null) {
-      return;
-    }
-
-    this.unlisten(window, 'scroll', '_handleWindowScroll');
-    this.unlisten(this, 'mouseleave', '_handleHideTooltip');
-    this.unlisten(this, 'click', '_handleHideTooltip');
-    this.setAttribute('title', this._titleText);
-    if (this._tooltip && this._tooltip.parentNode) {
-      this._tooltip.parentNode.removeChild(this._tooltip);
-    }
-    this._tooltip = null;
-  },
-
-  _handleWindowScroll(e) {
-    if (!this._tooltip) { return; }
-
-    this._positionTooltip(this._tooltip);
-  },
-
-  _positionTooltip(tooltip) {
-    // This flush is needed for tooltips to be positioned correctly in Firefox
-    // and Safari.
-    flush();
-    const rect = this.getBoundingClientRect();
-    const boxRect = tooltip.getBoundingClientRect();
-    const parentRect = tooltip.parentElement.getBoundingClientRect();
-    const top = rect.top - parentRect.top;
-    const left =
-        rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
-    const right = parentRect.width - left - boxRect.width;
-    if (left < 0) {
-      tooltip.updateStyles({
-        '--gr-tooltip-arrow-center-offset': left + 'px',
-      });
-    } else if (right < 0) {
-      tooltip.updateStyles({
-        '--gr-tooltip-arrow-center-offset': (-0.5 * right) + 'px',
-      });
-    }
-    tooltip.style.left = Math.max(0, left) + 'px';
-
-    if (!this.positionBelow) {
-      tooltip.style.top = Math.max(0, top) + 'px';
-      tooltip.style.transform = 'translateY(calc(-100% - ' + BOTTOM_OFFSET +
-          'px))';
-    } else {
-      tooltip.style.top = top + rect.height + BOTTOM_OFFSET + 'px';
-    }
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.TooltipBehavior = TooltipBehavior;
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
deleted file mode 100644
index 79c515e..0000000
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
+++ /dev/null
@@ -1,154 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<title>tooltip-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <tooltip-behavior-element></tooltip-behavior-element>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {TooltipBehavior} from './gr-tooltip-behavior.js';
-suite('gr-tooltip-behavior tests', () => {
-  let element;
-  let sandbox;
-
-  function makeTooltip(tooltipRect, parentRect) {
-    return {
-      getBoundingClientRect() { return tooltipRect; },
-      updateStyles: sinon.stub(),
-      style: {left: 0, top: 0},
-      parentElement: {
-        getBoundingClientRect() { return parentRect; },
-      },
-    };
-  }
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'tooltip-behavior-element',
-      behaviors: [TooltipBehavior],
-    });
-  });
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('normal position', () => {
-    sandbox.stub(element, 'getBoundingClientRect', () => {
-      return {top: 100, left: 100, width: 200};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 50},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.isFalse(tooltip.updateStyles.called);
-    assert.equal(tooltip.style.left, '175px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('left side position', () => {
-    sandbox.stub(element, 'getBoundingClientRect', () => {
-      return {top: 100, left: 10, width: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '0px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('right side position', () => {
-    sandbox.stub(element, 'getBoundingClientRect', () => {
-      return {top: 100, left: 950, width: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '915px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('position to bottom', () => {
-    sandbox.stub(element, 'getBoundingClientRect', () => {
-      return {top: 100, left: 950, width: 50, height: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element.positionBelow = true;
-    element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '915px');
-    assert.equal(tooltip.style.top, '157.2px');
-  });
-
-  test('hides tooltip when detached', () => {
-    sandbox.stub(element, '_handleHideTooltip');
-    element.remove();
-    flushAsynchronousOperations();
-    assert.isTrue(element._handleHideTooltip.called);
-  });
-
-  test('sets up listeners when has-tooltip is changed', () => {
-    const addListenerStub = sandbox.stub(element, 'addEventListener');
-    element.hasTooltip = true;
-    assert.isTrue(addListenerStub.called);
-  });
-
-  test('clean up listeners when has-tooltip changed to false', () => {
-    const removeListenerStub = sandbox.stub(element, 'removeEventListener');
-    element.hasTooltip = true;
-    element.hasTooltip = false;
-    assert.isTrue(removeListenerStub.called);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js
deleted file mode 100644
index 5c9e911..0000000
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** @polymerBehavior Gerrit.URLEncodingBehavior */
-export const URLEncodingBehavior = {
-  /**
-   * Pretty-encodes a URL. Double-encodes the string, and then replaces
-   *   benevolent characters for legibility.
-   *
-   * @param {string} url
-   * @param {boolean=} replaceSlashes
-   * @return {string}
-   */
-  encodeURL(url, replaceSlashes) {
-    // @see Issue 4255 regarding double-encoding.
-    let output = encodeURIComponent(encodeURIComponent(url));
-    // @see Issue 4577 regarding more readable URLs.
-    output = output.replace(/%253A/g, ':');
-    output = output.replace(/%2520/g, '+');
-    if (replaceSlashes) {
-      output = output.replace(/%252F/g, '/');
-    }
-    return output;
-  },
-
-  /**
-   * Single decode for URL components. Will decode plus signs ('+') to spaces.
-   * Note: because this function decodes once, it is not the inverse of
-   * encodeURL.
-   *
-   * @param {string} url
-   * @return {string}
-   */
-  singleDecodeURL(url) {
-    const withoutPlus = url.replace(/\+/g, '%20');
-    return decodeURIComponent(withoutPlus);
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.URLEncodingBehavior = URLEncodingBehavior;
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
deleted file mode 100644
index d0a2cde..0000000
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior_test.html
+++ /dev/null
@@ -1,92 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<title>gr-url-encoding-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {URLEncodingBehavior} from './gr-url-encoding-behavior.js';
-suite('gr-url-encoding-behavior tests', () => {
-  let element;
-  let sandbox;
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'test-element',
-      behaviors: [URLEncodingBehavior],
-    });
-  });
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('encodeURL', () => {
-    test('double encodes', () => {
-      assert.equal(element.encodeURL('abc?123'), 'abc%253F123');
-      assert.equal(element.encodeURL('def/ghi'), 'def%252Fghi');
-      assert.equal(element.encodeURL('jkl'), 'jkl');
-      assert.equal(element.encodeURL(''), '');
-    });
-
-    test('does not convert colons', () => {
-      assert.equal(element.encodeURL('mno:pqr'), 'mno:pqr');
-    });
-
-    test('converts spaces to +', () => {
-      assert.equal(element.encodeURL('words with spaces'), 'words+with+spaces');
-    });
-
-    test('does not convert slashes when configured', () => {
-      assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
-    });
-
-    test('does not convert slashes when configured', () => {
-      assert.equal(element.encodeURL('stu/vwx', true), 'stu/vwx');
-    });
-  });
-
-  suite('singleDecodeUrl', () => {
-    test('single decodes', () => {
-      assert.equal(element.singleDecodeURL('abc%3Fdef'), 'abc?def');
-    });
-
-    test('converts + to space', () => {
-      assert.equal(element.singleDecodeURL('ghi+jkl'), 'ghi jkl');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
deleted file mode 100644
index cb21a9f..0000000
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js
+++ /dev/null
@@ -1,660 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-/*
-
-How to Add a Keyboard Shortcut
-==============================
-
-A keyboard shortcut is composed of the following parts:
-
-  1. A semantic identifier (e.g. OPEN_CHANGE, NEXT_PAGE)
-  2. Documentation for the keyboard shortcut help dialog
-  3. A binding between key combos and the semantic identifier
-  4. A binding between the semantic identifier and a listener
-
-Parts (1) and (2) for all shortcuts are defined in this file. The semantic
-identifier is declared in the Shortcut enum near the head of this script:
-
-  const Shortcut = {
-    // ...
-    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
-    // ...
-  };
-
-Immediately following the Shortcut enum definition, there is a _describe
-function defined which is then invoked many times to populate the help dialog.
-Add a new invocation here to document the shortcut:
-
-  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
-      'Hide/show left diff');
-
-When an attached view binds one or more key combos to this shortcut, the help
-dialog will display this text in the given section (in this case, "Diffs"). See
-the ShortcutSection enum immediately below for the list of supported sections.
-
-Part (3), the actual key bindings, are declared by gr-app. In the future, this
-system may be expanded to allow key binding customizations by plugins or user
-preferences. Key bindings are defined in the following forms:
-
-  // Ordinary shortcut with a single binding.
-  this.bindShortcut(
-      this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-
-  // Ordinary shortcut with multiple bindings.
-  this.bindShortcut(
-      this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-
-  // A "go-key" keyboard shortcut, which is combined with a previously and
-  // continuously pressed "go" key (the go-key is hard-coded as 'g').
-  this.bindShortcut(
-      this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
-
-  // A "doc-only" keyboard shortcut. This declares the key-binding for help
-  // dialog purposes, but doesn't actually implement the binding. It is up
-  // to some element to implement this binding using iron-a11y-keys-behavior's
-  // keyBindings property.
-  this.bindShortcut(
-      this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
-
-Part (4), the listener definitions, are declared by the view or element that
-implements the shortcut behavior. This is done by implementing a method named
-keyboardShortcuts() in an element that mixes in this behavior, returning an
-object that maps semantic identifiers (as property names) to listener method
-names, like this:
-
-  keyboardShortcuts() {
-    return {
-      [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-    };
-  },
-
-You can implement key bindings in an element that is hosted by a view IF that
-element is always attached exactly once under that view (e.g. the search bar in
-gr-app). When that is not the case, you will have to define a doc-only binding
-in gr-app, declare the shortcut in the view that hosts the element, and use
-iron-a11y-keys-behavior's keyBindings attribute to implement the binding in the
-element. An example of this is in comment threads. A diff view supports actions
-on comment threads, but there may be zero or many comment threads attached at
-any given point. So the shortcut is declared as doc-only by the diff view and
-by gr-app, and actually implemented by gr-comment-thread.
-
-NOTE: doc-only shortcuts will not be customizable in the same way that other
-shortcuts are.
-*/
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-import '../../scripts/bundled-polymer.js';
-
-import {IronA11yKeysBehavior} from '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-const DOC_ONLY = 'DOC_ONLY';
-const GO_KEY = 'GO_KEY';
-
-// The maximum age of a keydown event to be used in a jump navigation. This
-// is only for cases when the keyup event is lost.
-const GO_KEY_TIMEOUT_MS = 1000;
-
-const ShortcutSection = {
-  ACTIONS: 'Actions',
-  DIFFS: 'Diffs',
-  EVERYWHERE: 'Everywhere',
-  FILE_LIST: 'File list',
-  NAVIGATION: 'Navigation',
-  REPLY_DIALOG: 'Reply dialog',
-};
-
-const Shortcut = {
-  OPEN_SHORTCUT_HELP_DIALOG: 'OPEN_SHORTCUT_HELP_DIALOG',
-  GO_TO_USER_DASHBOARD: 'GO_TO_USER_DASHBOARD',
-  GO_TO_OPENED_CHANGES: 'GO_TO_OPENED_CHANGES',
-  GO_TO_MERGED_CHANGES: 'GO_TO_MERGED_CHANGES',
-  GO_TO_ABANDONED_CHANGES: 'GO_TO_ABANDONED_CHANGES',
-  GO_TO_WATCHED_CHANGES: 'GO_TO_WATCHED_CHANGES',
-
-  CURSOR_NEXT_CHANGE: 'CURSOR_NEXT_CHANGE',
-  CURSOR_PREV_CHANGE: 'CURSOR_PREV_CHANGE',
-  OPEN_CHANGE: 'OPEN_CHANGE',
-  NEXT_PAGE: 'NEXT_PAGE',
-  PREV_PAGE: 'PREV_PAGE',
-  TOGGLE_CHANGE_REVIEWED: 'TOGGLE_CHANGE_REVIEWED',
-  TOGGLE_CHANGE_STAR: 'TOGGLE_CHANGE_STAR',
-  REFRESH_CHANGE_LIST: 'REFRESH_CHANGE_LIST',
-
-  OPEN_REPLY_DIALOG: 'OPEN_REPLY_DIALOG',
-  OPEN_DOWNLOAD_DIALOG: 'OPEN_DOWNLOAD_DIALOG',
-  EXPAND_ALL_MESSAGES: 'EXPAND_ALL_MESSAGES',
-  COLLAPSE_ALL_MESSAGES: 'COLLAPSE_ALL_MESSAGES',
-  UP_TO_DASHBOARD: 'UP_TO_DASHBOARD',
-  UP_TO_CHANGE: 'UP_TO_CHANGE',
-  TOGGLE_DIFF_MODE: 'TOGGLE_DIFF_MODE',
-  REFRESH_CHANGE: 'REFRESH_CHANGE',
-  EDIT_TOPIC: 'EDIT_TOPIC',
-
-  NEXT_LINE: 'NEXT_LINE',
-  PREV_LINE: 'PREV_LINE',
-  VISIBLE_LINE: 'VISIBLE_LINE',
-  NEXT_CHUNK: 'NEXT_CHUNK',
-  PREV_CHUNK: 'PREV_CHUNK',
-  EXPAND_ALL_DIFF_CONTEXT: 'EXPAND_ALL_DIFF_CONTEXT',
-  NEXT_COMMENT_THREAD: 'NEXT_COMMENT_THREAD',
-  PREV_COMMENT_THREAD: 'PREV_COMMENT_THREAD',
-  EXPAND_ALL_COMMENT_THREADS: 'EXPAND_ALL_COMMENT_THREADS',
-  COLLAPSE_ALL_COMMENT_THREADS: 'COLLAPSE_ALL_COMMENT_THREADS',
-  LEFT_PANE: 'LEFT_PANE',
-  RIGHT_PANE: 'RIGHT_PANE',
-  TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
-  NEW_COMMENT: 'NEW_COMMENT',
-  SAVE_COMMENT: 'SAVE_COMMENT',
-  OPEN_DIFF_PREFS: 'OPEN_DIFF_PREFS',
-  TOGGLE_DIFF_REVIEWED: 'TOGGLE_DIFF_REVIEWED',
-
-  NEXT_FILE: 'NEXT_FILE',
-  PREV_FILE: 'PREV_FILE',
-  NEXT_FILE_WITH_COMMENTS: 'NEXT_FILE_WITH_COMMENTS',
-  PREV_FILE_WITH_COMMENTS: 'PREV_FILE_WITH_COMMENTS',
-  NEXT_UNREVIEWED_FILE: 'NEXT_UNREVIEWED_FILE',
-  CURSOR_NEXT_FILE: 'CURSOR_NEXT_FILE',
-  CURSOR_PREV_FILE: 'CURSOR_PREV_FILE',
-  OPEN_FILE: 'OPEN_FILE',
-  TOGGLE_FILE_REVIEWED: 'TOGGLE_FILE_REVIEWED',
-  TOGGLE_ALL_INLINE_DIFFS: 'TOGGLE_ALL_INLINE_DIFFS',
-  TOGGLE_INLINE_DIFF: 'TOGGLE_INLINE_DIFF',
-
-  OPEN_FIRST_FILE: 'OPEN_FIRST_FILE',
-  OPEN_LAST_FILE: 'OPEN_LAST_FILE',
-
-  SEARCH: 'SEARCH',
-  SEND_REPLY: 'SEND_REPLY',
-  EMOJI_DROPDOWN: 'EMOJI_DROPDOWN',
-  TOGGLE_BLAME: 'TOGGLE_BLAME',
-};
-
-const _help = new Map();
-
-function _describe(shortcut, section, text) {
-  if (!_help.has(section)) {
-    _help.set(section, []);
-  }
-  _help.get(section).push({shortcut, text});
-}
-
-_describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
-_describe(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, ShortcutSection.EVERYWHERE,
-    'Show this dialog');
-_describe(Shortcut.GO_TO_USER_DASHBOARD, ShortcutSection.EVERYWHERE,
-    'Go to User Dashboard');
-_describe(Shortcut.GO_TO_OPENED_CHANGES, ShortcutSection.EVERYWHERE,
-    'Go to Opened Changes');
-_describe(Shortcut.GO_TO_MERGED_CHANGES, ShortcutSection.EVERYWHERE,
-    'Go to Merged Changes');
-_describe(Shortcut.GO_TO_ABANDONED_CHANGES, ShortcutSection.EVERYWHERE,
-    'Go to Abandoned Changes');
-_describe(Shortcut.GO_TO_WATCHED_CHANGES, ShortcutSection.EVERYWHERE,
-    'Go to Watched Changes');
-
-_describe(Shortcut.CURSOR_NEXT_CHANGE, ShortcutSection.ACTIONS,
-    'Select next change');
-_describe(Shortcut.CURSOR_PREV_CHANGE, ShortcutSection.ACTIONS,
-    'Select previous change');
-_describe(Shortcut.OPEN_CHANGE, ShortcutSection.ACTIONS,
-    'Show selected change');
-_describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
-_describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
-_describe(Shortcut.OPEN_REPLY_DIALOG, ShortcutSection.ACTIONS,
-    'Open reply dialog to publish comments and add reviewers');
-_describe(Shortcut.OPEN_DOWNLOAD_DIALOG, ShortcutSection.ACTIONS,
-    'Open download overlay');
-_describe(Shortcut.EXPAND_ALL_MESSAGES, ShortcutSection.ACTIONS,
-    'Expand all messages');
-_describe(Shortcut.COLLAPSE_ALL_MESSAGES, ShortcutSection.ACTIONS,
-    'Collapse all messages');
-_describe(Shortcut.REFRESH_CHANGE, ShortcutSection.ACTIONS,
-    'Reload the change at the latest patch');
-_describe(Shortcut.TOGGLE_CHANGE_REVIEWED, ShortcutSection.ACTIONS,
-    'Mark/unmark change as reviewed');
-_describe(Shortcut.TOGGLE_FILE_REVIEWED, ShortcutSection.ACTIONS,
-    'Toggle review flag on selected file');
-_describe(Shortcut.REFRESH_CHANGE_LIST, ShortcutSection.ACTIONS,
-    'Refresh list of changes');
-_describe(Shortcut.TOGGLE_CHANGE_STAR, ShortcutSection.ACTIONS,
-    'Star/unstar change');
-_describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS,
-    'Add a change topic');
-
-_describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
-_describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
-_describe(Shortcut.VISIBLE_LINE, ShortcutSection.DIFFS,
-    'Move cursor to currently visible code');
-_describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS,
-    'Go to next diff chunk');
-_describe(Shortcut.PREV_CHUNK, ShortcutSection.DIFFS,
-    'Go to previous diff chunk');
-_describe(Shortcut.EXPAND_ALL_DIFF_CONTEXT, ShortcutSection.DIFFS,
-    'Expand all diff context');
-_describe(Shortcut.NEXT_COMMENT_THREAD, ShortcutSection.DIFFS,
-    'Go to next comment thread');
-_describe(Shortcut.PREV_COMMENT_THREAD, ShortcutSection.DIFFS,
-    'Go to previous comment thread');
-_describe(Shortcut.EXPAND_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
-    'Expand all comment threads');
-_describe(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
-    'Collapse all comment threads');
-_describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
-_describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
-_describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
-    'Hide/show left diff');
-_describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
-_describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
-_describe(Shortcut.OPEN_DIFF_PREFS, ShortcutSection.DIFFS,
-    'Show diff preferences');
-_describe(Shortcut.TOGGLE_DIFF_REVIEWED, ShortcutSection.DIFFS,
-    'Mark/unmark file as reviewed');
-_describe(Shortcut.TOGGLE_DIFF_MODE, ShortcutSection.DIFFS,
-    'Toggle unified/side-by-side diff');
-_describe(Shortcut.NEXT_UNREVIEWED_FILE, ShortcutSection.DIFFS,
-    'Mark file as reviewed and go to next unreviewed file');
-_describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame');
-
-_describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file');
-_describe(Shortcut.PREV_FILE, ShortcutSection.NAVIGATION,
-    'Go to previous file');
-_describe(Shortcut.NEXT_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
-    'Go to next file that has comments');
-_describe(Shortcut.PREV_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
-    'Go to previous file that has comments');
-_describe(Shortcut.OPEN_FIRST_FILE, ShortcutSection.NAVIGATION,
-    'Go to first file');
-_describe(Shortcut.OPEN_LAST_FILE, ShortcutSection.NAVIGATION,
-    'Go to last file');
-_describe(Shortcut.UP_TO_DASHBOARD, ShortcutSection.NAVIGATION,
-    'Up to dashboard');
-_describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
-
-_describe(Shortcut.CURSOR_NEXT_FILE, ShortcutSection.FILE_LIST,
-    'Select next file');
-_describe(Shortcut.CURSOR_PREV_FILE, ShortcutSection.FILE_LIST,
-    'Select previous file');
-_describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST,
-    'Go to selected file');
-_describe(Shortcut.TOGGLE_ALL_INLINE_DIFFS, ShortcutSection.FILE_LIST,
-    'Show/hide all inline diffs');
-_describe(Shortcut.TOGGLE_INLINE_DIFF, ShortcutSection.FILE_LIST,
-    'Show/hide selected inline diff');
-
-_describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
-_describe(Shortcut.EMOJI_DROPDOWN, ShortcutSection.REPLY_DIALOG,
-    'Emoji dropdown');
-
-// Must be declared outside behavior implementation to be accessed inside
-// behavior functions.
-
-/** @return {!(Event|PolymerDomApi|PolymerEventApi)} */
-const getKeyboardEvent = function(e) {
-  e = dom(e.detail ? e.detail.keyboardEvent : e);
-  // When e is a keyboardEvent, e.event is not null.
-  if (e.event) { e = e.event; }
-  return e;
-};
-
-class ShortcutManager {
-  constructor() {
-    this.activeHosts = new Map();
-    this.bindings = new Map();
-    this.listeners = new Set();
-  }
-
-  bindShortcut(shortcut, ...bindings) {
-    this.bindings.set(shortcut, bindings);
-  }
-
-  getBindingsForShortcut(shortcut) {
-    return this.bindings.get(shortcut);
-  }
-
-  attachHost(host) {
-    if (!host.keyboardShortcuts) { return; }
-    const shortcuts = host.keyboardShortcuts();
-    this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
-    this.notifyListeners();
-    return shortcuts;
-  }
-
-  detachHost(host) {
-    if (this.activeHosts.delete(host)) {
-      this.notifyListeners();
-      return true;
-    }
-    return false;
-  }
-
-  addListener(listener) {
-    this.listeners.add(listener);
-    listener(this.directoryView());
-  }
-
-  removeListener(listener) {
-    return this.listeners.delete(listener);
-  }
-
-  getDescription(section, shortcutName) {
-    const binding =
-        _help.get(section).find(binding => binding.shortcut == shortcutName);
-    return binding ? binding.text : '';
-  }
-
-  getShortcut(shortcutName) {
-    const binding = this.bindings.get(shortcutName);
-    return binding ? this.describeBinding(binding) : '';
-  }
-
-  activeShortcutsBySection() {
-    const activeShortcuts = new Set();
-    this.activeHosts.forEach(shortcuts => {
-      shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
-    });
-
-    const activeShortcutsBySection = new Map();
-    _help.forEach((shortcutList, section) => {
-      shortcutList.forEach(shortcutHelp => {
-        if (activeShortcuts.has(shortcutHelp.shortcut)) {
-          if (!activeShortcutsBySection.has(section)) {
-            activeShortcutsBySection.set(section, []);
-          }
-          activeShortcutsBySection.get(section).push(shortcutHelp);
-        }
-      });
-    });
-    return activeShortcutsBySection;
-  }
-
-  directoryView() {
-    const view = new Map();
-    this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
-      const sectionView = [];
-      shortcutHelps.forEach(shortcutHelp => {
-        const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
-        if (!bindingDesc) { return; }
-        this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
-          sectionView.push({
-            binding: bindingDesc,
-            text: shortcutHelp.text,
-          });
-        });
-      });
-      view.set(section, sectionView);
-    });
-    return view;
-  }
-
-  distributeBindingDesc(bindingDesc) {
-    if (bindingDesc.length === 1 ||
-        this.comboSetDisplayWidth(bindingDesc) < 21) {
-      return [bindingDesc];
-    }
-    // Find the largest prefix of bindings that is under the
-    // size threshold.
-    const head = [bindingDesc[0]];
-    for (let i = 1; i < bindingDesc.length; i++) {
-      head.push(bindingDesc[i]);
-      if (this.comboSetDisplayWidth(head) >= 21) {
-        head.pop();
-        return [head].concat(
-            this.distributeBindingDesc(bindingDesc.slice(i)));
-      }
-    }
-  }
-
-  comboSetDisplayWidth(bindingDesc) {
-    const bindingSizer = binding => binding.reduce(
-        (acc, key) => acc + key.length, 0);
-    // Width is the sum of strings + (n-1) * 2 to account for the word
-    // "or" joining them.
-    return bindingDesc.reduce(
-        (acc, binding) => acc + bindingSizer(binding), 0) +
-        2 * (bindingDesc.length - 1);
-  }
-
-  describeBindings(shortcut) {
-    const bindings = this.bindings.get(shortcut);
-    if (!bindings) { return null; }
-    if (bindings[0] === GO_KEY) {
-      return [['g'].concat(bindings.slice(1))];
-    }
-    return bindings
-        .filter(binding => binding !== DOC_ONLY)
-        .map(binding => this.describeBinding(binding));
-  }
-
-  describeBinding(binding) {
-    if (binding.length === 1) {
-      return [binding];
-    }
-    return binding.split(':')[0].split('+').map(part => {
-      switch (part) {
-        case 'shift':
-          return 'Shift';
-        case 'meta':
-          return 'Meta';
-        case 'ctrl':
-          return 'Ctrl';
-        case 'enter':
-          return 'Enter';
-        case 'up':
-          return '↑';
-        case 'down':
-          return '↓';
-        case 'left':
-          return '←';
-        case 'right':
-          return '→';
-        default:
-          return part;
-      }
-    });
-  }
-
-  notifyListeners() {
-    const view = this.directoryView();
-    this.listeners.forEach(listener => listener(view));
-  }
-}
-
-const shortcutManager = new ShortcutManager();
-
-/** @polymerBehavior Gerrit.KeyboardShortcutBehavior*/
-export const KeyboardShortcutBehavior = [
-  IronA11yKeysBehavior,
-  {
-    // Exports for convenience. Note: Closure compiler crashes when
-    // object-shorthand syntax is used here.
-    // eslint-disable-next-line object-shorthand
-    DOC_ONLY: DOC_ONLY,
-    // eslint-disable-next-line object-shorthand
-    GO_KEY: GO_KEY,
-    // eslint-disable-next-line object-shorthand
-    Shortcut: Shortcut,
-    // eslint-disable-next-line object-shorthand
-    ShortcutSection: ShortcutSection,
-
-    properties: {
-      _shortcut_go_key_last_pressed: {
-        type: Number,
-        value: null,
-      },
-
-      _shortcut_go_table: {
-        type: Array,
-        value() { return new Map(); },
-      },
-    },
-
-    modifierPressed(e) {
-      e = getKeyboardEvent(e);
-      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
-    },
-
-    isModifierPressed(e, modifier) {
-      return getKeyboardEvent(e)[modifier];
-    },
-
-    shouldSuppressKeyboardShortcut(e) {
-      e = getKeyboardEvent(e);
-      const tagName = dom(e).rootTarget.tagName;
-      if (tagName === 'INPUT' || tagName === 'TEXTAREA' ||
-          (e.keyCode === 13 && tagName === 'A')) {
-        // Suppress shortcuts if the key is 'enter' and target is an anchor.
-        return true;
-      }
-      for (let i = 0; e.path && i < e.path.length; i++) {
-        if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
-      }
-
-      this.dispatchEvent(new CustomEvent('shortcut-triggered', {
-        detail: {
-          event: e,
-          goKey: this._inGoKeyMode(),
-        },
-        composed: true, bubbles: true,
-      }));
-      return false;
-    },
-
-    // Alias for getKeyboardEvent.
-    /** @return {!Event} */
-    getKeyboardEvent(e) {
-      return getKeyboardEvent(e);
-    },
-
-    getRootTarget(e) {
-      return dom(getKeyboardEvent(e)).rootTarget;
-    },
-
-    bindShortcut(shortcut, ...bindings) {
-      shortcutManager.bindShortcut(shortcut, ...bindings);
-    },
-
-    createTitle(shortcutName, section) {
-      const desc = shortcutManager.getDescription(section, shortcutName);
-      const shortcut = shortcutManager.getShortcut(shortcutName);
-      return (desc && shortcut) ? `${desc} (shortcut: ${shortcut})` : '';
-    },
-
-    _addOwnKeyBindings(shortcut, handler) {
-      const bindings = shortcutManager.getBindingsForShortcut(shortcut);
-      if (!bindings) {
-        return;
-      }
-      if (bindings[0] === DOC_ONLY) {
-        return;
-      }
-      if (bindings[0] === GO_KEY) {
-        this._shortcut_go_table.set(bindings[1], handler);
-      } else {
-        this.addOwnKeyBinding(bindings.join(' '), handler);
-      }
-    },
-
-    /** @override */
-    attached() {
-      const shortcuts = shortcutManager.attachHost(this);
-      if (!shortcuts) { return; }
-
-      for (const key of Object.keys(shortcuts)) {
-        this._addOwnKeyBindings(key, shortcuts[key]);
-      }
-
-      // If any of the shortcuts utilized GO_KEY, then they are handled
-      // directly by this behavior.
-      if (this._shortcut_go_table.size > 0) {
-        this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
-        this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
-        this._shortcut_go_table.forEach((handler, key) => {
-          this.addOwnKeyBinding(key, '_handleGoAction');
-        });
-      }
-    },
-
-    /** @override */
-    detached() {
-      if (shortcutManager.detachHost(this)) {
-        this.removeOwnKeyBindings();
-      }
-    },
-
-    keyboardShortcuts() {
-      return {};
-    },
-
-    addKeyboardShortcutDirectoryListener(listener) {
-      shortcutManager.addListener(listener);
-    },
-
-    removeKeyboardShortcutDirectoryListener(listener) {
-      shortcutManager.removeListener(listener);
-    },
-
-    _handleGoKeyDown(e) {
-      if (this.modifierPressed(e)) { return; }
-      this._shortcut_go_key_last_pressed = Date.now();
-    },
-
-    _handleGoKeyUp(e) {
-      this._shortcut_go_key_last_pressed = null;
-    },
-
-    _inGoKeyMode() {
-      return this._shortcut_go_key_last_pressed &&
-          (Date.now() - this._shortcut_go_key_last_pressed <=
-              GO_KEY_TIMEOUT_MS);
-    },
-
-    _handleGoAction(e) {
-      if (!this._inGoKeyMode() ||
-          !this._shortcut_go_table.has(e.detail.key) ||
-          this.shouldSuppressKeyboardShortcut(e)) {
-        return;
-      }
-      e.preventDefault();
-      const handler = this._shortcut_go_table.get(e.detail.key);
-      this[handler](e);
-    },
-  },
-];
-
-export const KeyboardShortcutBinder = {
-  DOC_ONLY,
-  GO_KEY,
-  Shortcut,
-  ShortcutManager,
-  ShortcutSection,
-
-  bindShortcut(shortcut, ...bindings) {
-    shortcutManager.bindShortcut(shortcut, ...bindings);
-  },
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.KeyboardShortcutBehavior = KeyboardShortcutBehavior;
-window.Gerrit.KeyboardShortcutBinder = KeyboardShortcutBinder;
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
deleted file mode 100644
index fcb7b4f..0000000
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
+++ /dev/null
@@ -1,442 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>keyboard-shortcut-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<test-fixture id="basic">
-  <template>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<test-fixture id="within-overlay">
-  <template>
-    <gr-overlay>
-      <test-element></test-element>
-    </gr-overlay>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {KeyboardShortcutBehavior, KeyboardShortcutBinder} from './keyboard-shortcut-behavior.js';
-suite('keyboard-shortcut-behavior tests', () => {
-  const kb = KeyboardShortcutBinder;
-
-  let element;
-  let overlay;
-  let sandbox;
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'test-element',
-      behaviors: [KeyboardShortcutBehavior],
-      keyBindings: {
-        k: '_handleKey',
-        enter: '_handleKey',
-      },
-      _handleKey() {},
-    });
-  });
-
-  setup(() => {
-    element = fixture('basic');
-    overlay = fixture('within-overlay');
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('ShortcutManager', () => {
-    test('bindings management', () => {
-      const mgr = new kb.ShortcutManager();
-      const {NEXT_FILE} = kb.Shortcut;
-
-      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
-      mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
-      assert.deepEqual(
-          mgr.getBindingsForShortcut(NEXT_FILE),
-          [']', '}', 'right']);
-    });
-
-    suite('binding descriptions', () => {
-      function mapToObject(m) {
-        const o = {};
-        m.forEach((v, k) => o[k] = v);
-        return o;
-      }
-
-      test('single combo description', () => {
-        const mgr = new kb.ShortcutManager();
-        assert.deepEqual(mgr.describeBinding('a'), ['a']);
-        assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
-        assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
-        assert.deepEqual(
-            mgr.describeBinding('ctrl+shift+up:keyup'),
-            ['Ctrl', 'Shift', '↑']);
-      });
-
-      test('combo set description', () => {
-        const {GO_KEY, DOC_ONLY, ShortcutManager} = kb;
-        const {GO_TO_OPENED_CHANGES, NEXT_FILE, PREV_FILE} = kb.Shortcut;
-
-        const mgr = new ShortcutManager();
-        assert.isNull(mgr.describeBindings(NEXT_FILE));
-
-        mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
-        assert.deepEqual(
-            mgr.describeBindings(GO_TO_OPENED_CHANGES),
-            [['g', 'o']]);
-
-        mgr.bindShortcut(NEXT_FILE, DOC_ONLY, ']', 'ctrl+shift+right:keyup');
-        assert.deepEqual(
-            mgr.describeBindings(NEXT_FILE),
-            [[']'], ['Ctrl', 'Shift', '→']]);
-
-        mgr.bindShortcut(PREV_FILE, '[');
-        assert.deepEqual(mgr.describeBindings(PREV_FILE), [['[']]);
-      });
-
-      test('combo set description width', () => {
-        const mgr = new kb.ShortcutManager();
-        assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
-        assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
-        assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
-        assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
-        assert.strictEqual(
-            mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
-            12);
-      });
-
-      test('distribute shortcut help', () => {
-        const mgr = new kb.ShortcutManager();
-        assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([['g', 'o']]),
-            [[['g', 'o']]]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
-            [[['ctrl', 'shift', 'meta', 'enter']]]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([
-              ['ctrl', 'shift', 'meta', 'enter'],
-              ['o'],
-            ]),
-            [
-              [['ctrl', 'shift', 'meta', 'enter']],
-              [['o']],
-            ]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([
-              ['ctrl', 'enter'],
-              ['meta', 'enter'],
-              ['ctrl', 's'],
-              ['meta', 's'],
-            ]),
-            [
-              [['ctrl', 'enter'], ['meta', 'enter']],
-              [['ctrl', 's'], ['meta', 's']],
-            ]);
-      });
-
-      test('active shortcuts by section', () => {
-        const {NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH} =
-            kb.Shortcut;
-        const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
-
-        const mgr = new kb.ShortcutManager();
-        mgr.bindShortcut(NEXT_FILE, ']');
-        mgr.bindShortcut(NEXT_LINE, 'j');
-        mgr.bindShortcut(GO_TO_OPENED_CHANGES, 'g+o');
-        mgr.bindShortcut(SEARCH, '/');
-
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {});
-
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [NEXT_FILE]: null,
-            };
-          },
-        });
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {
-              [NAVIGATION]: [
-                {shortcut: NEXT_FILE, text: 'Go to next file'},
-              ],
-            });
-
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [NEXT_LINE]: null,
-            };
-          },
-        });
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {
-              [DIFFS]: [
-                {shortcut: NEXT_LINE, text: 'Go to next line'},
-              ],
-              [NAVIGATION]: [
-                {shortcut: NEXT_FILE, text: 'Go to next file'},
-              ],
-            });
-
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [SEARCH]: null,
-              [GO_TO_OPENED_CHANGES]: null,
-            };
-          },
-        });
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {
-              [DIFFS]: [
-                {shortcut: NEXT_LINE, text: 'Go to next line'},
-              ],
-              [EVERYWHERE]: [
-                {shortcut: SEARCH, text: 'Search'},
-                {
-                  shortcut: GO_TO_OPENED_CHANGES,
-                  text: 'Go to Opened Changes',
-                },
-              ],
-              [NAVIGATION]: [
-                {shortcut: NEXT_FILE, text: 'Go to next file'},
-              ],
-            });
-      });
-
-      test('directory view', () => {
-        const {
-          NEXT_FILE, NEXT_LINE, GO_TO_OPENED_CHANGES, SEARCH,
-          SAVE_COMMENT,
-        } = kb.Shortcut;
-        const {DIFFS, EVERYWHERE, NAVIGATION} = kb.ShortcutSection;
-        const {GO_KEY, ShortcutManager} = kb;
-
-        const mgr = new ShortcutManager();
-        mgr.bindShortcut(NEXT_FILE, ']');
-        mgr.bindShortcut(NEXT_LINE, 'j');
-        mgr.bindShortcut(GO_TO_OPENED_CHANGES, GO_KEY, 'o');
-        mgr.bindShortcut(SEARCH, '/');
-        mgr.bindShortcut(
-            SAVE_COMMENT, 'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
-
-        assert.deepEqual(mapToObject(mgr.directoryView()), {});
-
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [GO_TO_OPENED_CHANGES]: null,
-              [NEXT_FILE]: null,
-              [NEXT_LINE]: null,
-              [SAVE_COMMENT]: null,
-              [SEARCH]: null,
-            };
-          },
-        });
-        assert.deepEqual(
-            mapToObject(mgr.directoryView()),
-            {
-              [DIFFS]: [
-                {binding: [['j']], text: 'Go to next line'},
-                {
-                  binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']],
-                  text: 'Save comment',
-                },
-                {
-                  binding: [['Ctrl', 's'], ['Meta', 's']],
-                  text: 'Save comment',
-                },
-              ],
-              [EVERYWHERE]: [
-                {binding: [['/']], text: 'Search'},
-                {binding: [['g', 'o']], text: 'Go to Opened Changes'},
-              ],
-              [NAVIGATION]: [
-                {binding: [[']']], text: 'Go to next file'},
-              ],
-            });
-      });
-    });
-  });
-
-  test('doesn’t block kb shortcuts for non-whitelisted els', done => {
-    const divEl = document.createElement('div');
-    element.appendChild(divEl);
-    element._handleKey = e => {
-      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
-      done();
-    };
-    MockInteractions.keyDownOn(divEl, 75, null, 'k');
-  });
-
-  test('blocks kb shortcuts for input els', done => {
-    const inputEl = document.createElement('input');
-    element.appendChild(inputEl);
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      done();
-    };
-    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
-  });
-
-  test('blocks kb shortcuts for textarea els', done => {
-    const textareaEl = document.createElement('textarea');
-    element.appendChild(textareaEl);
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      done();
-    };
-    MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
-  });
-
-  test('blocks kb shortcuts for anything in a gr-overlay', done => {
-    const divEl = document.createElement('div');
-    const element = overlay.querySelector('test-element');
-    element.appendChild(divEl);
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      done();
-    };
-    MockInteractions.keyDownOn(divEl, 75, null, 'k');
-  });
-
-  test('blocks enter shortcut on an anchor', done => {
-    const anchorEl = document.createElement('a');
-    const element = overlay.querySelector('test-element');
-    element.appendChild(anchorEl);
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      done();
-    };
-    MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
-  });
-
-  test('modifierPressed returns accurate values', () => {
-    const spy = sandbox.spy(element, 'modifierPressed');
-    element._handleKey = e => {
-      element.modifierPressed(e);
-    };
-    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-  });
-
-  test('isModifierPressed returns accurate value', () => {
-    const spy = sandbox.spy(element, 'isModifierPressed');
-    element._handleKey = e => {
-      element.isModifierPressed(e, 'shiftKey');
-    };
-    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-  });
-
-  suite('GO_KEY timing', () => {
-    let handlerStub;
-
-    setup(() => {
-      element._shortcut_go_table.set('a', '_handleA');
-      handlerStub = element._handleA = sinon.stub();
-      sandbox.stub(Date, 'now').returns(10000);
-    });
-
-    test('success', () => {
-      const e = {detail: {key: 'a'}, preventDefault: () => {}};
-      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = 9000;
-      element._handleGoAction(e);
-      assert.isTrue(handlerStub.calledOnce);
-      assert.strictEqual(handlerStub.lastCall.args[0], e);
-    });
-
-    test('go key not pressed', () => {
-      const e = {detail: {key: 'a'}, preventDefault: () => {}};
-      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = null;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-
-    test('go key pressed too long ago', () => {
-      const e = {detail: {key: 'a'}, preventDefault: () => {}};
-      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = 3000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-
-    test('should suppress', () => {
-      const e = {detail: {key: 'a'}, preventDefault: () => {}};
-      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
-      element._shortcut_go_key_last_pressed = 9000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-
-    test('unrecognized key', () => {
-      const e = {detail: {key: 'f'}, preventDefault: () => {}};
-      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = 9000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.js b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.js
deleted file mode 100644
index 919a763..0000000
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.js
+++ /dev/null
@@ -1,201 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../scripts/bundled-polymer.js';
-import {BaseUrlBehavior} from '../base-url-behavior/base-url-behavior.js';
-
-/** @polymerBehavior Gerrit.RESTClientBehavior */
-export const RESTClientBehavior = [{
-  ChangeDiffType: {
-    ADDED: 'ADDED',
-    COPIED: 'COPIED',
-    DELETED: 'DELETED',
-    MODIFIED: 'MODIFIED',
-    RENAMED: 'RENAMED',
-    REWRITE: 'REWRITE',
-  },
-
-  ChangeStatus: {
-    ABANDONED: 'ABANDONED',
-    MERGED: 'MERGED',
-    NEW: 'NEW',
-  },
-
-  // Must be kept in sync with the ListChangesOption enum and protobuf.
-  ListChangesOption: {
-    LABELS: 0,
-    DETAILED_LABELS: 8,
-
-    // Return information on the current patch set of the change.
-    CURRENT_REVISION: 1,
-    ALL_REVISIONS: 2,
-
-    // If revisions are included, parse the commit object.
-    CURRENT_COMMIT: 3,
-    ALL_COMMITS: 4,
-
-    // If a patch set is included, include the files of the patch set.
-    CURRENT_FILES: 5,
-    ALL_FILES: 6,
-
-    // If accounts are included, include detailed account info.
-    DETAILED_ACCOUNTS: 7,
-
-    // Include messages associated with the change.
-    MESSAGES: 9,
-
-    // Include allowed actions client could perform.
-    CURRENT_ACTIONS: 10,
-
-    // Set the reviewed boolean for the caller.
-    REVIEWED: 11,
-
-    // Include download commands for the caller.
-    DOWNLOAD_COMMANDS: 13,
-
-    // Include patch set weblinks.
-    WEB_LINKS: 14,
-
-    // Include consistency check results.
-    CHECK: 15,
-
-    // Include allowed change actions client could perform.
-    CHANGE_ACTIONS: 16,
-
-    // Include a copy of commit messages including review footers.
-    COMMIT_FOOTERS: 17,
-
-    // Include push certificate information along with any patch sets.
-    PUSH_CERTIFICATES: 18,
-
-    // Include change's reviewer updates.
-    REVIEWER_UPDATES: 19,
-
-    // Set the submittable boolean.
-    SUBMITTABLE: 20,
-
-    // If tracking ids are included, include detailed tracking ids info.
-    TRACKING_IDS: 21,
-
-    // Skip mergeability data.
-    SKIP_MERGEABLE: 22,
-
-    /**
-     * Skip diffstat computation that compute the insertions field (number of lines inserted) and
-     * deletions field (number of lines deleted)
-     */
-    SKIP_DIFFSTAT: 23,
-  },
-
-  listChangesOptionsToHex(...args) {
-    let v = 0;
-    for (let i = 0; i < args.length; i++) {
-      v |= 1 << args[i];
-    }
-    return v.toString(16);
-  },
-
-  /**
-   *  @return {string}
-   */
-  changeBaseURL(project, changeNum, patchNum) {
-    let v = this.getBaseUrl() + '/changes/' +
-       encodeURIComponent(project) + '~' + changeNum;
-    if (patchNum) {
-      v += '/revisions/' + patchNum;
-    }
-    return v;
-  },
-
-  changePath(changeNum) {
-    return this.getBaseUrl() + '/c/' + changeNum;
-  },
-
-  changeIsOpen(change) {
-    return change && change.status === this.ChangeStatus.NEW;
-  },
-
-  /**
-   * @param {!Object} change
-   * @param {!Object=} opt_options
-   *
-   * @return {!Array}
-   */
-  changeStatuses(change, opt_options) {
-    const states = [];
-    if (change.status === this.ChangeStatus.MERGED) {
-      states.push('Merged');
-    } else if (change.status === this.ChangeStatus.ABANDONED) {
-      states.push('Abandoned');
-    } else if (change.mergeable === false ||
-        (opt_options && opt_options.mergeable === false)) {
-      // 'mergeable' prop may not always exist (@see Issue 6819)
-      states.push('Merge Conflict');
-    }
-    if (change.work_in_progress) { states.push('WIP'); }
-    if (change.is_private) { states.push('Private'); }
-
-    // If there are any pre-defined statuses, only return those. Otherwise,
-    // will determine the derived status.
-    if (states.length || !opt_options) { return states; }
-
-    // If no missing requirements, either active or ready to submit.
-    if (change.submittable && opt_options.submitEnabled) {
-      states.push('Ready to submit');
-    } else {
-      // Otherwise it is active.
-      states.push('Active');
-    }
-    return states;
-  },
-
-  /**
-   * @param {!Object} change
-   * @return {string}
-   */
-  changeStatusString(change) {
-    return this.changeStatuses(change).join(', ');
-  },
-},
-BaseUrlBehavior,
-];
-
-// eslint-disable-next-line no-unused-vars
-function defineEmptyMixin() {
-  // This is a temporary function.
-  // Polymer linter doesn't process correctly the following code:
-  // class MyElement extends Polymer.mixinBehaviors([legacyBehaviors], ...) {...}
-  // To workaround this issue, the mock mixin is declared in this method.
-  // In the following changes, legacy behaviors will be converted to mixins.
-
-  /**
-   * @polymer
-   * @mixinFunction
-   */
-  const RESTClientMixin = base => // eslint-disable-line no-unused-vars
-    class extends base {
-      changeStatusString(change) {}
-
-      changeStatuses(change, opt_options) {}
-    };
-}
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.RESTClientBehavior = RESTClientBehavior;
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
deleted file mode 100644
index 980bc8f..0000000
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
+++ /dev/null
@@ -1,237 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>keyboard-shortcut-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../test/common-test-setup.js';
-/** @type {string} */
-window.CANONICAL_PATH = '/r';
-</script>
-
-<test-fixture id="basic">
-  <template>
-    <test-element></test-element>
-  </template>
-</test-fixture>
-
-<test-fixture id="within-overlay">
-  <template>
-    <gr-overlay>
-      <test-element></test-element>
-    </gr-overlay>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {BaseUrlBehavior} from '../base-url-behavior/base-url-behavior.js';
-import {RESTClientBehavior} from './rest-client-behavior.js';
-suite('rest-client-behavior tests', () => {
-  let element;
-  // eslint-disable-next-line no-unused-vars
-  let overlay;
-
-  suiteSetup(() => {
-    // Define a Polymer element that uses this behavior.
-    Polymer({
-      is: 'test-element',
-      behaviors: [
-        BaseUrlBehavior,
-        RESTClientBehavior,
-      ],
-    });
-  });
-
-  setup(() => {
-    element = fixture('basic');
-    overlay = fixture('within-overlay');
-  });
-
-  test('changeBaseURL', () => {
-    assert.deepEqual(
-        element.changeBaseURL('test/project', '1', '2'),
-        '/r/changes/test%2Fproject~1/revisions/2'
-    );
-  });
-
-  test('changePath', () => {
-    assert.deepEqual(element.changePath('1'), '/r/c/1');
-  });
-
-  test('Open status', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: true,
-    };
-    let statuses = element.changeStatuses(change);
-    const statusString = element.changeStatusString(change);
-    assert.deepEqual(statuses, []);
-    assert.equal(statusString, '');
-
-    change.submittable = false;
-    statuses = element.changeStatuses(change,
-        {includeDerived: true});
-    assert.deepEqual(statuses, ['Active']);
-
-    // With no missing labels but no submitEnabled option.
-    change.submittable = true;
-    statuses = element.changeStatuses(change,
-        {includeDerived: true});
-    assert.deepEqual(statuses, ['Active']);
-
-    // Without missing labels and enabled submit
-    statuses = element.changeStatuses(change,
-        {includeDerived: true, submitEnabled: true});
-    assert.deepEqual(statuses, ['Ready to submit']);
-
-    change.mergeable = false;
-    change.submittable = true;
-    statuses = element.changeStatuses(change,
-        {includeDerived: true});
-    assert.deepEqual(statuses, ['Merge Conflict']);
-
-    delete change.mergeable;
-    change.submittable = true;
-    statuses = element.changeStatuses(change,
-        {includeDerived: true, mergeable: true, submitEnabled: true});
-    assert.deepEqual(statuses, ['Ready to submit']);
-
-    change.submittable = true;
-    statuses = element.changeStatuses(change,
-        {includeDerived: true, mergeable: false});
-    assert.deepEqual(statuses, ['Merge Conflict']);
-  });
-
-  test('Merge conflict', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: false,
-    };
-    const statuses = element.changeStatuses(change);
-    const statusString = element.changeStatusString(change);
-    assert.deepEqual(statuses, ['Merge Conflict']);
-    assert.equal(statusString, 'Merge Conflict');
-  });
-
-  test('mergeable prop undefined', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-    };
-    const statuses = element.changeStatuses(change);
-    const statusString = element.changeStatusString(change);
-    assert.deepEqual(statuses, []);
-    assert.equal(statusString, '');
-  });
-
-  test('Merged status', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'MERGED',
-      labels: {},
-    };
-    const statuses = element.changeStatuses(change);
-    const statusString = element.changeStatusString(change);
-    assert.deepEqual(statuses, ['Merged']);
-    assert.equal(statusString, 'Merged');
-  });
-
-  test('Abandoned status', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'ABANDONED',
-      labels: {},
-    };
-    const statuses = element.changeStatuses(change);
-    const statusString = element.changeStatusString(change);
-    assert.deepEqual(statuses, ['Abandoned']);
-    assert.equal(statusString, 'Abandoned');
-  });
-
-  test('Open status with private and wip', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      is_private: true,
-      work_in_progress: true,
-      labels: {},
-      mergeable: true,
-    };
-    const statuses = element.changeStatuses(change);
-    const statusString = element.changeStatusString(change);
-    assert.deepEqual(statuses, ['WIP', 'Private']);
-    assert.equal(statusString, 'WIP, Private');
-  });
-
-  test('Merge conflict with private and wip', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      is_private: true,
-      work_in_progress: true,
-      labels: {},
-      mergeable: false,
-    };
-    const statuses = element.changeStatuses(change);
-    const statusString = element.changeStatusString(change);
-    assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
-    assert.equal(statusString, 'Merge Conflict, WIP, Private');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.js b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.js
deleted file mode 100644
index ec7a9f4..0000000
--- a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-const SAFE_URL_PATTERN = /^(https?:\/\/|mailto:|[^:/?#]*(?:[/?#]|$))/i;
-
-/** @polymerBehavior Gerrit.SafeTypes */
-export const SafeTypes = {};
-
-/**
- * Wraps a string to be used as a URL. An error is thrown if the string cannot
- * be considered safe.
- *
- * @constructor
- * @param {string} url the unwrapped, potentially unsafe URL.
- */
-SafeTypes.SafeUrl = function(url) {
-  if (!SAFE_URL_PATTERN.test(url)) {
-    throw new Error(`URL not marked as safe: ${url}`);
-  }
-  this._url = url;
-};
-
-/**
- * Get the string representation of the safe URL.
- *
- * @returns {string}
- */
-SafeTypes.SafeUrl.prototype.asString = function() {
-  return this._url;
-};
-
-SafeTypes.safeTypesBridge = function(value, type) {
-  // If the value is being bound to a URL, ensure the value is wrapped in the
-  // SafeUrl type first. If the URL is not safe, allow the SafeUrl constructor
-  // to surface the error.
-  if (type === 'URL') {
-    let safeValue = null;
-    if (value instanceof SafeTypes.SafeUrl) {
-      safeValue = value;
-    } else if (typeof value === 'string') {
-      safeValue = new SafeTypes.SafeUrl(value);
-    }
-    if (safeValue) {
-      return safeValue.asString();
-    }
-  }
-
-  // If the value is being bound to a string or a constant, then the string
-  // can be used as is.
-  if (type === 'STRING' || type === 'CONSTANT') {
-    return value;
-  }
-
-  // Otherwise fail.
-  throw new Error(`Refused to bind value as ${type}: ${value}`);
-};
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use the behavior because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.SafeTypes = SafeTypes;
-
diff --git a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html b/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
deleted file mode 100644
index 6fe4460..0000000
--- a/polygerrit-ui/app/behaviors/safe-types-behavior/safe-types-behavior_test.html
+++ /dev/null
@@ -1,122 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<title>safe-types-behavior</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <safe-types-element></safe-types-element>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {SafeTypes} from './safe-types-behavior.js';
-suite('gr-tooltip-behavior tests', () => {
-  let element;
-  let sandbox;
-
-  suiteSetup(() => {
-    Polymer({
-      is: 'safe-types-element',
-      behaviors: [SafeTypes],
-    });
-  });
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('SafeUrl accepts valid urls', () => {
-    function accepts(url) {
-      const safeUrl = new element.SafeUrl(url);
-      assert.isOk(safeUrl);
-      assert.equal(url, safeUrl.asString());
-    }
-    accepts('http://www.google.com/');
-    accepts('https://www.google.com/');
-    accepts('HtTpS://www.google.com/');
-    accepts('//www.google.com/');
-    accepts('/c/1234/file/path.html@45');
-    accepts('#hash-url');
-    accepts('mailto:name@example.com');
-  });
-
-  test('SafeUrl rejects invalid urls', () => {
-    function rejects(url) {
-      assert.throws(() => { new element.SafeUrl(url); });
-    }
-    rejects('javascript://alert("evil");');
-    rejects('ftp:example.com');
-    rejects('data:text/html,scary business');
-  });
-
-  suite('safeTypesBridge', () => {
-    function acceptsString(value, type) {
-      assert.equal(SafeTypes.safeTypesBridge(value, type),
-          value);
-    }
-
-    function rejects(value, type) {
-      assert.throws(() => { SafeTypes.safeTypesBridge(value, type); });
-    }
-
-    test('accepts valid URL strings', () => {
-      acceptsString('/foo/bar', 'URL');
-      acceptsString('#baz', 'URL');
-    });
-
-    test('rejects invalid URL strings', () => {
-      rejects('javascript://void();', 'URL');
-    });
-
-    test('accepts SafeUrl values', () => {
-      const url = '/abc/123';
-      const safeUrl = new element.SafeUrl(url);
-      assert.equal(SafeTypes.safeTypesBridge(safeUrl, 'URL'), url);
-    });
-
-    test('rejects non-string or non-SafeUrl types', () => {
-      rejects(3.1415926, 'URL');
-    });
-
-    test('accepts any binding to STRING or CONSTANT', () => {
-      acceptsString('foo/bar/baz', 'STRING');
-      acceptsString('lorem ipsum dolor', 'CONSTANT');
-    });
-
-    test('rejects all other types', () => {
-      rejects('foo', 'JAVASCRIPT');
-      rejects('foo', 'HTML');
-      rejects('foo', 'RESOURCE_URL');
-      rejects('foo', 'STYLE');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/constants/constants.js b/polygerrit-ui/app/constants/constants.js
deleted file mode 100644
index cab50f6..0000000
--- a/polygerrit-ui/app/constants/constants.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * @enum
- * @desc Tab names for primary tabs on change view page.
- */
-export const PrimaryTabs = {
-  FILES: '_files',
-  FINDINGS: '_findings',
-};
-
-/**
- * @enum
- * @desc Tab names for secondary tabs on change view page.
- */
-export const SecondaryTabs = {
-  CHANGE_LOG: '_changeLog',
-  COMMENT_THREADS: '_commentThreads',
-};
-
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
new file mode 100644
index 0000000..32e1bed
--- /dev/null
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -0,0 +1,148 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @desc Tab names for primary tabs on change view page.
+ */
+export enum PrimaryTab {
+  FILES = 'files',
+  /**
+   * When renaming this, the links in UrlFormatter must be updated.
+   */
+  COMMENT_THREADS = 'comments',
+  FINDINGS = 'findings',
+}
+
+/**
+ * @desc Tab names for secondary tabs on change view page.
+ */
+export enum SecondaryTab {
+  CHANGE_LOG = '_changeLog',
+}
+
+/**
+ * @desc Tag names of change log messages.
+ */
+export enum MessageTag {
+  TAG_DELETE_REVIEWER = 'autogenerated:gerrit:deleteReviewer',
+  TAG_NEW_PATCHSET = 'autogenerated:gerrit:newPatchSet',
+  TAG_NEW_WIP_PATCHSET = 'autogenerated:gerrit:newWipPatchSet',
+  TAG_REVIEWER_UPDATE = 'autogenerated:gerrit:reviewerUpdate',
+  TAG_SET_PRIVATE = 'autogenerated:gerrit:setPrivate',
+  TAG_UNSET_PRIVATE = 'autogenerated:gerrit:unsetPrivate',
+  TAG_SET_READY = 'autogenerated:gerrit:setReadyForReview',
+  TAG_SET_WIP = 'autogenerated:gerrit:setWorkInProgress',
+  TAG_SET_ASSIGNEE = 'autogenerated:gerrit:setAssignee',
+  TAG_UNSET_ASSIGNEE = 'autogenerated:gerrit:deleteAssignee',
+}
+
+/**
+ * @desc Modes for gr-diff-cursor
+ * The scroll behavior for the cursor. Values are 'never' and
+ * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+ * the viewport.
+ */
+export enum ScrollMode {
+  KEEP_VISIBLE = 'keep-visible',
+  NEVER = 'never',
+}
+
+/**
+ * @desc Specifies status for a change
+ */
+export enum ChangeStatus {
+  ABANDONED = 'ABANDONED',
+  MERGED = 'MERGED',
+  NEW = 'NEW',
+}
+
+/**
+ * @desc Special file paths
+ */
+export enum SpecialFilePath {
+  PATCHSET_LEVEL_COMMENTS = '/PATCHSET_LEVEL',
+  COMMIT_MESSAGE = '/COMMIT_MSG',
+  MERGE_LIST = '/MERGE_LIST',
+}
+
+/**
+ * @desc The reviewer state
+ */
+export enum RequirementStatus {
+  OK = 'OK',
+  NOT_READY = 'NOT_READY',
+  RULE_ERROR = 'RULE_ERROR',
+}
+
+/**
+ * @desc The reviewer state
+ */
+export enum ReviewerState {
+  REVIEWER = 'REVIEWER',
+  CC = 'CC',
+  REMOVED = 'REMOVED',
+}
+
+/**
+ * @desc The patchset kind
+ */
+export enum RevisionKind {
+  REWORK = 'REWORK',
+  TRIVIAL_REBASE = 'TRIVIAL_REBASE',
+  MERGE_FIRST_PARENT_UPDATE = 'MERGE_FIRST_PARENT_UPDATE',
+  NO_CODE_CHANGE = 'NO_CODE_CHANGE',
+  NO_CHANGE = 'NO_CHANGE',
+}
+
+/**
+ * @desc The status of fixing the problem
+ */
+export enum ProblemInfoStatus {
+  FIXED = 'FIXED',
+  FIX_FAILED = 'FIX_FAILED',
+}
+
+/**
+ * @desc The status of the file
+ */
+export enum FileInfoStatus {
+  ADDED = 'A',
+  DELETED = 'D',
+  RENAMED = 'R',
+  COPIED = 'C',
+  REWRITTEN = 'W',
+  // Modifed = 'M', // but API not set it if the file was modified
+  UNMODIFIED = 'U', // Not returned by BE, but added by UI for certain files
+}
+
+/**
+ * @desc The status of the file
+ */
+export enum GpgKeyInfoStatus {
+  BAD = 'BAD',
+  OK = 'OK',
+  TRUSTED = 'TRUSTED',
+}
+
+/**
+ * @desc Used for server config of accounts
+ */
+export enum DefaultDisplayNameConfig {
+  USERNAME = 'USERNAME',
+  FIRST_NAME = 'FIRST_NAME',
+  FULL_NAME = 'FULL_NAME',
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
index e07a64e..d1255d2 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 import '@polymer/iron-input/iron-input.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/shared-styles.js';
@@ -24,11 +23,10 @@
 import '../gr-permission/gr-permission.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {htmlTemplate} from './gr-access-section_html.js';
-import {AccessBehavior} from '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
+import {AccessPermissions, toSortedPermissionsArray} from '../../../utils/access-util.js';
 
 /**
  * Fired when the section has been modified or removed.
@@ -51,13 +49,11 @@
 const LABEL = 'Label';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrAccessSection extends mixinBehaviors( [
-  AccessBehavior,
-], GestureEventListeners(
+class GrAccessSection extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-access-section'; }
@@ -101,7 +97,7 @@
   }
 
   _updateSection(section) {
-    this._permissions = this.toSortedArray(section.value.permissions);
+    this._permissions = toSortedPermissionsArray(section.value.permissions);
     this._originalId = section.id;
   }
 
@@ -150,11 +146,11 @@
       return [];
     }
     if (name === GLOBAL_NAME) {
-      allPermissions = this.toSortedArray(capabilities);
+      allPermissions = toSortedPermissionsArray(capabilities);
     } else {
       const labelOptions = this._computeLabelOptions(labels);
       allPermissions = labelOptions.concat(
-          this.toSortedArray(this.permissionValues));
+          toSortedPermissionsArray(AccessPermissions));
     }
     return allPermissions
         .filter(permission => !this.section.value.permissions[permission.id]);
@@ -192,11 +188,11 @@
     return labelOptions;
   }
 
-  _computePermissionName(name, permission, permissionValues, capabilities) {
+  _computePermissionName(name, permission, capabilities) {
     if (name === GLOBAL_NAME) {
       return capabilities[permission.id].name;
-    } else if (permissionValues[permission.id]) {
-      return permissionValues[permission.id].name;
+    } else if (AccessPermissions[permission.id]) {
+      return AccessPermissions[permission.id].name;
     } else if (permission.value.label) {
       let behalfOf = '';
       if (permission.id.startsWith('labelAs-')) {
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js
deleted file mode 100644
index c46cf30..0000000
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.js
+++ /dev/null
@@ -1,157 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-l);
-    }
-    fieldset {
-      border: 1px solid var(--border-color);
-    }
-    .name {
-      align-items: center;
-      display: flex;
-    }
-    .header,
-    #deletedContainer {
-      align-items: center;
-      background: var(--table-header-background-color);
-      border-bottom: 1px dotted var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      min-height: 3em;
-      padding: 0 var(--spacing-m);
-    }
-    #deletedContainer {
-      border-bottom: 0;
-    }
-    .sectionContent {
-      padding: var(--spacing-m);
-    }
-    #editBtn,
-    .editing #editBtn.global,
-    #deletedContainer,
-    .deleted #mainContainer,
-    #addPermission,
-    #deleteBtn,
-    .editingRef .name,
-    .editRefInput {
-      display: none;
-    }
-    .editing #editBtn,
-    .editingRef .editRefInput {
-      display: flex;
-    }
-    .deleted #deletedContainer {
-      display: flex;
-    }
-    .editing #addPermission,
-    #mainContainer,
-    .editing #deleteBtn {
-      display: block;
-    }
-    .editing #deleteBtn,
-    #undoRemoveBtn {
-      padding-right: var(--spacing-m);
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <fieldset
-    id="section"
-    class$="gr-form-styles [[_computeSectionClass(editing, canUpload, ownerOf, _editingRef, _deleted)]]"
-  >
-    <div id="mainContainer">
-      <div class="header">
-        <div class="name">
-          <h3>[[_computeSectionName(section.id)]]</h3>
-          <gr-button
-            id="editBtn"
-            link=""
-            class$="[[_computeEditBtnClass(section.id)]]"
-            on-click="editReference"
-          >
-            <iron-icon id="icon" icon="gr-icons:create"></iron-icon>
-          </gr-button>
-        </div>
-        <iron-input
-          class="editRefInput"
-          bind-value="{{section.id}}"
-          type="text"
-          on-input="_handleValueChange"
-        >
-          <input
-            class="editRefInput"
-            bind-value="{{section.id}}"
-            is="iron-input"
-            type="text"
-            on-input="_handleValueChange"
-          />
-        </iron-input>
-        <gr-button link="" id="deleteBtn" on-click="_handleRemoveReference"
-          >Remove</gr-button
-        >
-      </div>
-      <!-- end header -->
-      <div class="sectionContent">
-        <template is="dom-repeat" items="{{_permissions}}" as="permission">
-          <gr-permission
-            name="[[_computePermissionName(section.id, permission, permissionValues, capabilities)]]"
-            permission="{{permission}}"
-            labels="[[labels]]"
-            section="[[section.id]]"
-            editing="[[editing]]"
-            groups="[[groups]]"
-            on-added-permission-removed="_handleAddedPermissionRemoved"
-          >
-          </gr-permission>
-        </template>
-        <div id="addPermission">
-          Add permission:
-          <select id="permissionSelect">
-            <!-- called with a third parameter so that permissions update
-                  after a new section is added. -->
-            <template
-              is="dom-repeat"
-              items="[[_computePermissions(section.id, capabilities, labels, section.value.permissions.*)]]"
-            >
-              <option value="[[item.value.id]]">[[item.value.name]]</option>
-            </template>
-          </select>
-          <gr-button link="" id="addBtn" on-click="_handleAddPermission"
-            >Add</gr-button
-          >
-        </div>
-        <!-- end addPermission -->
-      </div>
-      <!-- end sectionContent -->
-    </div>
-    <!-- end mainContainer -->
-    <div id="deletedContainer">
-      <span>[[_computeSectionName(section.id)]] was deleted</span>
-      <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
-        >Undo</gr-button
-      >
-    </div>
-    <!-- end deletedContainer -->
-  </fieldset>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
new file mode 100644
index 0000000..7c9f28b
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
@@ -0,0 +1,157 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-l);
+    }
+    fieldset {
+      border: 1px solid var(--border-color);
+    }
+    .name {
+      align-items: center;
+      display: flex;
+    }
+    .header,
+    #deletedContainer {
+      align-items: center;
+      background: var(--table-header-background-color);
+      border-bottom: 1px dotted var(--border-color);
+      display: flex;
+      justify-content: space-between;
+      min-height: 3em;
+      padding: 0 var(--spacing-m);
+    }
+    #deletedContainer {
+      border-bottom: 0;
+    }
+    .sectionContent {
+      padding: var(--spacing-m);
+    }
+    #editBtn,
+    .editing #editBtn.global,
+    #deletedContainer,
+    .deleted #mainContainer,
+    #addPermission,
+    #deleteBtn,
+    .editingRef .name,
+    .editRefInput {
+      display: none;
+    }
+    .editing #editBtn,
+    .editingRef .editRefInput {
+      display: flex;
+    }
+    .deleted #deletedContainer {
+      display: flex;
+    }
+    .editing #addPermission,
+    #mainContainer,
+    .editing #deleteBtn {
+      display: block;
+    }
+    .editing #deleteBtn,
+    #undoRemoveBtn {
+      padding-right: var(--spacing-m);
+    }
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <fieldset
+    id="section"
+    class$="gr-form-styles [[_computeSectionClass(editing, canUpload, ownerOf, _editingRef, _deleted)]]"
+  >
+    <div id="mainContainer">
+      <div class="header">
+        <div class="name">
+          <h3 class="heading-3">[[_computeSectionName(section.id)]]</h3>
+          <gr-button
+            id="editBtn"
+            link=""
+            class$="[[_computeEditBtnClass(section.id)]]"
+            on-click="editReference"
+          >
+            <iron-icon id="icon" icon="gr-icons:create"></iron-icon>
+          </gr-button>
+        </div>
+        <iron-input
+          class="editRefInput"
+          bind-value="{{section.id}}"
+          type="text"
+          on-input="_handleValueChange"
+        >
+          <input
+            class="editRefInput"
+            bind-value="{{section.id}}"
+            is="iron-input"
+            type="text"
+            on-input="_handleValueChange"
+          />
+        </iron-input>
+        <gr-button link="" id="deleteBtn" on-click="_handleRemoveReference"
+          >Remove</gr-button
+        >
+      </div>
+      <!-- end header -->
+      <div class="sectionContent">
+        <template is="dom-repeat" items="{{_permissions}}" as="permission">
+          <gr-permission
+            name="[[_computePermissionName(section.id, permission, capabilities)]]"
+            permission="{{permission}}"
+            labels="[[labels]]"
+            section="[[section.id]]"
+            editing="[[editing]]"
+            groups="[[groups]]"
+            on-added-permission-removed="_handleAddedPermissionRemoved"
+          >
+          </gr-permission>
+        </template>
+        <div id="addPermission">
+          Add permission:
+          <select id="permissionSelect">
+            <!-- called with a third parameter so that permissions update
+                  after a new section is added. -->
+            <template
+              is="dom-repeat"
+              items="[[_computePermissions(section.id, capabilities, labels, section.value.permissions.*)]]"
+            >
+              <option value="[[item.value.id]]">[[item.value.name]]</option>
+            </template>
+          </select>
+          <gr-button link="" id="addBtn" on-click="_handleAddPermission"
+            >Add</gr-button
+          >
+        </div>
+        <!-- end addPermission -->
+      </div>
+      <!-- end sectionContent -->
+    </div>
+    <!-- end mainContainer -->
+    <div id="deletedContainer">
+      <span>[[_computeSectionName(section.id)]] was deleted</span>
+      <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
+        >Undo</gr-button
+      >
+    </div>
+    <!-- end deletedContainer -->
+  </fieldset>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
deleted file mode 100644
index 95345fe..0000000
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
+++ /dev/null
@@ -1,556 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-access-section</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-access-section></gr-access-section>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-access-section.js';
-suite('gr-access-section tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('unit tests', () => {
-    setup(() => {
-      element.section = {
-        id: 'refs/*',
-        value: {
-          permissions: {
-            read: {
-              rules: {},
-            },
-          },
-        },
-      };
-      element.capabilities = {
-        accessDatabase: {
-          id: 'accessDatabase',
-          name: 'Access Database',
-        },
-        administrateServer: {
-          id: 'administrateServer',
-          name: 'Administrate Server',
-        },
-        batchChangesLimit: {
-          id: 'batchChangesLimit',
-          name: 'Batch Changes Limit',
-        },
-        createAccount: {
-          id: 'createAccount',
-          name: 'Create Account',
-        },
-      };
-      element.labels = {
-        'Code-Review': {
-          values: {
-            ' 0': 'No score',
-            '-1': 'I would prefer this is not merged as is',
-            '-2': 'This shall not be merged',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          default_value: 0,
-        },
-      };
-      element._updateSection(element.section);
-      flushAsynchronousOperations();
-    });
-
-    test('_updateSection', () => {
-      // _updateSection was called in setup, so just make assertions.
-      const expectedPermissions = [
-        {
-          id: 'read',
-          value: {
-            rules: {},
-          },
-        },
-      ];
-      assert.deepEqual(element._permissions, expectedPermissions);
-      assert.equal(element._originalId, element.section.id);
-    });
-
-    test('_computeLabelOptions', () => {
-      const expectedLabelOptions = [
-        {
-          id: 'label-Code-Review',
-          value: {
-            name: 'Label Code-Review',
-            id: 'label-Code-Review',
-          },
-        },
-        {
-          id: 'labelAs-Code-Review',
-          value: {
-            name: 'Label Code-Review (On Behalf Of)',
-            id: 'labelAs-Code-Review',
-          },
-        },
-      ];
-
-      assert.deepEqual(element._computeLabelOptions(element.labels),
-          expectedLabelOptions);
-    });
-
-    test('_handleAccessSaved', () => {
-      assert.equal(element._originalId, 'refs/*');
-      element.section.id = 'refs/for/bar';
-      element._handleAccessSaved();
-      assert.equal(element._originalId, 'refs/for/bar');
-    });
-
-    test('_computePermissions', () => {
-      sandbox.stub(element, 'toSortedArray').returns(
-          [{
-            id: 'push',
-            value: {
-              rules: {},
-            },
-          },
-          {
-            id: 'read',
-            value: {
-              rules: {},
-            },
-          },
-          ]);
-
-      const expectedPermissions = [{
-        id: 'push',
-        value: {
-          rules: {},
-        },
-      },
-      ];
-      const labelOptions = [
-        {
-          id: 'label-Code-Review',
-          value: {
-            name: 'Label Code-Review',
-            id: 'label-Code-Review',
-          },
-        },
-        {
-          id: 'labelAs-Code-Review',
-          value: {
-            name: 'Label Code-Review (On Behalf Of)',
-            id: 'labelAs-Code-Review',
-          },
-        },
-      ];
-
-      // For global capabilities, just return the sorted array filtered by
-      // existing permissions.
-      let name = 'GLOBAL_CAPABILITIES';
-      assert.deepEqual(element._computePermissions(name, element.capabilities,
-          element.labels), expectedPermissions);
-
-      // Uses the capabilities array to come up with possible values.
-      assert.isTrue(element.toSortedArray.lastCall.
-          calledWithExactly(element.capabilities));
-
-      // For everything else, include possible label values before filtering.
-      name = 'refs/for/*';
-      assert.deepEqual(element._computePermissions(name, element.capabilities,
-          element.labels), labelOptions.concat(expectedPermissions));
-
-      // Uses permissionValues (defined in gr-access-behavior) to come up with
-      // possible values.
-      assert.isTrue(element.toSortedArray.lastCall.
-          calledWithExactly(element.permissionValues));
-    });
-
-    test('_computePermissionName', () => {
-      let name = 'GLOBAL_CAPABILITIES';
-      let permission = {
-        id: 'administrateServer',
-        value: {},
-      };
-      assert.equal(element._computePermissionName(name, permission,
-          element.permissionValues, element.capabilities),
-      element.capabilities[permission.id].name);
-
-      name = 'refs/for/*';
-      permission = {
-        id: 'abandon',
-        value: {},
-      };
-
-      assert.equal(element._computePermissionName(
-          name, permission, element.permissionValues, element.capabilities),
-      element.permissionValues[permission.id].name);
-
-      name = 'refs/for/*';
-      permission = {
-        id: 'label-Code-Review',
-        value: {
-          label: 'Code-Review',
-        },
-      };
-
-      assert.equal(element._computePermissionName(name, permission,
-          element.permissionValues, element.capabilities),
-      'Label Code-Review');
-
-      permission = {
-        id: 'labelAs-Code-Review',
-        value: {
-          label: 'Code-Review',
-        },
-      };
-
-      assert.equal(element._computePermissionName(name, permission,
-          element.permissionValues, element.capabilities),
-      'Label Code-Review(On Behalf Of)');
-    });
-
-    test('_computeSectionName', () => {
-      let name;
-      // When computing the section name for an undefined name, it means a
-      // new section is being added. In this case, it should defualt to
-      // 'refs/heads/*'.
-      element._editingRef = false;
-      assert.equal(element._computeSectionName(name),
-          'Reference: refs/heads/*');
-      assert.isTrue(element._editingRef);
-      assert.equal(element.section.id, 'refs/heads/*');
-
-      // Reset editing to false.
-      element._editingRef = false;
-      name = 'GLOBAL_CAPABILITIES';
-      assert.equal(element._computeSectionName(name), 'Global Capabilities');
-      assert.isFalse(element._editingRef);
-
-      name = 'refs/for/*';
-      assert.equal(element._computeSectionName(name),
-          'Reference: refs/for/*');
-      assert.isFalse(element._editingRef);
-    });
-
-    test('editReference', () => {
-      element.editReference();
-      assert.isTrue(element._editingRef);
-    });
-
-    test('_computeSectionClass', () => {
-      let editingRef = false;
-      let canUpload = false;
-      let ownerOf = [];
-      let editing = false;
-      let deleted = false;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), '');
-
-      editing = true;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), '');
-
-      ownerOf = ['refs/*'];
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing');
-
-      ownerOf = [];
-      canUpload = true;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing');
-
-      editingRef = true;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing editingRef');
-
-      deleted = true;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing editingRef deleted');
-
-      editingRef = false;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing deleted');
-    });
-
-    test('_computeEditBtnClass', () => {
-      let name = 'GLOBAL_CAPABILITIES';
-      assert.equal(element._computeEditBtnClass(name), 'global');
-      name = 'refs/for/*';
-      assert.equal(element._computeEditBtnClass(name), '');
-    });
-  });
-
-  suite('interactive tests', () => {
-    setup(() => {
-      element.labels = {
-        'Code-Review': {
-          values: {
-            ' 0': 'No score',
-            '-1': 'I would prefer this is not merged as is',
-            '-2': 'This shall not be merged',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          default_value: 0,
-        },
-      };
-    });
-    suite('Global section', () => {
-      setup(() => {
-        element.section = {
-          id: 'GLOBAL_CAPABILITIES',
-          value: {
-            permissions: {
-              accessDatabase: {
-                rules: {},
-              },
-            },
-          },
-        };
-        element.capabilities = {
-          accessDatabase: {
-            id: 'accessDatabase',
-            name: 'Access Database',
-          },
-          administrateServer: {
-            id: 'administrateServer',
-            name: 'Administrate Server',
-          },
-          batchChangesLimit: {
-            id: 'batchChangesLimit',
-            name: 'Batch Changes Limit',
-          },
-          createAccount: {
-            id: 'createAccount',
-            name: 'Create Account',
-          },
-        };
-        element._updateSection(element.section);
-        flushAsynchronousOperations();
-      });
-
-      test('classes are assigned correctly', () => {
-        assert.isFalse(element.$.section.classList.contains('editing'));
-        assert.isFalse(element.$.section.classList.contains('deleted'));
-        assert.isTrue(element.$.editBtn.classList.contains('global'));
-        element.editing = true;
-        element.canUpload = true;
-        element.ownerOf = [];
-        assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
-      });
-    });
-
-    suite('Non-global section', () => {
-      setup(() => {
-        element.section = {
-          id: 'refs/*',
-          value: {
-            permissions: {
-              read: {
-                rules: {},
-              },
-            },
-          },
-        };
-        element.capabilities = {};
-        element._updateSection(element.section);
-        flushAsynchronousOperations();
-      });
-
-      test('classes are assigned correctly', () => {
-        assert.isFalse(element.$.section.classList.contains('editing'));
-        assert.isFalse(element.$.section.classList.contains('deleted'));
-        assert.isFalse(element.$.editBtn.classList.contains('global'));
-        element.editing = true;
-        element.canUpload = true;
-        element.ownerOf = [];
-        flushAsynchronousOperations();
-        assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
-      });
-
-      test('add permission', () => {
-        element.editing = true;
-        element.$.permissionSelect.value = 'label-Code-Review';
-        assert.equal(element._permissions.length, 1);
-        assert.equal(Object.keys(element.section.value.permissions).length,
-            1);
-        MockInteractions.tap(element.$.addBtn);
-        flushAsynchronousOperations();
-
-        // The permission is added to both the permissions array and also
-        // the section's permission object.
-        assert.equal(element._permissions.length, 2);
-        let permission = {
-          id: 'label-Code-Review',
-          value: {
-            added: true,
-            label: 'Code-Review',
-            rules: {},
-          },
-        };
-        assert.equal(element._permissions.length, 2);
-        assert.deepEqual(element._permissions[1], permission);
-        assert.equal(Object.keys(element.section.value.permissions).length,
-            2);
-        assert.deepEqual(
-            element.section.value.permissions['label-Code-Review'],
-            permission.value);
-
-        element.$.permissionSelect.value = 'abandon';
-        MockInteractions.tap(element.$.addBtn);
-        flushAsynchronousOperations();
-
-        permission = {
-          id: 'abandon',
-          value: {
-            added: true,
-            rules: {},
-          },
-        };
-
-        assert.equal(element._permissions.length, 3);
-        assert.deepEqual(element._permissions[2], permission);
-        assert.equal(Object.keys(element.section.value.permissions).length,
-            3);
-        assert.deepEqual(element.section.value.permissions['abandon'],
-            permission.value);
-
-        // Unsaved changes are discarded when editing is cancelled.
-        element.editing = false;
-        assert.equal(element._permissions.length, 1);
-        assert.equal(Object.keys(element.section.value.permissions).length,
-            1);
-      });
-
-      test('edit section reference', done => {
-        element.canUpload = true;
-        element.ownerOf = [];
-        element.section = {id: 'refs/for/bar', value: {permissions: {}}};
-        assert.isFalse(element.$.section.classList.contains('editing'));
-        element.editing = true;
-        assert.isTrue(element.$.section.classList.contains('editing'));
-        assert.isFalse(element._editingRef);
-        MockInteractions.tap(element.$.editBtn);
-        element.editRefInput().bindValue='new/ref';
-        setTimeout(() => {
-          assert.equal(element.section.id, 'new/ref');
-          assert.isTrue(element._editingRef);
-          assert.isTrue(element.$.section.classList.contains('editingRef'));
-          element.editing = false;
-          assert.isFalse(element._editingRef);
-          assert.equal(element.section.id, 'refs/for/bar');
-          done();
-        });
-      });
-
-      test('_handleValueChange', () => {
-        // For an exising section.
-        const modifiedHandler = sandbox.stub();
-        element.section = {id: 'refs/for/bar', value: {permissions: {}}};
-        assert.notOk(element.section.value.updatedId);
-        element.section.id = 'refs/for/baz';
-        element.addEventListener('access-modified', modifiedHandler);
-        assert.isNotOk(element.section.value.modified);
-        element._handleValueChange();
-        assert.equal(element.section.value.updatedId, 'refs/for/baz');
-        assert.isTrue(element.section.value.modified);
-        assert.equal(modifiedHandler.callCount, 1);
-        element.section.id = 'refs/for/bar';
-        element._handleValueChange();
-        assert.isFalse(element.section.value.modified);
-        assert.equal(modifiedHandler.callCount, 2);
-
-        // For a new section.
-        element.section.value.added = true;
-        element._handleValueChange();
-        assert.isFalse(element.section.value.modified);
-        assert.equal(modifiedHandler.callCount, 2);
-        element.section.id = 'refs/for/bar';
-        element._handleValueChange();
-        assert.isFalse(element.section.value.modified);
-        assert.equal(modifiedHandler.callCount, 2);
-      });
-
-      test('remove section', () => {
-        element.editing = true;
-        element.canUpload = true;
-        element.ownerOf = [];
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.section.value.deleted);
-        MockInteractions.tap(element.$.deleteBtn);
-        flushAsynchronousOperations();
-        assert.isTrue(element._deleted);
-        assert.isTrue(element.section.value.deleted);
-        assert.isTrue(element.$.section.classList.contains('deleted'));
-        assert.isTrue(element.section.value.deleted);
-
-        MockInteractions.tap(element.$.undoRemoveBtn);
-        flushAsynchronousOperations();
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.section.value.deleted);
-
-        MockInteractions.tap(element.$.deleteBtn);
-        assert.isTrue(element._deleted);
-        assert.isTrue(element.section.value.deleted);
-        element.editing = false;
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.section.value.deleted);
-      });
-
-      test('removing an added permission', () => {
-        element.editing = true;
-        assert.equal(element._permissions.length, 1);
-        element.shadowRoot
-            .querySelector('gr-permission').dispatchEvent(
-                new CustomEvent('added-permission-removed', {
-                  composed: true, bubbles: true,
-                }));
-        flushAsynchronousOperations();
-        assert.equal(element._permissions.length, 0);
-      });
-
-      test('remove an added section', () => {
-        const removeStub = sandbox.stub();
-        element.addEventListener('added-section-removed', removeStub);
-        element.editing = true;
-        element.section.value.added = true;
-        MockInteractions.tap(element.$.deleteBtn);
-        assert.isTrue(removeStub.called);
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
new file mode 100644
index 0000000..8749158
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
@@ -0,0 +1,523 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-access-section.js';
+import {AccessPermissions, toSortedPermissionsArray} from '../../../utils/access-util.js';
+
+const fixture = fixtureFromElement('gr-access-section');
+
+suite('gr-access-section tests', () => {
+  let element;
+
+  setup(() => {
+    element = fixture.instantiate();
+  });
+
+  suite('unit tests', () => {
+    setup(() => {
+      element.section = {
+        id: 'refs/*',
+        value: {
+          permissions: {
+            read: {
+              rules: {},
+            },
+          },
+        },
+      };
+      element.capabilities = {
+        accessDatabase: {
+          id: 'accessDatabase',
+          name: 'Access Database',
+        },
+        administrateServer: {
+          id: 'administrateServer',
+          name: 'Administrate Server',
+        },
+        batchChangesLimit: {
+          id: 'batchChangesLimit',
+          name: 'Batch Changes Limit',
+        },
+        createAccount: {
+          id: 'createAccount',
+          name: 'Create Account',
+        },
+      };
+      element.labels = {
+        'Code-Review': {
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not merged as is',
+            '-2': 'This shall not be merged',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      };
+      element._updateSection(element.section);
+      flushAsynchronousOperations();
+    });
+
+    test('_updateSection', () => {
+      // _updateSection was called in setup, so just make assertions.
+      const expectedPermissions = [
+        {
+          id: 'read',
+          value: {
+            rules: {},
+          },
+        },
+      ];
+      assert.deepEqual(element._permissions, expectedPermissions);
+      assert.equal(element._originalId, element.section.id);
+    });
+
+    test('_computeLabelOptions', () => {
+      const expectedLabelOptions = [
+        {
+          id: 'label-Code-Review',
+          value: {
+            name: 'Label Code-Review',
+            id: 'label-Code-Review',
+          },
+        },
+        {
+          id: 'labelAs-Code-Review',
+          value: {
+            name: 'Label Code-Review (On Behalf Of)',
+            id: 'labelAs-Code-Review',
+          },
+        },
+      ];
+
+      assert.deepEqual(element._computeLabelOptions(element.labels),
+          expectedLabelOptions);
+    });
+
+    test('_handleAccessSaved', () => {
+      assert.equal(element._originalId, 'refs/*');
+      element.section.id = 'refs/for/bar';
+      element._handleAccessSaved();
+      assert.equal(element._originalId, 'refs/for/bar');
+    });
+
+    test('_computePermissions', () => {
+      const capabilities = {
+        push: {
+          rules: {},
+        },
+        read: {
+          rules: {},
+        },
+      };
+
+      const expectedPermissions = [{
+        id: 'push',
+        value: {
+          rules: {},
+        },
+      },
+      ];
+      const labelOptions = [
+        {
+          id: 'label-Code-Review',
+          value: {
+            name: 'Label Code-Review',
+            id: 'label-Code-Review',
+          },
+        },
+        {
+          id: 'labelAs-Code-Review',
+          value: {
+            name: 'Label Code-Review (On Behalf Of)',
+            id: 'labelAs-Code-Review',
+          },
+        },
+      ];
+
+      // For global capabilities, just return the sorted array filtered by
+      // existing permissions.
+      let name = 'GLOBAL_CAPABILITIES';
+      assert.deepEqual(element._computePermissions(name, capabilities,
+          element.labels), expectedPermissions);
+
+      // For everything else, include possible label values before filtering.
+      name = 'refs/for/*';
+      assert.deepEqual(
+          element._computePermissions(name, capabilities, element.labels),
+          labelOptions
+              .concat(toSortedPermissionsArray(AccessPermissions))
+              .filter(permission => permission.id !== 'read'));
+    });
+
+    test('_computePermissionName', () => {
+      let name = 'GLOBAL_CAPABILITIES';
+      let permission = {
+        id: 'administrateServer',
+        value: {},
+      };
+      assert.equal(element._computePermissionName(name, permission,
+          element.capabilities),
+      element.capabilities[permission.id].name);
+
+      name = 'refs/for/*';
+      permission = {
+        id: 'abandon',
+        value: {},
+      };
+
+      assert.equal(element._computePermissionName(
+          name, permission, element.capabilities),
+      AccessPermissions[permission.id].name);
+
+      name = 'refs/for/*';
+      permission = {
+        id: 'label-Code-Review',
+        value: {
+          label: 'Code-Review',
+        },
+      };
+
+      assert.equal(element._computePermissionName(name, permission,
+          element.capabilities),
+      'Label Code-Review');
+
+      permission = {
+        id: 'labelAs-Code-Review',
+        value: {
+          label: 'Code-Review',
+        },
+      };
+
+      assert.equal(element._computePermissionName(name, permission,
+          element.capabilities),
+      'Label Code-Review(On Behalf Of)');
+    });
+
+    test('_computeSectionName', () => {
+      let name;
+      // When computing the section name for an undefined name, it means a
+      // new section is being added. In this case, it should default to
+      // 'refs/heads/*'.
+      element._editingRef = false;
+      assert.equal(element._computeSectionName(name),
+          'Reference: refs/heads/*');
+      assert.isTrue(element._editingRef);
+      assert.equal(element.section.id, 'refs/heads/*');
+
+      // Reset editing to false.
+      element._editingRef = false;
+      name = 'GLOBAL_CAPABILITIES';
+      assert.equal(element._computeSectionName(name), 'Global Capabilities');
+      assert.isFalse(element._editingRef);
+
+      name = 'refs/for/*';
+      assert.equal(element._computeSectionName(name),
+          'Reference: refs/for/*');
+      assert.isFalse(element._editingRef);
+    });
+
+    test('editReference', () => {
+      element.editReference();
+      assert.isTrue(element._editingRef);
+    });
+
+    test('_computeSectionClass', () => {
+      let editingRef = false;
+      let canUpload = false;
+      let ownerOf = [];
+      let editing = false;
+      let deleted = false;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), '');
+
+      editing = true;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), '');
+
+      ownerOf = ['refs/*'];
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing');
+
+      ownerOf = [];
+      canUpload = true;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing');
+
+      editingRef = true;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing editingRef');
+
+      deleted = true;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing editingRef deleted');
+
+      editingRef = false;
+      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
+          editingRef, deleted), 'editing deleted');
+    });
+
+    test('_computeEditBtnClass', () => {
+      let name = 'GLOBAL_CAPABILITIES';
+      assert.equal(element._computeEditBtnClass(name), 'global');
+      name = 'refs/for/*';
+      assert.equal(element._computeEditBtnClass(name), '');
+    });
+  });
+
+  suite('interactive tests', () => {
+    setup(() => {
+      element.labels = {
+        'Code-Review': {
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not merged as is',
+            '-2': 'This shall not be merged',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      };
+    });
+    suite('Global section', () => {
+      setup(() => {
+        element.section = {
+          id: 'GLOBAL_CAPABILITIES',
+          value: {
+            permissions: {
+              accessDatabase: {
+                rules: {},
+              },
+            },
+          },
+        };
+        element.capabilities = {
+          accessDatabase: {
+            id: 'accessDatabase',
+            name: 'Access Database',
+          },
+          administrateServer: {
+            id: 'administrateServer',
+            name: 'Administrate Server',
+          },
+          batchChangesLimit: {
+            id: 'batchChangesLimit',
+            name: 'Batch Changes Limit',
+          },
+          createAccount: {
+            id: 'createAccount',
+            name: 'Create Account',
+          },
+        };
+        element._updateSection(element.section);
+        flushAsynchronousOperations();
+      });
+
+      test('classes are assigned correctly', () => {
+        assert.isFalse(element.$.section.classList.contains('editing'));
+        assert.isFalse(element.$.section.classList.contains('deleted'));
+        assert.isTrue(element.$.editBtn.classList.contains('global'));
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
+      });
+    });
+
+    suite('Non-global section', () => {
+      setup(() => {
+        element.section = {
+          id: 'refs/*',
+          value: {
+            permissions: {
+              read: {
+                rules: {},
+              },
+            },
+          },
+        };
+        element.capabilities = {};
+        element._updateSection(element.section);
+        flushAsynchronousOperations();
+      });
+
+      test('classes are assigned correctly', () => {
+        assert.isFalse(element.$.section.classList.contains('editing'));
+        assert.isFalse(element.$.section.classList.contains('deleted'));
+        assert.isFalse(element.$.editBtn.classList.contains('global'));
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        flushAsynchronousOperations();
+        assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
+      });
+
+      test('add permission', () => {
+        element.editing = true;
+        element.$.permissionSelect.value = 'label-Code-Review';
+        assert.equal(element._permissions.length, 1);
+        assert.equal(Object.keys(element.section.value.permissions).length,
+            1);
+        MockInteractions.tap(element.$.addBtn);
+        flushAsynchronousOperations();
+
+        // The permission is added to both the permissions array and also
+        // the section's permission object.
+        assert.equal(element._permissions.length, 2);
+        let permission = {
+          id: 'label-Code-Review',
+          value: {
+            added: true,
+            label: 'Code-Review',
+            rules: {},
+          },
+        };
+        assert.equal(element._permissions.length, 2);
+        assert.deepEqual(element._permissions[1], permission);
+        assert.equal(Object.keys(element.section.value.permissions).length,
+            2);
+        assert.deepEqual(
+            element.section.value.permissions['label-Code-Review'],
+            permission.value);
+
+        element.$.permissionSelect.value = 'abandon';
+        MockInteractions.tap(element.$.addBtn);
+        flushAsynchronousOperations();
+
+        permission = {
+          id: 'abandon',
+          value: {
+            added: true,
+            rules: {},
+          },
+        };
+
+        assert.equal(element._permissions.length, 3);
+        assert.deepEqual(element._permissions[2], permission);
+        assert.equal(Object.keys(element.section.value.permissions).length,
+            3);
+        assert.deepEqual(element.section.value.permissions['abandon'],
+            permission.value);
+
+        // Unsaved changes are discarded when editing is cancelled.
+        element.editing = false;
+        assert.equal(element._permissions.length, 1);
+        assert.equal(Object.keys(element.section.value.permissions).length,
+            1);
+      });
+
+      test('edit section reference', done => {
+        element.canUpload = true;
+        element.ownerOf = [];
+        element.section = {id: 'refs/for/bar', value: {permissions: {}}};
+        assert.isFalse(element.$.section.classList.contains('editing'));
+        element.editing = true;
+        assert.isTrue(element.$.section.classList.contains('editing'));
+        assert.isFalse(element._editingRef);
+        MockInteractions.tap(element.$.editBtn);
+        element.editRefInput().bindValue='new/ref';
+        setTimeout(() => {
+          assert.equal(element.section.id, 'new/ref');
+          assert.isTrue(element._editingRef);
+          assert.isTrue(element.$.section.classList.contains('editingRef'));
+          element.editing = false;
+          assert.isFalse(element._editingRef);
+          assert.equal(element.section.id, 'refs/for/bar');
+          done();
+        });
+      });
+
+      test('_handleValueChange', () => {
+        // For an existing section.
+        const modifiedHandler = sinon.stub();
+        element.section = {id: 'refs/for/bar', value: {permissions: {}}};
+        assert.notOk(element.section.value.updatedId);
+        element.section.id = 'refs/for/baz';
+        element.addEventListener('access-modified', modifiedHandler);
+        assert.isNotOk(element.section.value.modified);
+        element._handleValueChange();
+        assert.equal(element.section.value.updatedId, 'refs/for/baz');
+        assert.isTrue(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 1);
+        element.section.id = 'refs/for/bar';
+        element._handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
+
+        // For a new section.
+        element.section.value.added = true;
+        element._handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
+        element.section.id = 'refs/for/bar';
+        element._handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
+      });
+
+      test('remove section', () => {
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        assert.isFalse(element._deleted);
+        assert.isNotOk(element.section.value.deleted);
+        MockInteractions.tap(element.$.deleteBtn);
+        flushAsynchronousOperations();
+        assert.isTrue(element._deleted);
+        assert.isTrue(element.section.value.deleted);
+        assert.isTrue(element.$.section.classList.contains('deleted'));
+        assert.isTrue(element.section.value.deleted);
+
+        MockInteractions.tap(element.$.undoRemoveBtn);
+        flushAsynchronousOperations();
+        assert.isFalse(element._deleted);
+        assert.isNotOk(element.section.value.deleted);
+
+        MockInteractions.tap(element.$.deleteBtn);
+        assert.isTrue(element._deleted);
+        assert.isTrue(element.section.value.deleted);
+        element.editing = false;
+        assert.isFalse(element._deleted);
+        assert.isNotOk(element.section.value.deleted);
+      });
+
+      test('removing an added permission', () => {
+        element.editing = true;
+        assert.equal(element._permissions.length, 1);
+        element.shadowRoot
+            .querySelector('gr-permission').dispatchEvent(
+                new CustomEvent('added-permission-removed', {
+                  composed: true, bubbles: true,
+                }));
+        flushAsynchronousOperations();
+        assert.equal(element._permissions.length, 0);
+      });
+
+      test('remove an added section', () => {
+        const removeStub = sinon.stub();
+        element.addEventListener('added-section-removed', removeStub);
+        element.editing = true;
+        element.section.value.added = true;
+        MockInteractions.tap(element.$.deleteBtn);
+        assert.isTrue(removeStub.called);
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
index 36e2cc4..fd1f492 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 
 import '../../../styles/gr-table-styles.js';
 import '../../../styles/shared-styles.js';
@@ -23,21 +22,18 @@
 import '../../shared/gr-overlay/gr-overlay.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-create-group-dialog/gr-create-group-dialog.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-admin-group-list_html.js';
-import {ListViewBehavior} from '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
 /**
  * @appliesMixin ListViewMixin
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrAdminGroupList extends mixinBehaviors( [
-  ListViewBehavior,
-], GestureEventListeners(
+class GrAdminGroupList extends ListViewMixin(GestureEventListeners(
     LegacyElementMixin(
         PolymerElement))) {
   static get template() { return htmlTemplate; }
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.js
deleted file mode 100644
index 4548a45..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <gr-list-view
-    create-new="[[_createNewCapability]]"
-    filter="[[_filter]]"
-    items="[[_groups]]"
-    items-per-page="[[_groupsPerPage]]"
-    loading="[[_loading]]"
-    offset="[[_offset]]"
-    on-create-clicked="_handleCreateClicked"
-    path="[[_path]]"
-  >
-    <table id="list" class="genericList">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Group Name</th>
-          <th class="description topHeader">Group Description</th>
-          <th class="visibleToAll topHeader">Visible To All</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_shownGroups]]">
-          <tr class="table">
-            <td class="name">
-              <a href$="[[_computeGroupUrl(item.id)]]">[[item.name]]</a>
-            </td>
-            <td class="description">[[item.description]]</td>
-            <td class="visibleToAll">[[_visibleToAll(item)]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </gr-list-view>
-  <gr-overlay id="createOverlay" with-backdrop="">
-    <gr-dialog
-      id="createDialog"
-      class="confirmDialog"
-      disabled="[[!_hasNewGroupName]]"
-      confirm-label="Create"
-      confirm-on-enter=""
-      on-confirm="_handleCreateGroup"
-      on-cancel="_handleCloseCreate"
-    >
-      <div class="header" slot="header">
-        Create Group
-      </div>
-      <div class="main" slot="main">
-        <gr-create-group-dialog
-          has-new-group-name="{{_hasNewGroupName}}"
-          params="[[params]]"
-          id="createNewModal"
-        ></gr-create-group-dialog>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
new file mode 100644
index 0000000..e22dc66
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
@@ -0,0 +1,83 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-table-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <gr-list-view
+    create-new="[[_createNewCapability]]"
+    filter="[[_filter]]"
+    items="[[_groups]]"
+    items-per-page="[[_groupsPerPage]]"
+    loading="[[_loading]]"
+    offset="[[_offset]]"
+    on-create-clicked="_handleCreateClicked"
+    path="[[_path]]"
+  >
+    <table id="list" class="genericList">
+      <tbody>
+        <tr class="headerRow">
+          <th class="name topHeader">Group Name</th>
+          <th class="description topHeader">Group Description</th>
+          <th class="visibleToAll topHeader">Visible To All</th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+      </tbody>
+      <tbody class$="[[computeLoadingClass(_loading)]]">
+        <template is="dom-repeat" items="[[_shownGroups]]">
+          <tr class="table">
+            <td class="name">
+              <a href$="[[_computeGroupUrl(item.id)]]">[[item.name]]</a>
+            </td>
+            <td class="description">[[item.description]]</td>
+            <td class="visibleToAll">[[_visibleToAll(item)]]</td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </gr-list-view>
+  <gr-overlay id="createOverlay" with-backdrop="">
+    <gr-dialog
+      id="createDialog"
+      class="confirmDialog"
+      disabled="[[!_hasNewGroupName]]"
+      confirm-label="Create"
+      confirm-on-enter=""
+      on-confirm="_handleCreateGroup"
+      on-cancel="_handleCloseCreate"
+    >
+      <div class="header" slot="header">
+        Create Group
+      </div>
+      <div class="main" slot="main">
+        <gr-create-group-dialog
+          has-new-group-name="{{_hasNewGroupName}}"
+          params="[[params]]"
+          id="createNewModal"
+        ></gr-create-group-dialog>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
deleted file mode 100644
index c8c7f0c..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
+++ /dev/null
@@ -1,219 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-admin-group-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-admin-group-list></gr-admin-group-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-admin-group-list.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-let counter = 0;
-const groupGenerator = () => {
-  return {
-    name: `test${++counter}`,
-    id: '59b92f35489e62c80d1ab1bf0c2d17843038df8b',
-    url: '#/admin/groups/uuid-59b92f35489e62c80d1ab1bf0c2d17843038df8b',
-    options: {
-      visible_to_all: false,
-    },
-    description: 'Gerrit Site Administrators',
-    group_id: 1,
-    owner: 'Administrators',
-    owner_id: '7ca042f4d5847936fcb90ca91057673157fd06fc',
-  };
-};
-
-suite('gr-admin-group-list tests', () => {
-  let element;
-  let groups;
-  let sandbox;
-  let value;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_computeGroupUrl', () => {
-    let urlStub = sandbox.stub(GerritNav, 'getUrlForGroup',
-        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-    let group = {
-      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
-    };
-    assert.equal(element._computeGroupUrl(group),
-        '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-    urlStub.restore();
-
-    urlStub = sandbox.stub(GerritNav, 'getUrlForGroup',
-        () => '/admin/groups/user/test');
-
-    group = {
-      id: 'user%2Ftest',
-    };
-    assert.equal(element._computeGroupUrl(group),
-        '/admin/groups/user/test');
-
-    urlStub.restore();
-  });
-
-  suite('list with groups', () => {
-    setup(done => {
-      groups = _.times(26, groupGenerator);
-
-      stub('gr-rest-api-interface', {
-        getGroups(num, offset) {
-          return Promise.resolve(groups);
-        },
-      });
-
-      element._paramsChanged(value).then(() => { flush(done); });
-    });
-
-    test('test for test group in the list', done => {
-      flush(() => {
-        assert.equal(element._groups[1].name, '1');
-        assert.equal(element._groups[1].options.visible_to_all, false);
-        done();
-      });
-    });
-
-    test('_shownGroups', () => {
-      assert.equal(element._shownGroups.length, 25);
-    });
-
-    test('_maybeOpenCreateOverlay', () => {
-      const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
-      element._maybeOpenCreateOverlay();
-      assert.isFalse(overlayOpen.called);
-      const params = {};
-      element._maybeOpenCreateOverlay(params);
-      assert.isFalse(overlayOpen.called);
-      params.openCreateModal = true;
-      element._maybeOpenCreateOverlay(params);
-      assert.isTrue(overlayOpen.called);
-    });
-  });
-
-  suite('test with less then 25 groups', () => {
-    setup(done => {
-      groups = _.times(25, groupGenerator);
-
-      stub('gr-rest-api-interface', {
-        getGroups(num, offset) {
-          return Promise.resolve(groups);
-        },
-      });
-
-      element._paramsChanged(value).then(() => { flush(done); });
-    });
-
-    test('_shownGroups', () => {
-      assert.equal(element._shownGroups.length, 25);
-    });
-  });
-
-  suite('filter', () => {
-    test('_paramsChanged', done => {
-      sandbox.stub(
-          element.$.restAPI,
-          'getGroups',
-          () => Promise.resolve(groups));
-      const value = {
-        filter: 'test',
-        offset: 25,
-      };
-      element._paramsChanged(value).then(() => {
-        assert.isTrue(element.$.restAPI.getGroups.lastCall
-            .calledWithExactly('test', 25, 25));
-        done();
-      });
-    });
-  });
-
-  suite('loading', () => {
-    test('correct contents are displayed', () => {
-      assert.isTrue(element._loading);
-      assert.equal(element.computeLoadingClass(element._loading), 'loading');
-      assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-      element._loading = false;
-      element._groups = _.times(25, groupGenerator);
-
-      flushAsynchronousOperations();
-      assert.equal(element.computeLoadingClass(element._loading), '');
-      assert.equal(getComputedStyle(element.$.loading).display, 'none');
-    });
-  });
-
-  suite('create new', () => {
-    test('_handleCreateClicked called when create-click fired', () => {
-      sandbox.stub(element, '_handleCreateClicked');
-      element.shadowRoot
-          .querySelector('gr-list-view').dispatchEvent(
-              new CustomEvent('create-clicked', {
-                composed: true, bubbles: true,
-              }));
-      assert.isTrue(element._handleCreateClicked.called);
-    });
-
-    test('_handleCreateClicked opens modal', () => {
-      const openStub = sandbox.stub(element.$.createOverlay, 'open');
-      element._handleCreateClicked();
-      assert.isTrue(openStub.called);
-    });
-
-    test('_handleCreateGroup called when confirm fired', () => {
-      sandbox.stub(element, '_handleCreateGroup');
-      element.$.createDialog.dispatchEvent(
-          new CustomEvent('confirm', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCreateGroup.called);
-    });
-
-    test('_handleCloseCreate called when cancel fired', () => {
-      sandbox.stub(element, '_handleCloseCreate');
-      element.$.createDialog.dispatchEvent(
-          new CustomEvent('cancel', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCloseCreate.called);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
new file mode 100644
index 0000000..546b8a8
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
@@ -0,0 +1,199 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-admin-group-list.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-admin-group-list');
+
+let counter = 0;
+const groupGenerator = () => {
+  return {
+    name: `test${++counter}`,
+    id: '59b92f35489e62c80d1ab1bf0c2d17843038df8b',
+    url: '#/admin/groups/uuid-59b92f35489e62c80d1ab1bf0c2d17843038df8b',
+    options: {
+      visible_to_all: false,
+    },
+    description: 'Gerrit Site Administrators',
+    group_id: 1,
+    owner: 'Administrators',
+    owner_id: '7ca042f4d5847936fcb90ca91057673157fd06fc',
+  };
+};
+
+suite('gr-admin-group-list tests', () => {
+  let element;
+  let groups;
+
+  let value;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_computeGroupUrl', () => {
+    let urlStub = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
+        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+    let group = {
+      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
+    };
+    assert.equal(element._computeGroupUrl(group),
+        '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+    urlStub.restore();
+
+    urlStub = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
+        () => '/admin/groups/user/test');
+
+    group = {
+      id: 'user%2Ftest',
+    };
+    assert.equal(element._computeGroupUrl(group),
+        '/admin/groups/user/test');
+
+    urlStub.restore();
+  });
+
+  suite('list with groups', () => {
+    setup(done => {
+      groups = _.times(26, groupGenerator);
+
+      stub('gr-rest-api-interface', {
+        getGroups(num, offset) {
+          return Promise.resolve(groups);
+        },
+      });
+
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
+
+    test('test for test group in the list', done => {
+      flush(() => {
+        assert.equal(element._groups[1].name, '1');
+        assert.equal(element._groups[1].options.visible_to_all, false);
+        done();
+      });
+    });
+
+    test('_shownGroups', () => {
+      assert.equal(element._shownGroups.length, 25);
+    });
+
+    test('_maybeOpenCreateOverlay', () => {
+      const overlayOpen = sinon.stub(element.$.createOverlay, 'open');
+      element._maybeOpenCreateOverlay();
+      assert.isFalse(overlayOpen.called);
+      const params = {};
+      element._maybeOpenCreateOverlay(params);
+      assert.isFalse(overlayOpen.called);
+      params.openCreateModal = true;
+      element._maybeOpenCreateOverlay(params);
+      assert.isTrue(overlayOpen.called);
+    });
+  });
+
+  suite('test with less then 25 groups', () => {
+    setup(done => {
+      groups = _.times(25, groupGenerator);
+
+      stub('gr-rest-api-interface', {
+        getGroups(num, offset) {
+          return Promise.resolve(groups);
+        },
+      });
+
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
+
+    test('_shownGroups', () => {
+      assert.equal(element._shownGroups.length, 25);
+    });
+  });
+
+  suite('filter', () => {
+    test('_paramsChanged', done => {
+      sinon.stub(
+          element.$.restAPI,
+          'getGroups')
+          .callsFake(() => Promise.resolve(groups));
+      const value = {
+        filter: 'test',
+        offset: 25,
+      };
+      element._paramsChanged(value).then(() => {
+        assert.isTrue(element.$.restAPI.getGroups.lastCall
+            .calledWithExactly('test', 25, 25));
+        done();
+      });
+    });
+  });
+
+  suite('loading', () => {
+    test('correct contents are displayed', () => {
+      assert.isTrue(element._loading);
+      assert.equal(element.computeLoadingClass(element._loading), 'loading');
+      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+      element._loading = false;
+      element._groups = _.times(25, groupGenerator);
+
+      flushAsynchronousOperations();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+
+  suite('create new', () => {
+    test('_handleCreateClicked called when create-click fired', () => {
+      sinon.stub(element, '_handleCreateClicked');
+      element.shadowRoot
+          .querySelector('gr-list-view').dispatchEvent(
+              new CustomEvent('create-clicked', {
+                composed: true, bubbles: true,
+              }));
+      assert.isTrue(element._handleCreateClicked.called);
+    });
+
+    test('_handleCreateClicked opens modal', () => {
+      const openStub = sinon.stub(element.$.createOverlay, 'open');
+      element._handleCreateClicked();
+      assert.isTrue(openStub.called);
+    });
+
+    test('_handleCreateGroup called when confirm fired', () => {
+      sinon.stub(element, '_handleCreateGroup');
+      element.$.createDialog.dispatchEvent(
+          new CustomEvent('confirm', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCreateGroup.called);
+    });
+
+    test('_handleCloseCreate called when cancel fired', () => {
+      sinon.stub(element, '_handleCloseCreate');
+      element.$.createDialog.dispatchEvent(
+          new CustomEvent('cancel', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCloseCreate.called);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
index b318ee6..e555583 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 import '../../../styles/gr-menu-page-styles.js';
 import '../../../styles/gr-page-nav-styles.js';
 import '../../../styles/shared-styles.js';
@@ -34,29 +33,23 @@
 import '../gr-repo-dashboards/gr-repo-dashboards.js';
 import '../gr-repo-detail-list/gr-repo-detail-list.js';
 import '../gr-repo-list/gr-repo-list.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-admin-view_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {AdminNavBehavior} from '../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getAdminLinks} from '../../../utils/admin-nav-util.js';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrAdminView extends mixinBehaviors( [
-  AdminNavBehavior,
-  BaseUrlBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
+class GrAdminView extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-admin-view'; }
@@ -136,7 +129,7 @@
         };
       }
 
-      return this.getAdminLinks(this._account,
+      return getAdminLinks(this._account,
           this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
           this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI),
           options)
@@ -239,7 +232,7 @@
   // updated. They are currently copied from gr-dropdown (and should be
   // updated there as well once complete).
   _computeURLHelper(host, path) {
-    return '//' + host + this.getBaseUrl() + path;
+    return '//' + host + getBaseUrl() + path;
   }
 
   _computeRelativeURL(path) {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.js
deleted file mode 100644
index b62a41b..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.js
+++ /dev/null
@@ -1,183 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-page-nav-styles">
-    gr-dropdown-list {
-      --trigger-style: {
-        text-transform: none;
-      }
-    }
-    .breadcrumbText {
-      /* Same as dropdown trigger so chevron spacing is consistent. */
-      padding: 5px 4px;
-    }
-    iron-icon {
-      margin: 0 var(--spacing-xs);
-    }
-    .breadcrumb {
-      align-items: center;
-      display: flex;
-    }
-    .mainHeader {
-      align-items: baseline;
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-    }
-    .selectText {
-      display: none;
-    }
-    .selectText.show {
-      display: inline-block;
-    }
-    main.breadcrumbs:not(.table) {
-      margin-top: var(--spacing-l);
-    }
-  </style>
-  <gr-page-nav class="navStyles">
-    <ul class="sectionContent">
-      <template id="adminNav" is="dom-repeat" items="[[_filteredLinks]]">
-        <li class$="sectionTitle [[_computeSelectedClass(item.view, params)]]">
-          <a class="title" href="[[_computeLinkURL(item)]]" rel="noopener"
-            >[[item.name]]</a
-          >
-        </li>
-        <template is="dom-repeat" items="[[item.children]]" as="child">
-          <li class$="[[_computeSelectedClass(child.view, params)]]">
-            <a href$="[[_computeLinkURL(child)]]" rel="noopener"
-              >[[child.name]]</a
-            >
-          </li>
-        </template>
-        <template is="dom-if" if="[[item.subsection]]">
-          <!--If a section has a subsection, render that.-->
-          <li class$="[[_computeSelectedClass(item.subsection.view, params)]]">
-            <a
-              class="title"
-              href$="[[_computeLinkURL(item.subsection)]]"
-              rel="noopener"
-            >
-              [[item.subsection.name]]</a
-            >
-          </li>
-          <!--Loop through the links in the sub-section.-->
-          <template
-            is="dom-repeat"
-            items="[[item.subsection.children]]"
-            as="child"
-          >
-            <li
-              class$="subsectionItem [[_computeSelectedClass(child.view, params, child.detailType)]]"
-            >
-              <a href$="[[_computeLinkURL(child)]]">[[child.name]]</a>
-            </li>
-          </template>
-        </template>
-      </template>
-    </ul>
-  </gr-page-nav>
-  <template is="dom-if" if="[[_subsectionLinks.length]]">
-    <section class="mainHeader">
-      <span class="breadcrumb">
-        <span class="breadcrumbText">[[_breadcrumbParentName]]</span>
-        <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-      </span>
-      <gr-dropdown-list
-        lowercase=""
-        id="pageSelect"
-        value="[[_computeSelectValue(params)]]"
-        items="[[_subsectionLinks]]"
-        on-value-change="_handleSubsectionChange"
-      >
-      </gr-dropdown-list>
-    </section>
-  </template>
-  <template is="dom-if" if="[[_showRepoList]]" restamp="true">
-    <main class="table">
-      <gr-repo-list class="table" params="[[params]]"></gr-repo-list>
-    </main>
-  </template>
-  <template is="dom-if" if="[[_showGroupList]]" restamp="true">
-    <main class="table">
-      <gr-admin-group-list class="table" params="[[params]]">
-      </gr-admin-group-list>
-    </main>
-  </template>
-  <template is="dom-if" if="[[_showPluginList]]" restamp="true">
-    <main class="table">
-      <gr-plugin-list class="table" params="[[params]]"></gr-plugin-list>
-    </main>
-  </template>
-  <template is="dom-if" if="[[_showRepoMain]]" restamp="true">
-    <main class="breadcrumbs">
-      <gr-repo repo="[[params.repo]]"></gr-repo>
-    </main>
-  </template>
-  <template is="dom-if" if="[[_showGroup]]" restamp="true">
-    <main class="breadcrumbs">
-      <gr-group
-        group-id="[[params.groupId]]"
-        on-name-changed="_updateGroupName"
-      ></gr-group>
-    </main>
-  </template>
-  <template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
-    <main class="breadcrumbs">
-      <gr-group-members group-id="[[params.groupId]]"></gr-group-members>
-    </main>
-  </template>
-  <template is="dom-if" if="[[_showRepoDetailList]]" restamp="true">
-    <main class="table breadcrumbs">
-      <gr-repo-detail-list
-        params="[[params]]"
-        class="table"
-      ></gr-repo-detail-list>
-    </main>
-  </template>
-  <template is="dom-if" if="[[_showGroupAuditLog]]" restamp="true">
-    <main class="table breadcrumbs">
-      <gr-group-audit-log
-        group-id="[[params.groupId]]"
-        class="table"
-      ></gr-group-audit-log>
-    </main>
-  </template>
-  <template is="dom-if" if="[[_showRepoCommands]]" restamp="true">
-    <main class="breadcrumbs">
-      <gr-repo-commands repo="[[params.repo]]"></gr-repo-commands>
-    </main>
-  </template>
-  <template is="dom-if" if="[[_showRepoAccess]]" restamp="true">
-    <main class="breadcrumbs">
-      <gr-repo-access path="[[path]]" repo="[[params.repo]]"></gr-repo-access>
-    </main>
-  </template>
-  <template is="dom-if" if="[[_showRepoDashboards]]" restamp="true">
-    <main class="table breadcrumbs">
-      <gr-repo-dashboards repo="[[params.repo]]"></gr-repo-dashboards>
-    </main>
-  </template>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
new file mode 100644
index 0000000..5e85a93
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
@@ -0,0 +1,183 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-menu-page-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-page-nav-styles">
+    gr-dropdown-list {
+      --trigger-style: {
+        text-transform: none;
+      }
+    }
+    .breadcrumbText {
+      /* Same as dropdown trigger so chevron spacing is consistent. */
+      padding: 5px 4px;
+    }
+    iron-icon {
+      margin: 0 var(--spacing-xs);
+    }
+    .breadcrumb {
+      align-items: center;
+      display: flex;
+    }
+    .mainHeader {
+      align-items: baseline;
+      border-bottom: 1px solid var(--border-color);
+      display: flex;
+    }
+    .selectText {
+      display: none;
+    }
+    .selectText.show {
+      display: inline-block;
+    }
+    main.breadcrumbs:not(.table) {
+      margin-top: var(--spacing-l);
+    }
+  </style>
+  <gr-page-nav class="navStyles">
+    <ul class="sectionContent">
+      <template id="adminNav" is="dom-repeat" items="[[_filteredLinks]]">
+        <li class$="sectionTitle [[_computeSelectedClass(item.view, params)]]">
+          <a class="title" href="[[_computeLinkURL(item)]]" rel="noopener"
+            >[[item.name]]</a
+          >
+        </li>
+        <template is="dom-repeat" items="[[item.children]]" as="child">
+          <li class$="[[_computeSelectedClass(child.view, params)]]">
+            <a href$="[[_computeLinkURL(child)]]" rel="noopener"
+              >[[child.name]]</a
+            >
+          </li>
+        </template>
+        <template is="dom-if" if="[[item.subsection]]">
+          <!--If a section has a subsection, render that.-->
+          <li class$="[[_computeSelectedClass(item.subsection.view, params)]]">
+            <a
+              class="title"
+              href$="[[_computeLinkURL(item.subsection)]]"
+              rel="noopener"
+            >
+              [[item.subsection.name]]</a
+            >
+          </li>
+          <!--Loop through the links in the sub-section.-->
+          <template
+            is="dom-repeat"
+            items="[[item.subsection.children]]"
+            as="child"
+          >
+            <li
+              class$="subsectionItem [[_computeSelectedClass(child.view, params, child.detailType)]]"
+            >
+              <a href$="[[_computeLinkURL(child)]]">[[child.name]]</a>
+            </li>
+          </template>
+        </template>
+      </template>
+    </ul>
+  </gr-page-nav>
+  <template is="dom-if" if="[[_subsectionLinks.length]]">
+    <section class="mainHeader">
+      <span class="breadcrumb">
+        <span class="breadcrumbText">[[_breadcrumbParentName]]</span>
+        <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+      </span>
+      <gr-dropdown-list
+        lowercase=""
+        id="pageSelect"
+        value="[[_computeSelectValue(params)]]"
+        items="[[_subsectionLinks]]"
+        on-value-change="_handleSubsectionChange"
+      >
+      </gr-dropdown-list>
+    </section>
+  </template>
+  <template is="dom-if" if="[[_showRepoList]]" restamp="true">
+    <main class="table">
+      <gr-repo-list class="table" params="[[params]]"></gr-repo-list>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showGroupList]]" restamp="true">
+    <main class="table">
+      <gr-admin-group-list class="table" params="[[params]]">
+      </gr-admin-group-list>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showPluginList]]" restamp="true">
+    <main class="table">
+      <gr-plugin-list class="table" params="[[params]]"></gr-plugin-list>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showRepoMain]]" restamp="true">
+    <main class="breadcrumbs">
+      <gr-repo repo="[[params.repo]]"></gr-repo>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showGroup]]" restamp="true">
+    <main class="breadcrumbs">
+      <gr-group
+        group-id="[[params.groupId]]"
+        on-name-changed="_updateGroupName"
+      ></gr-group>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
+    <main class="breadcrumbs">
+      <gr-group-members group-id="[[params.groupId]]"></gr-group-members>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showRepoDetailList]]" restamp="true">
+    <main class="table breadcrumbs">
+      <gr-repo-detail-list
+        params="[[params]]"
+        class="table"
+      ></gr-repo-detail-list>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showGroupAuditLog]]" restamp="true">
+    <main class="table breadcrumbs">
+      <gr-group-audit-log
+        group-id="[[params.groupId]]"
+        class="table"
+      ></gr-group-audit-log>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showRepoCommands]]" restamp="true">
+    <main class="breadcrumbs">
+      <gr-repo-commands repo="[[params.repo]]"></gr-repo-commands>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showRepoAccess]]" restamp="true">
+    <main class="breadcrumbs">
+      <gr-repo-access path="[[path]]" repo="[[params.repo]]"></gr-repo-access>
+    </main>
+  </template>
+  <template is="dom-if" if="[[_showRepoDashboards]]" restamp="true">
+    <main class="table breadcrumbs">
+      <gr-repo-dashboards repo="[[params.repo]]"></gr-repo-dashboards>
+    </main>
+  </template>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
deleted file mode 100644
index ec72bfd..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
+++ /dev/null
@@ -1,684 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-admin-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-admin-view></gr-admin-view>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-admin-view.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-
-suite('gr-admin-view tests', () => {
-  let element;
-  let sandbox;
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    stub('gr-rest-api-interface', {
-      getProjectConfig() {
-        return Promise.resolve({});
-      },
-    });
-    const pluginsLoaded = Promise.resolve();
-    sandbox.stub(pluginLoader, 'awaitPluginsLoaded').returns(pluginsLoaded);
-    pluginsLoaded.then(() => flush(done));
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_computeURLHelper', () => {
-    const path = '/test';
-    const host = 'http://www.testsite.com';
-    const computedPath = element._computeURLHelper(host, path);
-    assert.equal(computedPath, '//http://www.testsite.com/test');
-  });
-
-  test('link URLs', () => {
-    assert.equal(
-        element._computeLinkURL({url: '/test', noBaseUrl: true}),
-        '//' + window.location.host + '/test');
-
-    sandbox.stub(element, 'getBaseUrl').returns('/foo');
-    assert.equal(
-        element._computeLinkURL({url: '/test', noBaseUrl: true}),
-        '//' + window.location.host + '/foo/test');
-    assert.equal(element._computeLinkURL({url: '/test'}), '/test');
-    assert.equal(
-        element._computeLinkURL({url: '/test', target: '_blank'}),
-        '/test');
-  });
-
-  test('current page gets selected and is displayed', () => {
-    element._filteredLinks = [{
-      name: 'Repositories',
-      url: '/admin/repos',
-      view: 'gr-repo-list',
-    }];
-
-    element.params = {
-      view: 'admin',
-      adminView: 'gr-repo-list',
-    };
-
-    flushAsynchronousOperations();
-    assert.equal(dom(element.root).querySelectorAll(
-        '.selected').length, 1);
-    assert.ok(element.shadowRoot
-        .querySelector('gr-repo-list'));
-    assert.isNotOk(element.shadowRoot
-        .querySelector('gr-admin-create-repo'));
-  });
-
-  test('_filteredLinks admin', done => {
-    sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    sandbox.stub(
-        element.$.restAPI,
-        'getAccountCapabilities',
-        () => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        })
-    );
-    element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 3);
-
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
-
-      // Groups
-      assert.isNotOk(element._filteredLinks[0].subsection);
-
-      // Plugins
-      assert.isNotOk(element._filteredLinks[0].subsection);
-      done();
-    });
-  });
-
-  test('_filteredLinks non admin authenticated', done => {
-    sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    sandbox.stub(
-        element.$.restAPI,
-        'getAccountCapabilities',
-        () => Promise.resolve({})
-    );
-    element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 2);
-
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
-
-      // Groups
-      assert.isNotOk(element._filteredLinks[0].subsection);
-      done();
-    });
-  });
-
-  test('_filteredLinks non admin unathenticated', done => {
-    element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 1);
-
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
-      done();
-    });
-  });
-
-  test('_filteredLinks from plugin', () => {
-    sandbox.stub(element.$.jsAPI, 'getAdminMenuLinks').returns([
-      {text: 'internal link text', url: '/internal/link/url'},
-      {text: 'external link text', url: 'http://external/link/url'},
-    ]);
-    return element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 3);
-      assert.deepEqual(element._filteredLinks[1], {
-        capability: null,
-        url: '/internal/link/url',
-        name: 'internal link text',
-        noBaseUrl: true,
-        view: null,
-        viewableToAll: true,
-        target: null,
-      });
-      assert.deepEqual(element._filteredLinks[2], {
-        capability: null,
-        url: 'http://external/link/url',
-        name: 'external link text',
-        noBaseUrl: false,
-        view: null,
-        viewableToAll: true,
-        target: '_blank',
-      });
-    });
-  });
-
-  test('Repo shows up in nav', done => {
-    element._repoName = 'Test Repo';
-    sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    sandbox.stub(
-        element.$.restAPI,
-        'getAccountCapabilities',
-        () => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    element.reload().then(() => {
-      flushAsynchronousOperations();
-      assert.equal(dom(element.root)
-          .querySelectorAll('.sectionTitle').length, 3);
-      assert.equal(element.shadowRoot
-          .querySelector('.breadcrumbText').innerText, 'Test Repo');
-      assert.equal(
-          element.shadowRoot.querySelector('#pageSelect').items.length,
-          6
-      );
-      done();
-    });
-  });
-
-  test('Group shows up in nav', done => {
-    element._groupId = 'a15262';
-    element._groupName = 'my-group';
-    element._groupIsInternal = true;
-    element._isAdmin = true;
-    element._groupOwner = false;
-    sandbox.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    sandbox.stub(
-        element.$.restAPI,
-        'getAccountCapabilities',
-        () => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    element.reload().then(() => {
-      flushAsynchronousOperations();
-      assert.equal(element._filteredLinks.length, 3);
-
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
-
-      // Groups
-      assert.equal(element._filteredLinks[1].subsection.children.length, 2);
-      assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
-
-      // Plugins
-      assert.isNotOk(element._filteredLinks[2].subsection);
-      done();
-    });
-  });
-
-  test('Nav is reloaded when repo changes', () => {
-    sandbox.stub(
-        element.$.restAPI,
-        'getAccountCapabilities',
-        () => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    sandbox.stub(
-        element.$.restAPI,
-        'getAccount',
-        () => Promise.resolve({_id: 1}));
-    sandbox.stub(element, 'reload');
-    element.params = {repo: 'Test Repo', adminView: 'gr-repo'};
-    assert.equal(element.reload.callCount, 1);
-    element.params = {repo: 'Test Repo 2',
-      adminView: 'gr-repo'};
-    assert.equal(element.reload.callCount, 2);
-  });
-
-  test('Nav is reloaded when group changes', () => {
-    sandbox.stub(element, '_computeGroupName');
-    sandbox.stub(
-        element.$.restAPI,
-        'getAccountCapabilities',
-        () => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    sandbox.stub(
-        element.$.restAPI,
-        'getAccount',
-        () => Promise.resolve({_id: 1}));
-    sandbox.stub(element, 'reload');
-    element.params = {groupId: '1', adminView: 'gr-group'};
-    assert.equal(element.reload.callCount, 1);
-  });
-
-  test('Nav is reloaded when group name changes', done => {
-    const newName = 'newName';
-    sandbox.stub(element, '_computeGroupName');
-    sandbox.stub(element, 'reload', () => {
-      assert.equal(element._groupName, newName);
-      assert.isTrue(element.reload.called);
-      done();
-    });
-    element.params = {group: 1, view: GerritNav.View.GROUP};
-    element._groupName = 'oldName';
-    flushAsynchronousOperations();
-    element.shadowRoot
-        .querySelector('gr-group').dispatchEvent(
-            new CustomEvent('name-changed', {
-              detail: {name: newName},
-              composed: true, bubbles: true,
-            }));
-  });
-
-  test('dropdown displays if there is a subsection', () => {
-    assert.isNotOk(element.shadowRoot
-        .querySelector('.mainHeader'));
-    element._subsectionLinks = [
-      {
-        text: 'Home',
-        value: 'repo',
-        view: 'repo',
-        url: '',
-        parent: 'my-repo',
-        detailType: undefined,
-      },
-    ];
-    flushAsynchronousOperations();
-    assert.isOk(element.shadowRoot
-        .querySelector('.mainHeader'));
-    element._subsectionLinks = undefined;
-    flushAsynchronousOperations();
-    assert.equal(
-        getComputedStyle(element.shadowRoot
-            .querySelector('.mainHeader')).display,
-        'none');
-  });
-
-  test('Dropdown only triggers navigation on explicit select', done => {
-    element._repoName = 'my-repo';
-    element.params = {
-      repo: 'my-repo',
-      view: GerritNav.View.REPO,
-      detail: GerritNav.RepoDetailView.ACCESS,
-    };
-    sandbox.stub(
-        element.$.restAPI,
-        'getAccountCapabilities',
-        () => Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        }));
-    sandbox.stub(
-        element.$.restAPI,
-        'getAccount',
-        () => Promise.resolve({_id: 1}));
-    flushAsynchronousOperations();
-    const expectedFilteredLinks = [
-      {
-        name: 'Repositories',
-        noBaseUrl: true,
-        url: '/admin/repos',
-        view: 'gr-repo-list',
-        viewableToAll: true,
-        subsection: {
-          name: 'my-repo',
-          view: 'repo',
-          url: '',
-          children: [
-            {
-              name: 'Access',
-              view: 'repo',
-              detailType: 'access',
-              url: '',
-            },
-            {
-              name: 'Commands',
-              view: 'repo',
-              detailType: 'commands',
-              url: '',
-            },
-            {
-              name: 'Branches',
-              view: 'repo',
-              detailType: 'branches',
-              url: '',
-            },
-            {
-              name: 'Tags',
-              view: 'repo',
-              detailType: 'tags',
-              url: '',
-            },
-            {
-              name: 'Dashboards',
-              view: 'repo',
-              detailType: 'dashboards',
-              url: '',
-            },
-          ],
-        },
-      },
-      {
-        name: 'Groups',
-        section: 'Groups',
-        noBaseUrl: true,
-        url: '/admin/groups',
-        view: 'gr-admin-group-list',
-      },
-      {
-        name: 'Plugins',
-        capability: 'viewPlugins',
-        section: 'Plugins',
-        noBaseUrl: true,
-        url: '/admin/plugins',
-        view: 'gr-plugin-list',
-      },
-    ];
-    const expectedSubsectionLinks = [
-      {
-        text: 'Home',
-        value: 'repo',
-        view: 'repo',
-        url: '',
-        parent: 'my-repo',
-        detailType: undefined,
-      },
-      {
-        text: 'Access',
-        value: 'repoaccess',
-        view: 'repo',
-        url: '',
-        detailType: 'access',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Commands',
-        value: 'repocommands',
-        view: 'repo',
-        url: '',
-        detailType: 'commands',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Branches',
-        value: 'repobranches',
-        view: 'repo',
-        url: '',
-        detailType: 'branches',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Tags',
-        value: 'repotags',
-        view: 'repo',
-        url: '',
-        detailType: 'tags',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Dashboards',
-        value: 'repodashboards',
-        view: 'repo',
-        url: '',
-        detailType: 'dashboards',
-        parent: 'my-repo',
-      },
-    ];
-    sandbox.stub(GerritNav, 'navigateToRelativeUrl');
-    sandbox.spy(element, '_selectedIsCurrentPage');
-    sandbox.spy(element, '_handleSubsectionChange');
-    element.reload().then(() => {
-      assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
-      assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
-      assert.equal(
-          element.shadowRoot.querySelector('#pageSelect').value,
-          'repoaccess'
-      );
-      assert.isTrue(element._selectedIsCurrentPage.calledOnce);
-      // Doesn't trigger navigation from the page select menu.
-      assert.isFalse(GerritNav.navigateToRelativeUrl.called);
-
-      // When explicitly changed, navigation is called
-      element.shadowRoot.querySelector('#pageSelect').value = 'repo';
-      assert.isTrue(element._selectedIsCurrentPage.calledTwice);
-      assert.isTrue(GerritNav.navigateToRelativeUrl.calledOnce);
-      done();
-    });
-  });
-
-  test('_selectedIsCurrentPage', () => {
-    element._repoName = 'my-repo';
-    element.params = {view: 'repo', repo: 'my-repo'};
-    const selected = {
-      view: 'repo',
-      detailType: undefined,
-      parent: 'my-repo',
-    };
-    assert.isTrue(element._selectedIsCurrentPage(selected));
-    selected.parent = 'my-second-repo';
-    assert.isFalse(element._selectedIsCurrentPage(selected));
-    selected.detailType = 'detailType';
-    assert.isFalse(element._selectedIsCurrentPage(selected));
-  });
-
-  suite('_computeSelectedClass', () => {
-    setup(() => {
-      sandbox.stub(
-          element.$.restAPI,
-          'getAccountCapabilities',
-          () => Promise.resolve({
-            createGroup: true,
-            createProject: true,
-            viewPlugins: true,
-          }));
-      sandbox.stub(
-          element.$.restAPI,
-          'getAccount',
-          () => Promise.resolve({_id: 1}));
-
-      return element.reload();
-    });
-
-    suite('repos', () => {
-      setup(() => {
-        stub('gr-repo-access', {
-          _repoChanged: () => {},
-        });
-      });
-
-      test('repo list', () => {
-        element.params = {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-repo-list',
-          openCreateModal: false,
-        };
-        flushAsynchronousOperations();
-        const selected = element.shadowRoot
-            .querySelector('gr-page-nav .selected');
-        assert.isOk(selected);
-        assert.equal(selected.textContent.trim(), 'Repositories');
-      });
-
-      test('repo', () => {
-        element.params = {
-          view: GerritNav.View.REPO,
-          repoName: 'foo',
-        };
-        element._repoName = 'foo';
-        return element.reload().then(() => {
-          flushAsynchronousOperations();
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'foo');
-        });
-      });
-
-      test('repo access', () => {
-        element.params = {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.ACCESS,
-          repoName: 'foo',
-        };
-        element._repoName = 'foo';
-        return element.reload().then(() => {
-          flushAsynchronousOperations();
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'Access');
-        });
-      });
-
-      test('repo dashboards', () => {
-        element.params = {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.DASHBOARDS,
-          repoName: 'foo',
-        };
-        element._repoName = 'foo';
-        return element.reload().then(() => {
-          flushAsynchronousOperations();
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'Dashboards');
-        });
-      });
-    });
-
-    suite('groups', () => {
-      setup(() => {
-        stub('gr-group', {
-          _loadGroup: () => Promise.resolve({}),
-        });
-        stub('gr-group-members', {
-          _loadGroupDetails: () => {},
-        });
-
-        sandbox.stub(element.$.restAPI, 'getGroupConfig')
-            .returns(Promise.resolve({
-              name: 'foo',
-              id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
-            }));
-        sandbox.stub(element.$.restAPI, 'getIsGroupOwner')
-            .returns(Promise.resolve(true));
-        return element.reload();
-      });
-
-      test('group list', () => {
-        element.params = {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          openCreateModal: false,
-        };
-        flushAsynchronousOperations();
-        const selected = element.shadowRoot
-            .querySelector('gr-page-nav .selected');
-        assert.isOk(selected);
-        assert.equal(selected.textContent.trim(), 'Groups');
-      });
-
-      test('internal group', () => {
-        element.params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-        };
-        element._groupName = 'foo';
-        return element.reload().then(() => {
-          flushAsynchronousOperations();
-          const subsectionItems = dom(element.root)
-              .querySelectorAll('.subsectionItem');
-          assert.equal(subsectionItems.length, 2);
-          assert.isTrue(element._groupIsInternal);
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'foo');
-        });
-      });
-
-      test('external group', () => {
-        element.$.restAPI.getGroupConfig.restore();
-        sandbox.stub(element.$.restAPI, 'getGroupConfig')
-            .returns(Promise.resolve({
-              name: 'foo',
-              id: 'external-id',
-            }));
-        element.params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-        };
-        element._groupName = 'foo';
-        return element.reload().then(() => {
-          flushAsynchronousOperations();
-          const subsectionItems = dom(element.root)
-              .querySelectorAll('.subsectionItem');
-          assert.equal(subsectionItems.length, 0);
-          assert.isFalse(element._groupIsInternal);
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'foo');
-        });
-      });
-
-      test('group members', () => {
-        element.params = {
-          view: GerritNav.View.GROUP,
-          detail: GerritNav.GroupDetailView.MEMBERS,
-          groupId: 1234,
-        };
-        element._groupName = 'foo';
-        return element.reload().then(() => {
-          flushAsynchronousOperations();
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'Members');
-        });
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
new file mode 100644
index 0000000..f8b5abd
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -0,0 +1,664 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-admin-view.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-admin-view');
+
+suite('gr-admin-view tests', () => {
+  let element;
+
+  setup(done => {
+    element = basicFixture.instantiate();
+    stub('gr-rest-api-interface', {
+      getProjectConfig() {
+        return Promise.resolve({});
+      },
+    });
+    const pluginsLoaded = Promise.resolve();
+    sinon.stub(pluginLoader, 'awaitPluginsLoaded').returns(pluginsLoaded);
+    pluginsLoaded.then(() => flush(done));
+  });
+
+  test('_computeURLHelper', () => {
+    const path = '/test';
+    const host = 'http://www.testsite.com';
+    const computedPath = element._computeURLHelper(host, path);
+    assert.equal(computedPath, '//http://www.testsite.com/test');
+  });
+
+  test('link URLs', () => {
+    assert.equal(
+        element._computeLinkURL({url: '/test', noBaseUrl: true}),
+        '//' + window.location.host + '/test');
+
+    stubBaseUrl('/foo');
+    assert.equal(
+        element._computeLinkURL({url: '/test', noBaseUrl: true}),
+        '//' + window.location.host + '/foo/test');
+    assert.equal(element._computeLinkURL({url: '/test'}), '/test');
+    assert.equal(
+        element._computeLinkURL({url: '/test', target: '_blank'}),
+        '/test');
+  });
+
+  test('current page gets selected and is displayed', () => {
+    element._filteredLinks = [{
+      name: 'Repositories',
+      url: '/admin/repos',
+      view: 'gr-repo-list',
+    }];
+
+    element.params = {
+      view: 'admin',
+      adminView: 'gr-repo-list',
+    };
+
+    flushAsynchronousOperations();
+    assert.equal(dom(element.root).querySelectorAll(
+        '.selected').length, 1);
+    assert.ok(element.shadowRoot
+        .querySelector('gr-repo-list'));
+    assert.isNotOk(element.shadowRoot
+        .querySelector('gr-admin-create-repo'));
+  });
+
+  test('_filteredLinks admin', done => {
+    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        })
+        );
+    element.reload().then(() => {
+      assert.equal(element._filteredLinks.length, 3);
+
+      // Repos
+      assert.isNotOk(element._filteredLinks[0].subsection);
+
+      // Groups
+      assert.isNotOk(element._filteredLinks[0].subsection);
+
+      // Plugins
+      assert.isNotOk(element._filteredLinks[0].subsection);
+      done();
+    });
+  });
+
+  test('_filteredLinks non admin authenticated', done => {
+    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => Promise.resolve({})
+        );
+    element.reload().then(() => {
+      assert.equal(element._filteredLinks.length, 2);
+
+      // Repos
+      assert.isNotOk(element._filteredLinks[0].subsection);
+
+      // Groups
+      assert.isNotOk(element._filteredLinks[0].subsection);
+      done();
+    });
+  });
+
+  test('_filteredLinks non admin unathenticated', done => {
+    element.reload().then(() => {
+      assert.equal(element._filteredLinks.length, 1);
+
+      // Repos
+      assert.isNotOk(element._filteredLinks[0].subsection);
+      done();
+    });
+  });
+
+  test('_filteredLinks from plugin', () => {
+    sinon.stub(element.$.jsAPI, 'getAdminMenuLinks').returns([
+      {text: 'internal link text', url: '/internal/link/url'},
+      {text: 'external link text', url: 'http://external/link/url'},
+    ]);
+    return element.reload().then(() => {
+      assert.equal(element._filteredLinks.length, 3);
+      assert.deepEqual(element._filteredLinks[1], {
+        capability: null,
+        url: '/internal/link/url',
+        name: 'internal link text',
+        noBaseUrl: true,
+        view: null,
+        viewableToAll: true,
+        target: null,
+      });
+      assert.deepEqual(element._filteredLinks[2], {
+        capability: null,
+        url: 'http://external/link/url',
+        name: 'external link text',
+        noBaseUrl: false,
+        view: null,
+        viewableToAll: true,
+        target: '_blank',
+      });
+    });
+  });
+
+  test('Repo shows up in nav', done => {
+    element._repoName = 'Test Repo';
+    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    element.reload().then(() => {
+      flushAsynchronousOperations();
+      assert.equal(dom(element.root)
+          .querySelectorAll('.sectionTitle').length, 3);
+      assert.equal(element.shadowRoot
+          .querySelector('.breadcrumbText').innerText, 'Test Repo');
+      assert.equal(
+          element.shadowRoot.querySelector('#pageSelect').items.length,
+          6
+      );
+      done();
+    });
+  });
+
+  test('Group shows up in nav', done => {
+    element._groupId = 'a15262';
+    element._groupName = 'my-group';
+    element._groupIsInternal = true;
+    element._isAdmin = true;
+    element._groupOwner = false;
+    sinon.stub(element.$.restAPI, 'getAccount').returns(Promise.resolve({
+      name: 'test-user',
+    }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    element.reload().then(() => {
+      flushAsynchronousOperations();
+      assert.equal(element._filteredLinks.length, 3);
+
+      // Repos
+      assert.isNotOk(element._filteredLinks[0].subsection);
+
+      // Groups
+      assert.equal(element._filteredLinks[1].subsection.children.length, 2);
+      assert.equal(element._filteredLinks[1].subsection.name, 'my-group');
+
+      // Plugins
+      assert.isNotOk(element._filteredLinks[2].subsection);
+      done();
+    });
+  });
+
+  test('Nav is reloaded when repo changes', () => {
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccount')
+        .callsFake(() => Promise.resolve({_id: 1}));
+    sinon.stub(element, 'reload');
+    element.params = {repo: 'Test Repo', adminView: 'gr-repo'};
+    assert.equal(element.reload.callCount, 1);
+    element.params = {repo: 'Test Repo 2',
+      adminView: 'gr-repo'};
+    assert.equal(element.reload.callCount, 2);
+  });
+
+  test('Nav is reloaded when group changes', () => {
+    sinon.stub(element, '_computeGroupName');
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccount')
+        .callsFake(() => Promise.resolve({_id: 1}));
+    sinon.stub(element, 'reload');
+    element.params = {groupId: '1', adminView: 'gr-group'};
+    assert.equal(element.reload.callCount, 1);
+  });
+
+  test('Nav is reloaded when group name changes', done => {
+    const newName = 'newName';
+    sinon.stub(element, '_computeGroupName');
+    sinon.stub(element, 'reload').callsFake(() => {
+      assert.equal(element._groupName, newName);
+      assert.isTrue(element.reload.called);
+      done();
+    });
+    element.params = {group: 1, view: GerritNav.View.GROUP};
+    element._groupName = 'oldName';
+    flushAsynchronousOperations();
+    element.shadowRoot
+        .querySelector('gr-group').dispatchEvent(
+            new CustomEvent('name-changed', {
+              detail: {name: newName},
+              composed: true, bubbles: true,
+            }));
+  });
+
+  test('dropdown displays if there is a subsection', () => {
+    assert.isNotOk(element.shadowRoot
+        .querySelector('.mainHeader'));
+    element._subsectionLinks = [
+      {
+        text: 'Home',
+        value: 'repo',
+        view: 'repo',
+        url: '',
+        parent: 'my-repo',
+        detailType: undefined,
+      },
+    ];
+    flushAsynchronousOperations();
+    assert.isOk(element.shadowRoot
+        .querySelector('.mainHeader'));
+    element._subsectionLinks = undefined;
+    flushAsynchronousOperations();
+    assert.equal(
+        getComputedStyle(element.shadowRoot
+            .querySelector('.mainHeader')).display,
+        'none');
+  });
+
+  test('Dropdown only triggers navigation on explicit select', done => {
+    element._repoName = 'my-repo';
+    element.params = {
+      repo: 'my-repo',
+      view: GerritNav.View.REPO,
+      detail: GerritNav.RepoDetailView.ACCESS,
+    };
+    sinon.stub(
+        element.$.restAPI,
+        'getAccountCapabilities')
+        .callsFake(() => Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        }));
+    sinon.stub(
+        element.$.restAPI,
+        'getAccount')
+        .callsFake(() => Promise.resolve({_id: 1}));
+    flushAsynchronousOperations();
+    const expectedFilteredLinks = [
+      {
+        name: 'Repositories',
+        noBaseUrl: true,
+        url: '/admin/repos',
+        view: 'gr-repo-list',
+        viewableToAll: true,
+        subsection: {
+          name: 'my-repo',
+          view: 'repo',
+          url: '',
+          children: [
+            {
+              name: 'Access',
+              view: 'repo',
+              detailType: 'access',
+              url: '',
+            },
+            {
+              name: 'Commands',
+              view: 'repo',
+              detailType: 'commands',
+              url: '',
+            },
+            {
+              name: 'Branches',
+              view: 'repo',
+              detailType: 'branches',
+              url: '',
+            },
+            {
+              name: 'Tags',
+              view: 'repo',
+              detailType: 'tags',
+              url: '',
+            },
+            {
+              name: 'Dashboards',
+              view: 'repo',
+              detailType: 'dashboards',
+              url: '',
+            },
+          ],
+        },
+      },
+      {
+        name: 'Groups',
+        section: 'Groups',
+        noBaseUrl: true,
+        url: '/admin/groups',
+        view: 'gr-admin-group-list',
+      },
+      {
+        name: 'Plugins',
+        capability: 'viewPlugins',
+        section: 'Plugins',
+        noBaseUrl: true,
+        url: '/admin/plugins',
+        view: 'gr-plugin-list',
+      },
+    ];
+    const expectedSubsectionLinks = [
+      {
+        text: 'Home',
+        value: 'repo',
+        view: 'repo',
+        url: '',
+        parent: 'my-repo',
+        detailType: undefined,
+      },
+      {
+        text: 'Access',
+        value: 'repoaccess',
+        view: 'repo',
+        url: '',
+        detailType: 'access',
+        parent: 'my-repo',
+      },
+      {
+        text: 'Commands',
+        value: 'repocommands',
+        view: 'repo',
+        url: '',
+        detailType: 'commands',
+        parent: 'my-repo',
+      },
+      {
+        text: 'Branches',
+        value: 'repobranches',
+        view: 'repo',
+        url: '',
+        detailType: 'branches',
+        parent: 'my-repo',
+      },
+      {
+        text: 'Tags',
+        value: 'repotags',
+        view: 'repo',
+        url: '',
+        detailType: 'tags',
+        parent: 'my-repo',
+      },
+      {
+        text: 'Dashboards',
+        value: 'repodashboards',
+        view: 'repo',
+        url: '',
+        detailType: 'dashboards',
+        parent: 'my-repo',
+      },
+    ];
+    sinon.stub(GerritNav, 'navigateToRelativeUrl');
+    sinon.spy(element, '_selectedIsCurrentPage');
+    sinon.spy(element, '_handleSubsectionChange');
+    element.reload().then(() => {
+      assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
+      assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
+      assert.equal(
+          element.shadowRoot.querySelector('#pageSelect').value,
+          'repoaccess'
+      );
+      assert.isTrue(element._selectedIsCurrentPage.calledOnce);
+      // Doesn't trigger navigation from the page select menu.
+      assert.isFalse(GerritNav.navigateToRelativeUrl.called);
+
+      // When explicitly changed, navigation is called
+      element.shadowRoot.querySelector('#pageSelect').value = 'repo';
+      assert.isTrue(element._selectedIsCurrentPage.calledTwice);
+      assert.isTrue(GerritNav.navigateToRelativeUrl.calledOnce);
+      done();
+    });
+  });
+
+  test('_selectedIsCurrentPage', () => {
+    element._repoName = 'my-repo';
+    element.params = {view: 'repo', repo: 'my-repo'};
+    const selected = {
+      view: 'repo',
+      detailType: undefined,
+      parent: 'my-repo',
+    };
+    assert.isTrue(element._selectedIsCurrentPage(selected));
+    selected.parent = 'my-second-repo';
+    assert.isFalse(element._selectedIsCurrentPage(selected));
+    selected.detailType = 'detailType';
+    assert.isFalse(element._selectedIsCurrentPage(selected));
+  });
+
+  suite('_computeSelectedClass', () => {
+    setup(() => {
+      sinon.stub(
+          element.$.restAPI,
+          'getAccountCapabilities')
+          .callsFake(() => Promise.resolve({
+            createGroup: true,
+            createProject: true,
+            viewPlugins: true,
+          }));
+      sinon.stub(
+          element.$.restAPI,
+          'getAccount')
+          .callsFake(() => Promise.resolve({_id: 1}));
+
+      return element.reload();
+    });
+
+    suite('repos', () => {
+      setup(() => {
+        stub('gr-repo-access', {
+          _repoChanged: () => {},
+        });
+      });
+
+      test('repo list', () => {
+        element.params = {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-repo-list',
+          openCreateModal: false,
+        };
+        flushAsynchronousOperations();
+        const selected = element.shadowRoot
+            .querySelector('gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent.trim(), 'Repositories');
+      });
+
+      test('repo', () => {
+        element.params = {
+          view: GerritNav.View.REPO,
+          repoName: 'foo',
+        };
+        element._repoName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'foo');
+        });
+      });
+
+      test('repo access', () => {
+        element.params = {
+          view: GerritNav.View.REPO,
+          detail: GerritNav.RepoDetailView.ACCESS,
+          repoName: 'foo',
+        };
+        element._repoName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'Access');
+        });
+      });
+
+      test('repo dashboards', () => {
+        element.params = {
+          view: GerritNav.View.REPO,
+          detail: GerritNav.RepoDetailView.DASHBOARDS,
+          repoName: 'foo',
+        };
+        element._repoName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'Dashboards');
+        });
+      });
+    });
+
+    suite('groups', () => {
+      setup(() => {
+        stub('gr-group', {
+          _loadGroup: () => Promise.resolve({}),
+        });
+        stub('gr-group-members', {
+          _loadGroupDetails: () => {},
+        });
+
+        sinon.stub(element.$.restAPI, 'getGroupConfig')
+            .returns(Promise.resolve({
+              name: 'foo',
+              id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
+            }));
+        sinon.stub(element.$.restAPI, 'getIsGroupOwner')
+            .returns(Promise.resolve(true));
+        return element.reload();
+      });
+
+      test('group list', () => {
+        element.params = {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          openCreateModal: false,
+        };
+        flushAsynchronousOperations();
+        const selected = element.shadowRoot
+            .querySelector('gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent.trim(), 'Groups');
+      });
+
+      test('internal group', () => {
+        element.params = {
+          view: GerritNav.View.GROUP,
+          groupId: 1234,
+        };
+        element._groupName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const subsectionItems = dom(element.root)
+              .querySelectorAll('.subsectionItem');
+          assert.equal(subsectionItems.length, 2);
+          assert.isTrue(element._groupIsInternal);
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'foo');
+        });
+      });
+
+      test('external group', () => {
+        element.$.restAPI.getGroupConfig.restore();
+        sinon.stub(element.$.restAPI, 'getGroupConfig')
+            .returns(Promise.resolve({
+              name: 'foo',
+              id: 'external-id',
+            }));
+        element.params = {
+          view: GerritNav.View.GROUP,
+          groupId: 1234,
+        };
+        element._groupName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const subsectionItems = dom(element.root)
+              .querySelectorAll('.subsectionItem');
+          assert.equal(subsectionItems.length, 0);
+          assert.isFalse(element._groupIsInternal);
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'foo');
+        });
+      });
+
+      test('group members', () => {
+        element.params = {
+          view: GerritNav.View.GROUP,
+          detail: GerritNav.GroupDetailView.MEMBERS,
+          groupId: 1234,
+        };
+        element._groupName = 'foo';
+        return element.reload().then(() => {
+          flushAsynchronousOperations();
+          const selected = element.shadowRoot
+              .querySelector('gr-page-nav .selected');
+          assert.isOk(selected);
+          assert.equal(selected.textContent.trim(), 'Members');
+        });
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
index b6b21bd..288af4c 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-dialog/gr-dialog.js';
 import '../../../styles/shared-styles.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
@@ -30,7 +28,7 @@
 };
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrConfirmDeleteItemDialog extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.js
deleted file mode 100644
index 3810d32..0000000
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      width: 30em;
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Delete [[_computeItemName(itemType)]]"
-    confirm-on-enter=""
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">
-      [[_computeItemName(itemType)]] Deletion
-    </div>
-    <div class="main" slot="main">
-      <label for="branchInput">
-        Do you really want to delete the following
-        [[_computeItemName(itemType)]]?
-      </label>
-      <div>
-        [[item]]
-      </div>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts
new file mode 100644
index 0000000..ce9ac9c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      width: 30em;
+    }
+  </style>
+  <gr-dialog
+    confirm-label="Delete [[_computeItemName(itemType)]]"
+    confirm-on-enter=""
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header" slot="header">
+      [[_computeItemName(itemType)]] Deletion
+    </div>
+    <div class="main" slot="main">
+      <label for="branchInput">
+        Do you really want to delete the following
+        [[_computeItemName(itemType)]]?
+      </label>
+      <div>
+        [[item]]
+      </div>
+    </div>
+  </gr-dialog>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
deleted file mode 100644
index 003edfb..0000000
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
+++ /dev/null
@@ -1,90 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-confirm-delete-item-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-confirm-delete-item-dialog></gr-confirm-delete-item-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-confirm-delete-item-dialog.js';
-suite('gr-confirm-delete-item-dialog tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_handleConfirmTap', () => {
-    const confirmHandler = sandbox.stub();
-    element.addEventListener('confirm', confirmHandler);
-    sandbox.spy(element, '_handleConfirmTap');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('confirm', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(confirmHandler.called);
-    assert.isTrue(confirmHandler.calledOnce);
-    assert.isTrue(element._handleConfirmTap.called);
-    assert.isTrue(element._handleConfirmTap.calledOnce);
-  });
-
-  test('_handleCancelTap', () => {
-    const cancelHandler = sandbox.stub();
-    element.addEventListener('cancel', cancelHandler);
-    sandbox.spy(element, '_handleCancelTap');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(cancelHandler.called);
-    assert.isTrue(cancelHandler.calledOnce);
-    assert.isTrue(element._handleCancelTap.called);
-    assert.isTrue(element._handleCancelTap.calledOnce);
-  });
-
-  test('_computeItemName function for branches', () => {
-    assert.deepEqual(element._computeItemName('branches'), 'Branch');
-    assert.notEqual(element._computeItemName('branches'), 'Tag');
-  });
-
-  test('_computeItemName function for tags', () => {
-    assert.deepEqual(element._computeItemName('tags'), 'Tag');
-    assert.notEqual(element._computeItemName('tags'), 'Branch');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.js
new file mode 100644
index 0000000..485a48b
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.js
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-delete-item-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-delete-item-dialog');
+
+suite('gr-confirm-delete-item-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sinon.stub();
+    element.addEventListener('confirm', confirmHandler);
+    sinon.spy(element, '_handleConfirmTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').dispatchEvent(
+            new CustomEvent('confirm', {
+              composed: true, bubbles: true,
+            }));
+    assert.isTrue(confirmHandler.called);
+    assert.isTrue(confirmHandler.calledOnce);
+    assert.isTrue(element._handleConfirmTap.called);
+    assert.isTrue(element._handleConfirmTap.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sinon.stub();
+    element.addEventListener('cancel', cancelHandler);
+    sinon.spy(element, '_handleCancelTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').dispatchEvent(
+            new CustomEvent('cancel', {
+              composed: true, bubbles: true,
+            }));
+    assert.isTrue(cancelHandler.called);
+    assert.isTrue(cancelHandler.calledOnce);
+    assert.isTrue(element._handleCancelTap.called);
+    assert.isTrue(element._handleCancelTap.calledOnce);
+  });
+
+  test('_computeItemName function for branches', () => {
+    assert.deepEqual(element._computeItemName('branches'), 'Branch');
+    assert.notEqual(element._computeItemName('branches'), 'Tag');
+  });
+
+  test('_computeItemName function for tags', () => {
+    assert.deepEqual(element._computeItemName('tags'), 'Tag');
+    assert.notEqual(element._computeItemName('tags'), 'Branch');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
index 3347655..c0b71c0 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
@@ -14,37 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-
 import '@polymer/iron-input/iron-input.js';
-import '../../../scripts/bundled-polymer.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-autocomplete/gr-autocomplete.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../shared/gr-select/gr-select.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-create-change-dialog_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrCreateChangeDialog extends mixinBehaviors( [
-  BaseUrlBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
+class GrCreateChangeDialog extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-create-change-dialog'; }
@@ -152,11 +143,7 @@
     } else if (config && config.configured_value === 'FALSE') {
       return false;
     } else if (config && config.configured_value === 'INHERIT') {
-      if (config && config.inherited_value) {
-        return true;
-      } else {
-        return false;
-      }
+      return !!(config && config.inherited_value);
     } else {
       return false;
     }
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.js
deleted file mode 100644
index f18da81..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.js
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    input:not([type='checkbox']),
-    gr-autocomplete,
-    iron-autogrow-textarea {
-      width: 100%;
-    }
-    .value {
-      width: 32em;
-    }
-    .hide {
-      display: none;
-    }
-    @media only screen and (max-width: 40em) {
-      .value {
-        width: 29em;
-      }
-    }
-  </style>
-  <div class="gr-form-styles">
-    <section class$="[[_computeBranchClass(baseChange)]]">
-      <span class="title">Select branch for new change</span>
-      <span class="value">
-        <gr-autocomplete
-          id="branchInput"
-          text="{{branch}}"
-          query="[[_query]]"
-          placeholder="Destination branch"
-        >
-        </gr-autocomplete>
-      </span>
-    </section>
-    <section class$="[[_computeBranchClass(baseChange)]]">
-      <span class="title">Provide base commit sha1 for change</span>
-      <span class="value">
-        <iron-input
-          maxlength="40"
-          placeholder="(optional)"
-          bind-value="{{baseCommit}}"
-        >
-          <input
-            is="iron-input"
-            id="baseCommitInput"
-            maxlength="40"
-            placeholder="(optional)"
-            bind-value="{{baseCommit}}"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <span class="title">Enter topic for new change</span>
-      <span class="value">
-        <iron-input
-          maxlength="1024"
-          placeholder="(optional)"
-          bind-value="{{topic}}"
-        >
-          <input
-            is="iron-input"
-            id="tagNameInput"
-            maxlength="1024"
-            placeholder="(optional)"
-            bind-value="{{topic}}"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section id="description">
-      <span class="title">Description</span>
-      <span class="value">
-        <iron-autogrow-textarea
-          id="messageInput"
-          class="message"
-          autocomplete="on"
-          rows="4"
-          max-rows="15"
-          bind-value="{{subject}}"
-          placeholder="Insert the description of the change."
-        >
-        </iron-autogrow-textarea>
-      </span>
-    </section>
-    <section class$="[[_computePrivateSectionClass(_privateChangesEnabled)]]">
-      <label class="title" for="privateChangeCheckBox">Private change</label>
-      <span class="value">
-        <input
-          type="checkbox"
-          id="privateChangeCheckBox"
-          checked$="[[_formatBooleanString(privateByDefault)]]"
-        />
-      </span>
-    </section>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
new file mode 100644
index 0000000..77e2c3b
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    input:not([type='checkbox']),
+    gr-autocomplete,
+    iron-autogrow-textarea {
+      width: 100%;
+    }
+    .value {
+      width: 32em;
+    }
+    .hide {
+      display: none;
+    }
+    @media only screen and (max-width: 40em) {
+      .value {
+        width: 29em;
+      }
+    }
+  </style>
+  <div class="gr-form-styles">
+    <section class$="[[_computeBranchClass(baseChange)]]">
+      <span class="title">Select branch for new change</span>
+      <span class="value">
+        <gr-autocomplete
+          id="branchInput"
+          text="{{branch}}"
+          query="[[_query]]"
+          placeholder="Destination branch"
+        >
+        </gr-autocomplete>
+      </span>
+    </section>
+    <section class$="[[_computeBranchClass(baseChange)]]">
+      <span class="title">Provide base commit sha1 for change</span>
+      <span class="value">
+        <iron-input
+          maxlength="40"
+          placeholder="(optional)"
+          bind-value="{{baseCommit}}"
+        >
+          <input
+            is="iron-input"
+            id="baseCommitInput"
+            maxlength="40"
+            placeholder="(optional)"
+            bind-value="{{baseCommit}}"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Enter topic for new change</span>
+      <span class="value">
+        <iron-input
+          maxlength="1024"
+          placeholder="(optional)"
+          bind-value="{{topic}}"
+        >
+          <input
+            is="iron-input"
+            id="tagNameInput"
+            maxlength="1024"
+            placeholder="(optional)"
+            bind-value="{{topic}}"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section id="description">
+      <span class="title">Description</span>
+      <span class="value">
+        <iron-autogrow-textarea
+          id="messageInput"
+          class="message"
+          autocomplete="on"
+          rows="4"
+          max-rows="15"
+          bind-value="{{subject}}"
+          placeholder="Insert the description of the change."
+        >
+        </iron-autogrow-textarea>
+      </span>
+    </section>
+    <section class$="[[_computePrivateSectionClass(_privateChangesEnabled)]]">
+      <label class="title" for="privateChangeCheckBox">Private change</label>
+      <span class="value">
+        <input
+          type="checkbox"
+          id="privateChangeCheckBox"
+          checked$="[[_formatBooleanString(privateByDefault)]]"
+        />
+      </span>
+    </section>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
deleted file mode 100644
index 87105a7..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
+++ /dev/null
@@ -1,167 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-create-change-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-create-change-dialog></gr-create-change-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-create-change-dialog.js';
-suite('gr-create-change-dialog tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-      getRepoBranches(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              ref: 'refs/heads/test-branch',
-              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-              can_delete: true,
-            },
-          ]);
-        } else {
-          return Promise.resolve({});
-        }
-      },
-    });
-    element = fixture('basic');
-    element.repoName = 'test-repo',
-    element._repoConfig = {
-      private_by_default: {
-        configured_value: 'FALSE',
-        inherited_value: false,
-      },
-    };
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('new change created with default', done => {
-    const configInputObj = {
-      branch: 'test-branch',
-      subject: 'first change created with polygerrit ui',
-      topic: 'test-topic',
-      is_private: false,
-      work_in_progress: true,
-    };
-
-    const saveStub = sandbox.stub(element.$.restAPI,
-        'createChange', () => Promise.resolve({}));
-
-    element.branch = 'test-branch';
-    element.topic = 'test-topic';
-    element.subject = 'first change created with polygerrit ui';
-    assert.isFalse(element.$.privateChangeCheckBox.checked);
-
-    element.$.branchInput.bindValue = configInputObj.branch;
-    element.$.tagNameInput.bindValue = configInputObj.topic;
-    element.$.messageInput.bindValue = configInputObj.subject;
-
-    element.handleCreateChange().then(() => {
-      // Private change
-      assert.isFalse(saveStub.lastCall.args[4]);
-      // WIP Change
-      assert.isTrue(saveStub.lastCall.args[5]);
-      assert.isTrue(saveStub.called);
-      done();
-    });
-  });
-
-  test('new change created with private', done => {
-    element.privateByDefault = {
-      configured_value: 'TRUE',
-      inherited_value: false,
-    };
-    sandbox.stub(element, '_formatBooleanString', () => Promise.resolve(true));
-    flushAsynchronousOperations();
-
-    const configInputObj = {
-      branch: 'test-branch',
-      subject: 'first change created with polygerrit ui',
-      topic: 'test-topic',
-      is_private: true,
-      work_in_progress: true,
-    };
-
-    const saveStub = sandbox.stub(element.$.restAPI,
-        'createChange', () => Promise.resolve({}));
-
-    element.branch = 'test-branch';
-    element.topic = 'test-topic';
-    element.subject = 'first change created with polygerrit ui';
-    assert.isTrue(element.$.privateChangeCheckBox.checked);
-
-    element.$.branchInput.bindValue = configInputObj.branch;
-    element.$.tagNameInput.bindValue = configInputObj.topic;
-    element.$.messageInput.bindValue = configInputObj.subject;
-
-    element.handleCreateChange().then(() => {
-      // Private change
-      assert.isTrue(saveStub.lastCall.args[4]);
-      // WIP Change
-      assert.isTrue(saveStub.lastCall.args[5]);
-      assert.isTrue(saveStub.called);
-      done();
-    });
-  });
-
-  test('_getRepoBranchesSuggestions empty', done => {
-    element._getRepoBranchesSuggestions('nonexistent').then(branches => {
-      assert.equal(branches.length, 0);
-      done();
-    });
-  });
-
-  test('_getRepoBranchesSuggestions non-empty', done => {
-    element._getRepoBranchesSuggestions('test-branch').then(branches => {
-      assert.equal(branches.length, 1);
-      assert.equal(branches[0].name, 'test-branch');
-      done();
-    });
-  });
-
-  test('_computeBranchClass', () => {
-    assert.equal(element._computeBranchClass(true), 'hide');
-    assert.equal(element._computeBranchClass(false), '');
-  });
-
-  test('_computePrivateSectionClass', () => {
-    assert.equal(element._computePrivateSectionClass(true), 'hide');
-    assert.equal(element._computePrivateSectionClass(false), '');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.js
new file mode 100644
index 0000000..07eee42
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.js
@@ -0,0 +1,148 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-create-change-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-create-change-dialog');
+
+suite('gr-create-change-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+      getRepoBranches(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve([
+            {
+              ref: 'refs/heads/test-branch',
+              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+              can_delete: true,
+            },
+          ]);
+        } else {
+          return Promise.resolve({});
+        }
+      },
+    });
+    element = basicFixture.instantiate();
+    element.repoName = 'test-repo';
+    element._repoConfig = {
+      private_by_default: {
+        configured_value: 'FALSE',
+        inherited_value: false,
+      },
+    };
+  });
+
+  test('new change created with default', done => {
+    const configInputObj = {
+      branch: 'test-branch',
+      subject: 'first change created with polygerrit ui',
+      topic: 'test-topic',
+      is_private: false,
+      work_in_progress: true,
+    };
+
+    const saveStub = sinon.stub(element.$.restAPI,
+        'createChange').callsFake(() => Promise.resolve({}));
+
+    element.branch = 'test-branch';
+    element.topic = 'test-topic';
+    element.subject = 'first change created with polygerrit ui';
+    assert.isFalse(element.$.privateChangeCheckBox.checked);
+
+    element.$.branchInput.bindValue = configInputObj.branch;
+    element.$.tagNameInput.bindValue = configInputObj.topic;
+    element.$.messageInput.bindValue = configInputObj.subject;
+
+    element.handleCreateChange().then(() => {
+      // Private change
+      assert.isFalse(saveStub.lastCall.args[4]);
+      // WIP Change
+      assert.isTrue(saveStub.lastCall.args[5]);
+      assert.isTrue(saveStub.called);
+      done();
+    });
+  });
+
+  test('new change created with private', done => {
+    element.privateByDefault = {
+      configured_value: 'TRUE',
+      inherited_value: false,
+    };
+    sinon.stub(element, '_formatBooleanString')
+        .callsFake(() => Promise.resolve(true));
+    flushAsynchronousOperations();
+
+    const configInputObj = {
+      branch: 'test-branch',
+      subject: 'first change created with polygerrit ui',
+      topic: 'test-topic',
+      is_private: true,
+      work_in_progress: true,
+    };
+
+    const saveStub = sinon.stub(element.$.restAPI,
+        'createChange').callsFake(() => Promise.resolve({}));
+
+    element.branch = 'test-branch';
+    element.topic = 'test-topic';
+    element.subject = 'first change created with polygerrit ui';
+    assert.isTrue(element.$.privateChangeCheckBox.checked);
+
+    element.$.branchInput.bindValue = configInputObj.branch;
+    element.$.tagNameInput.bindValue = configInputObj.topic;
+    element.$.messageInput.bindValue = configInputObj.subject;
+
+    element.handleCreateChange().then(() => {
+      // Private change
+      assert.isTrue(saveStub.lastCall.args[4]);
+      // WIP Change
+      assert.isTrue(saveStub.lastCall.args[5]);
+      assert.isTrue(saveStub.called);
+      done();
+    });
+  });
+
+  test('_getRepoBranchesSuggestions empty', done => {
+    element._getRepoBranchesSuggestions('nonexistent').then(branches => {
+      assert.equal(branches.length, 0);
+      done();
+    });
+  });
+
+  test('_getRepoBranchesSuggestions non-empty', done => {
+    element._getRepoBranchesSuggestions('test-branch').then(branches => {
+      assert.equal(branches.length, 1);
+      assert.equal(branches[0].name, 'test-branch');
+      done();
+    });
+  });
+
+  test('_computeBranchClass', () => {
+    assert.equal(element._computeBranchClass(true), 'hide');
+    assert.equal(element._computeBranchClass(false), '');
+  });
+
+  test('_computePrivateSectionClass', () => {
+    assert.equal(element._computePrivateSectionClass(true), 'hide');
+    assert.equal(element._computePrivateSectionClass(false), '');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
index b21bdde..85a76f1 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
@@ -14,30 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-create-group-dialog_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util.js';
 import page from 'page/page.mjs';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrCreateGroupDialog extends mixinBehaviors( [
-  BaseUrlBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
+class GrCreateGroupDialog extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-create-group-dialog'; }
@@ -65,8 +58,7 @@
   }
 
   _computeGroupUrl(groupId) {
-    return this.getBaseUrl() + '/admin/groups/' +
-        this.encodeURL(groupId, true);
+    return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
   }
 
   _updateGroupName(name) {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.js
deleted file mode 100644
index bc1f24c..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    :host {
-      display: inline-block;
-    }
-    input {
-      width: 20em;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <div id="form">
-      <section>
-        <span class="title">Group name</span>
-        <iron-input bind-value="{{_name}}">
-          <input is="iron-input" bind-value="{{_name}}" />
-        </iron-input>
-      </section>
-    </div>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
new file mode 100644
index 0000000..d4ecc5d
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    :host {
+      display: inline-block;
+    }
+    input {
+      width: 20em;
+    }
+  </style>
+  <div class="gr-form-styles">
+    <div id="form">
+      <section>
+        <span class="title">Group name</span>
+        <iron-input bind-value="{{_name}}">
+          <input is="iron-input" bind-value="{{_name}}" />
+        </iron-input>
+      </section>
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
deleted file mode 100644
index 164db53..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
+++ /dev/null
@@ -1,100 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-create-group-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-create-group-dialog></gr-create-group-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-create-group-dialog.js';
-import page from 'page/page.mjs';
-
-suite('gr-create-group-dialog tests', () => {
-  let element;
-  let sandbox;
-  const GROUP_NAME = 'test-group';
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('name is updated correctly', done => {
-    assert.isFalse(element.hasNewGroupName);
-
-    const inputEl = element.root.querySelector('iron-input');
-    inputEl.bindValue = GROUP_NAME;
-
-    setTimeout(() => {
-      assert.isTrue(element.hasNewGroupName);
-      assert.deepEqual(element._name, GROUP_NAME);
-      done();
-    });
-  });
-
-  test('test for redirecting to group on successful creation', done => {
-    sandbox.stub(element.$.restAPI, 'createGroup')
-        .returns(Promise.resolve({status: 201}));
-
-    sandbox.stub(element.$.restAPI, 'getGroupConfig')
-        .returns(Promise.resolve({group_id: 551}));
-
-    const showStub = sandbox.stub(page, 'show');
-    element.handleCreateGroup()
-        .then(() => {
-          assert.isTrue(showStub.calledWith('/admin/groups/551'));
-          done();
-        });
-  });
-
-  test('test for unsuccessful group creation', done => {
-    sandbox.stub(element.$.restAPI, 'createGroup')
-        .returns(Promise.resolve({status: 409}));
-
-    sandbox.stub(element.$.restAPI, 'getGroupConfig')
-        .returns(Promise.resolve({group_id: 551}));
-
-    const showStub = sandbox.stub(page, 'show');
-    element.handleCreateGroup()
-        .then(() => {
-          assert.isFalse(showStub.called);
-          done();
-        });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
new file mode 100644
index 0000000..d9bc500
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
@@ -0,0 +1,79 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-create-group-dialog.js';
+import page from 'page/page.mjs';
+
+const basicFixture = fixtureFromElement('gr-create-group-dialog');
+
+suite('gr-create-group-dialog tests', () => {
+  let element;
+
+  const GROUP_NAME = 'test-group';
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('name is updated correctly', done => {
+    assert.isFalse(element.hasNewGroupName);
+
+    const inputEl = element.root.querySelector('iron-input');
+    inputEl.bindValue = GROUP_NAME;
+
+    setTimeout(() => {
+      assert.isTrue(element.hasNewGroupName);
+      assert.deepEqual(element._name, GROUP_NAME);
+      done();
+    });
+  });
+
+  test('test for redirecting to group on successful creation', done => {
+    sinon.stub(element.$.restAPI, 'createGroup')
+        .returns(Promise.resolve({status: 201}));
+
+    sinon.stub(element.$.restAPI, 'getGroupConfig')
+        .returns(Promise.resolve({group_id: 551}));
+
+    const showStub = sinon.stub(page, 'show');
+    element.handleCreateGroup()
+        .then(() => {
+          assert.isTrue(showStub.calledWith('/admin/groups/551'));
+          done();
+        });
+  });
+
+  test('test for unsuccessful group creation', done => {
+    sinon.stub(element.$.restAPI, 'createGroup')
+        .returns(Promise.resolve({status: 409}));
+
+    sinon.stub(element.$.restAPI, 'getGroupConfig')
+        .returns(Promise.resolve({group_id: 551}));
+
+    const showStub = sinon.stub(page, 'show');
+    element.handleCreateGroup()
+        .then(() => {
+          assert.isFalse(showStub.called);
+          done();
+        });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
index 72a6b99..3b9176c 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
@@ -14,21 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../shared/gr-select/gr-select.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-create-pointer-dialog_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util.js';
 import page from 'page/page.mjs';
 
 const DETAIL_TYPES = {
@@ -37,14 +33,11 @@
 };
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrCreatePointerDialog extends mixinBehaviors( [
-  BaseUrlBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
+class GrCreatePointerDialog extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-create-pointer-dialog'; }
@@ -77,11 +70,11 @@
 
   _computeItemUrl(project) {
     if (this.itemDetail === DETAIL_TYPES.branches) {
-      return this.getBaseUrl() + '/admin/repos/' +
-          this.encodeURL(this.repoName, true) + ',branches';
+      return getBaseUrl() + '/admin/repos/' +
+          encodeURL(this.repoName, true) + ',branches';
     } else if (this.itemDetail === DETAIL_TYPES.tags) {
-      return this.getBaseUrl() + '/admin/repos/' +
-          this.encodeURL(this.repoName, true) + ',tags';
+      return getBaseUrl() + '/admin/repos/' +
+          encodeURL(this.repoName, true) + ',tags';
     }
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.js
deleted file mode 100644
index 62a2e0f..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.js
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    :host {
-      display: inline-block;
-    }
-    input {
-      width: 20em;
-    }
-    /* Add css selector with #id to increase priority
-      (otherwise ".gr-form-styles section" rule wins) */
-    .hideItem,
-    #itemAnnotationSection.hideItem {
-      display: none;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <div id="form">
-      <section id="itemNameSection">
-        <span class="title">[[detailType]] name</span>
-        <iron-input
-          placeholder="[[detailType]] Name"
-          bind-value="{{_itemName}}"
-        >
-          <input
-            is="iron-input"
-            placeholder="[[detailType]] Name"
-            bind-value="{{_itemName}}"
-          />
-        </iron-input>
-      </section>
-      <section id="itemRevisionSection">
-        <span class="title">Initial Revision</span>
-        <iron-input
-          placeholder="Revision (Branch or SHA-1)"
-          bind-value="{{_itemRevision}}"
-        >
-          <input
-            is="iron-input"
-            placeholder="Revision (Branch or SHA-1)"
-            bind-value="{{_itemRevision}}"
-          />
-        </iron-input>
-      </section>
-      <section
-        id="itemAnnotationSection"
-        class$="[[_computeHideItemClass(itemDetail)]]"
-      >
-        <span class="title">Annotation</span>
-        <iron-input
-          placeholder="Annotation (Optional)"
-          bind-value="{{_itemAnnotation}}"
-        >
-          <input
-            is="iron-input"
-            placeholder="Annotation (Optional)"
-            bind-value="{{_itemAnnotation}}"
-          />
-        </iron-input>
-      </section>
-    </div>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
new file mode 100644
index 0000000..0b3d81ae
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    :host {
+      display: inline-block;
+    }
+    input {
+      width: 20em;
+    }
+    /* Add css selector with #id to increase priority
+      (otherwise ".gr-form-styles section" rule wins) */
+    .hideItem,
+    #itemAnnotationSection.hideItem {
+      display: none;
+    }
+  </style>
+  <div class="gr-form-styles">
+    <div id="form">
+      <section id="itemNameSection">
+        <span class="title">[[detailType]] name</span>
+        <iron-input
+          placeholder="[[detailType]] Name"
+          bind-value="{{_itemName}}"
+        >
+          <input
+            is="iron-input"
+            placeholder="[[detailType]] Name"
+            bind-value="{{_itemName}}"
+          />
+        </iron-input>
+      </section>
+      <section id="itemRevisionSection">
+        <span class="title">Initial Revision</span>
+        <iron-input
+          placeholder="Revision (Branch or SHA-1)"
+          bind-value="{{_itemRevision}}"
+        >
+          <input
+            is="iron-input"
+            placeholder="Revision (Branch or SHA-1)"
+            bind-value="{{_itemRevision}}"
+          />
+        </iron-input>
+      </section>
+      <section
+        id="itemAnnotationSection"
+        class$="[[_computeHideItemClass(itemDetail)]]"
+      >
+        <span class="title">Annotation</span>
+        <iron-input
+          placeholder="Annotation (Optional)"
+          bind-value="{{_itemAnnotation}}"
+        >
+          <input
+            is="iron-input"
+            placeholder="Annotation (Optional)"
+            bind-value="{{_itemAnnotation}}"
+          />
+        </iron-input>
+      </section>
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
deleted file mode 100644
index 2778d40..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
+++ /dev/null
@@ -1,135 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-create-pointer-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-create-pointer-dialog></gr-create-pointer-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-create-pointer-dialog.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-create-pointer-dialog tests', () => {
-  let element;
-  let sandbox;
-
-  const ironInput = function(element) {
-    return dom(element).querySelector('iron-input');
-  };
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('branch created', done => {
-    sandbox.stub(
-        element.$.restAPI,
-        'createRepoBranch',
-        () => Promise.resolve({}));
-
-    assert.isFalse(element.hasNewItemName);
-
-    element._itemName = 'test-branch';
-    element.itemDetail = 'branches';
-
-    ironInput(element.$.itemNameSection).bindValue = 'test-branch2';
-    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
-
-    setTimeout(() => {
-      assert.isTrue(element.hasNewItemName);
-      assert.equal(element._itemName, 'test-branch2');
-      assert.equal(element._itemRevision, 'HEAD');
-      done();
-    });
-  });
-
-  test('tag created', done => {
-    sandbox.stub(
-        element.$.restAPI,
-        'createRepoTag',
-        () => Promise.resolve({}));
-
-    assert.isFalse(element.hasNewItemName);
-
-    element._itemName = 'test-tag';
-    element.itemDetail = 'tags';
-
-    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
-    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
-
-    setTimeout(() => {
-      assert.isTrue(element.hasNewItemName);
-      assert.equal(element._itemName, 'test-tag2');
-      assert.equal(element._itemRevision, 'HEAD');
-      done();
-    });
-  });
-
-  test('tag created with annotations', done => {
-    sandbox.stub(
-        element.$.restAPI,
-        'createRepoTag',
-        () => Promise.resolve({}));
-
-    assert.isFalse(element.hasNewItemName);
-
-    element._itemName = 'test-tag';
-    element._itemAnnotation = 'test-message';
-    element.itemDetail = 'tags';
-
-    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
-    ironInput(element.$.itemAnnotationSection).bindValue = 'test-message2';
-    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
-
-    setTimeout(() => {
-      assert.isTrue(element.hasNewItemName);
-      assert.equal(element._itemName, 'test-tag2');
-      assert.equal(element._itemAnnotation, 'test-message2');
-      assert.equal(element._itemRevision, 'HEAD');
-      done();
-    });
-  });
-
-  test('_computeHideItemClass returns hideItem if type is branches', () => {
-    assert.equal(element._computeHideItemClass('branches'), 'hideItem');
-  });
-
-  test('_computeHideItemClass returns strings if not branches', () => {
-    assert.equal(element._computeHideItemClass('tags'), '');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
new file mode 100644
index 0000000..22f19a6
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
@@ -0,0 +1,115 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-create-pointer-dialog.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-create-pointer-dialog');
+
+suite('gr-create-pointer-dialog tests', () => {
+  let element;
+
+  const ironInput = function(element) {
+    return dom(element).querySelector('iron-input');
+  };
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('branch created', done => {
+    sinon.stub(
+        element.$.restAPI,
+        'createRepoBranch')
+        .callsFake(() => Promise.resolve({}));
+
+    assert.isFalse(element.hasNewItemName);
+
+    element._itemName = 'test-branch';
+    element.itemDetail = 'branches';
+
+    ironInput(element.$.itemNameSection).bindValue = 'test-branch2';
+    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+
+    setTimeout(() => {
+      assert.isTrue(element.hasNewItemName);
+      assert.equal(element._itemName, 'test-branch2');
+      assert.equal(element._itemRevision, 'HEAD');
+      done();
+    });
+  });
+
+  test('tag created', done => {
+    sinon.stub(
+        element.$.restAPI,
+        'createRepoTag')
+        .callsFake(() => Promise.resolve({}));
+
+    assert.isFalse(element.hasNewItemName);
+
+    element._itemName = 'test-tag';
+    element.itemDetail = 'tags';
+
+    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
+    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+
+    setTimeout(() => {
+      assert.isTrue(element.hasNewItemName);
+      assert.equal(element._itemName, 'test-tag2');
+      assert.equal(element._itemRevision, 'HEAD');
+      done();
+    });
+  });
+
+  test('tag created with annotations', done => {
+    sinon.stub(
+        element.$.restAPI,
+        'createRepoTag')
+        .callsFake(() => Promise.resolve({}));
+
+    assert.isFalse(element.hasNewItemName);
+
+    element._itemName = 'test-tag';
+    element._itemAnnotation = 'test-message';
+    element.itemDetail = 'tags';
+
+    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
+    ironInput(element.$.itemAnnotationSection).bindValue = 'test-message2';
+    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+
+    setTimeout(() => {
+      assert.isTrue(element.hasNewItemName);
+      assert.equal(element._itemName, 'test-tag2');
+      assert.equal(element._itemAnnotation, 'test-message2');
+      assert.equal(element._itemRevision, 'HEAD');
+      done();
+    });
+  });
+
+  test('_computeHideItemClass returns hideItem if type is branches', () => {
+    assert.equal(element._computeHideItemClass('branches'), 'hideItem');
+  });
+
+  test('_computeHideItemClass returns strings if not branches', () => {
+    assert.equal(element._computeHideItemClass('tags'), '');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
index 040f41b..9855523 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/shared-styles.js';
@@ -23,24 +21,19 @@
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../shared/gr-select/gr-select.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-create-repo-dialog_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util.js';
 import page from 'page/page.mjs';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrCreateRepoDialog extends mixinBehaviors( [
-  BaseUrlBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
+class GrCreateRepoDialog extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-create-repo-dialog'; }
@@ -97,8 +90,8 @@
   }
 
   _computeRepoUrl(repoName) {
-    return this.getBaseUrl() + '/admin/repos/' +
-        this.encodeURL(repoName, true);
+    return getBaseUrl() + '/admin/repos/' +
+        encodeURL(repoName, true);
   }
 
   _updateRepoName(name) {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.js
deleted file mode 100644
index 680986c..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    :host {
-      display: inline-block;
-    }
-    input {
-      width: 20em;
-    }
-    gr-autocomplete {
-      width: 20em;
-    }
-  </style>
-
-  <div class="gr-form-styles">
-    <div id="form">
-      <section>
-        <span class="title">Repository name</span>
-        <iron-input autocomplete="on" bind-value="{{_repoConfig.name}}">
-          <input
-            is="iron-input"
-            id="repoNameInput"
-            autocomplete="on"
-            bind-value="{{_repoConfig.name}}"
-          />
-        </iron-input>
-      </section>
-      <section>
-        <span class="title">Rights inherit from</span>
-        <span class="value">
-          <gr-autocomplete
-            id="rightsInheritFromInput"
-            text="{{_repoConfig.parent}}"
-            query="[[_query]]"
-            placeholder="Optional, defaults to 'All-Projects'"
-          >
-          </gr-autocomplete>
-        </span>
-      </section>
-      <section>
-        <span class="title">Owner</span>
-        <span class="value">
-          <gr-autocomplete
-            id="ownerInput"
-            text="{{_repoOwner}}"
-            value="{{_repoOwnerId}}"
-            query="[[_queryGroups]]"
-          >
-          </gr-autocomplete>
-        </span>
-      </section>
-      <section>
-        <span class="title">Create initial empty commit</span>
-        <span class="value">
-          <gr-select
-            id="initialCommit"
-            bind-value="{{_repoConfig.create_empty_commit}}"
-          >
-            <select>
-              <option value="false">False</option>
-              <option value="true">True</option>
-            </select>
-          </gr-select>
-        </span>
-      </section>
-      <section>
-        <span class="title">Only serve as parent for other repositories</span>
-        <span class="value">
-          <gr-select
-            id="parentRepo"
-            bind-value="{{_repoConfig.permissions_only}}"
-          >
-            <select>
-              <option value="false">False</option>
-              <option value="true">True</option>
-            </select>
-          </gr-select>
-        </span>
-      </section>
-    </div>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
new file mode 100644
index 0000000..02aabfe
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    :host {
+      display: inline-block;
+    }
+    input {
+      width: 20em;
+    }
+    gr-autocomplete {
+      width: 20em;
+    }
+  </style>
+
+  <div class="gr-form-styles">
+    <div id="form">
+      <section>
+        <span class="title">Repository name</span>
+        <iron-input autocomplete="on" bind-value="{{_repoConfig.name}}">
+          <input
+            is="iron-input"
+            id="repoNameInput"
+            autocomplete="on"
+            bind-value="{{_repoConfig.name}}"
+          />
+        </iron-input>
+      </section>
+      <section>
+        <span class="title">Rights inherit from</span>
+        <span class="value">
+          <gr-autocomplete
+            id="rightsInheritFromInput"
+            text="{{_repoConfig.parent}}"
+            query="[[_query]]"
+            placeholder="Optional, defaults to 'All-Projects'"
+          >
+          </gr-autocomplete>
+        </span>
+      </section>
+      <section>
+        <span class="title">Owner</span>
+        <span class="value">
+          <gr-autocomplete
+            id="ownerInput"
+            text="{{_repoOwner}}"
+            value="{{_repoOwnerId}}"
+            query="[[_queryGroups]]"
+          >
+          </gr-autocomplete>
+        </span>
+      </section>
+      <section>
+        <span class="title">Create initial empty commit</span>
+        <span class="value">
+          <gr-select
+            id="initialCommit"
+            bind-value="{{_repoConfig.create_empty_commit}}"
+          >
+            <select>
+              <option value="false">False</option>
+              <option value="true">True</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+      <section>
+        <span class="title">Only serve as parent for other repositories</span>
+        <span class="value">
+          <gr-select
+            id="parentRepo"
+            bind-value="{{_repoConfig.permissions_only}}"
+          >
+            <select>
+              <option value="false">False</option>
+              <option value="true">True</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
deleted file mode 100644
index dfab4ac..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
+++ /dev/null
@@ -1,105 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-create-repo-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-create-repo-dialog></gr-create-repo-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-create-repo-dialog.js';
-suite('gr-create-repo-dialog tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('default values are populated', () => {
-    assert.isTrue(element.$.initialCommit.bindValue);
-    assert.isFalse(element.$.parentRepo.bindValue);
-  });
-
-  test('repo created', done => {
-    const configInputObj = {
-      name: 'test-repo',
-      create_empty_commit: true,
-      parent: 'All-Project',
-      permissions_only: false,
-      owners: ['testId'],
-    };
-
-    const saveStub = sandbox.stub(element.$.restAPI,
-        'createRepo', () => Promise.resolve({}));
-
-    assert.isFalse(element.hasNewRepoName);
-
-    element._repoConfig = {
-      name: 'test-repo',
-      create_empty_commit: true,
-      parent: 'All-Project',
-      permissions_only: false,
-    };
-
-    element._repoOwner = 'test';
-    element._repoOwnerId = 'testId';
-
-    element.$.repoNameInput.bindValue = configInputObj.name;
-    element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
-    element.$.ownerInput.text = configInputObj.owners[0];
-    element.$.initialCommit.bindValue =
-        configInputObj.create_empty_commit;
-    element.$.parentRepo.bindValue =
-        configInputObj.permissions_only;
-
-    assert.isTrue(element.hasNewRepoName);
-
-    assert.deepEqual(element._repoConfig, configInputObj);
-
-    element.handleCreateRepo().then(() => {
-      assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
-      done();
-    });
-  });
-
-  test('testing observer of _repoOwner', () => {
-    element._repoOwnerId = 'test-5';
-    assert.deepEqual(element._repoConfig.owners, ['test-5']);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
new file mode 100644
index 0000000..1e1fb0e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.js
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-create-repo-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-create-repo-dialog');
+
+suite('gr-create-repo-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('default values are populated', () => {
+    assert.isTrue(element.$.initialCommit.bindValue);
+    assert.isFalse(element.$.parentRepo.bindValue);
+  });
+
+  test('repo created', done => {
+    const configInputObj = {
+      name: 'test-repo',
+      create_empty_commit: true,
+      parent: 'All-Project',
+      permissions_only: false,
+      owners: ['testId'],
+    };
+
+    const saveStub = sinon.stub(element.$.restAPI,
+        'createRepo').callsFake(() => Promise.resolve({}));
+
+    assert.isFalse(element.hasNewRepoName);
+
+    element._repoConfig = {
+      name: 'test-repo',
+      create_empty_commit: true,
+      parent: 'All-Project',
+      permissions_only: false,
+    };
+
+    element._repoOwner = 'test';
+    element._repoOwnerId = 'testId';
+
+    element.$.repoNameInput.bindValue = configInputObj.name;
+    element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
+    element.$.ownerInput.text = configInputObj.owners[0];
+    element.$.initialCommit.bindValue =
+        configInputObj.create_empty_commit;
+    element.$.parentRepo.bindValue =
+        configInputObj.permissions_only;
+
+    assert.isTrue(element.hasNewRepoName);
+
+    assert.deepEqual(element._repoConfig, configInputObj);
+
+    element.handleCreateRepo().then(() => {
+      assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
+      done();
+    });
+  });
+
+  test('testing observer of _repoOwner', () => {
+    element._repoOwnerId = 'test-5';
+    assert.deepEqual(element._repoConfig.owners, ['test-5']);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
index a3c05cb..259a302 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
@@ -15,28 +15,24 @@
  * limitations under the License.
  */
 
-import '../../../scripts/bundled-polymer.js';
 import '../../../styles/gr-table-styles.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-date-formatter/gr-date-formatter.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../shared/gr-account-link/gr-account-link.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-group-audit-log_html.js';
-import {ListViewBehavior} from '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
 const GROUP_EVENTS = ['ADD_GROUP', 'REMOVE_GROUP'];
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrGroupAuditLog extends mixinBehaviors( [
-  ListViewBehavior,
-], GestureEventListeners(
+class GrGroupAuditLog extends ListViewMixin(GestureEventListeners(
     LegacyElementMixin(
         PolymerElement))) {
   static get template() { return htmlTemplate; }
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.js
deleted file mode 100644
index 130efbb..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.js
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* GenericList style centers the last column, but we don't want that here. */
-    .genericList tr th:last-of-type,
-    .genericList tr td:last-of-type {
-      text-align: left;
-    }
-  </style>
-  <table id="list" class="genericList">
-    <tbody>
-      <tr class="headerRow">
-        <th class="date topHeader">Date</th>
-        <th class="type topHeader">Type</th>
-        <th class="member topHeader">Member</th>
-        <th class="by-user topHeader">By User</th>
-      </tr>
-      <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-        <td>Loading...</td>
-      </tr>
-    </tbody>
-    <tbody class$="[[computeLoadingClass(_loading)]]">
-      <template is="dom-repeat" items="[[_auditLog]]">
-        <tr class="table">
-          <td class="date">
-            <gr-date-formatter has-tooltip="" date-str="[[item.date]]">
-            </gr-date-formatter>
-          </td>
-          <td class="type">[[itemType(item.type)]]</td>
-          <td class="member">
-            <template is="dom-if" if="[[_isGroupEvent(item.type)]]">
-              <a href$="[[_computeGroupUrl(item.member)]]">
-                [[_getNameForGroup(item.member)]]
-              </a>
-            </template>
-            <template is="dom-if" if="[[!_isGroupEvent(item.type)]]">
-              <gr-account-link account="[[item.member]]"></gr-account-link>
-              [[_getIdForUser(item.member)]]
-            </template>
-          </td>
-          <td class="by-user">
-            <gr-account-link account="[[item.user]]"></gr-account-link>
-            [[_getIdForUser(item.user)]]
-          </td>
-        </tr>
-      </template>
-    </tbody>
-  </table>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
new file mode 100644
index 0000000..1212685
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-table-styles">
+    /* GenericList style centers the last column, but we don't want that here. */
+    .genericList tr th:last-of-type,
+    .genericList tr td:last-of-type {
+      text-align: left;
+    }
+  </style>
+  <table id="list" class="genericList">
+    <tbody>
+      <tr class="headerRow">
+        <th class="date topHeader">Date</th>
+        <th class="type topHeader">Type</th>
+        <th class="member topHeader">Member</th>
+        <th class="by-user topHeader">By User</th>
+      </tr>
+      <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+        <td>Loading...</td>
+      </tr>
+    </tbody>
+    <tbody class$="[[computeLoadingClass(_loading)]]">
+      <template is="dom-repeat" items="[[_auditLog]]">
+        <tr class="table">
+          <td class="date">
+            <gr-date-formatter has-tooltip="" date-str="[[item.date]]">
+            </gr-date-formatter>
+          </td>
+          <td class="type">[[itemType(item.type)]]</td>
+          <td class="member">
+            <template is="dom-if" if="[[_isGroupEvent(item.type)]]">
+              <a href$="[[_computeGroupUrl(item.member)]]">
+                [[_getNameForGroup(item.member)]]
+              </a>
+            </template>
+            <template is="dom-if" if="[[!_isGroupEvent(item.type)]]">
+              <gr-account-link account="[[item.member]]"></gr-account-link>
+              [[_getIdForUser(item.member)]]
+            </template>
+          </td>
+          <td class="by-user">
+            <gr-account-link account="[[item.user]]"></gr-account-link>
+            [[_getIdForUser(item.user)]]
+          </td>
+        </tr>
+      </template>
+    </tbody>
+  </table>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
deleted file mode 100644
index 4590220..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
+++ /dev/null
@@ -1,116 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-group-audit-log</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-group-audit-log></gr-group-audit-log>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-group-audit-log.js';
-suite('gr-group-audit-log tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('members', () => {
-    test('test _getNameForGroup', () => {
-      let group = {
-        member: {
-          name: 'test-name',
-        },
-      };
-      assert.equal(element._getNameForGroup(group.member), 'test-name');
-
-      group = {
-        member: {
-          id: 'test-id',
-        },
-      };
-      assert.equal(element._getNameForGroup(group.member), 'test-id');
-    });
-
-    test('test _isGroupEvent', () => {
-      assert.isTrue(element._isGroupEvent('ADD_GROUP'));
-      assert.isTrue(element._isGroupEvent('REMOVE_GROUP'));
-
-      assert.isFalse(element._isGroupEvent('ADD_USER'));
-      assert.isFalse(element._isGroupEvent('REMOVE_USER'));
-    });
-  });
-
-  suite('users', () => {
-    test('test _getIdForUser', () => {
-      const account = {
-        user: {
-          username: 'test-user',
-          _account_id: 12,
-        },
-      };
-      assert.equal(element._getIdForUser(account.user), ' (12)');
-    });
-
-    test('test _account_id not present', () => {
-      const account = {
-        user: {
-          username: 'test-user',
-        },
-      };
-      assert.equal(element._getIdForUser(account.user), '');
-    });
-  });
-
-  suite('404', () => {
-    test('fires page-error', done => {
-      element.groupId = 1;
-
-      const response = {status: 404};
-      sandbox.stub(
-          element.$.restAPI, 'getGroupAuditLog', (group, errFn) => {
-            errFn(response);
-          });
-
-      element.addEventListener('page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element._getAuditLogs();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
new file mode 100644
index 0000000..1bbfcae
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
@@ -0,0 +1,97 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-group-audit-log.js';
+
+const basicFixture = fixtureFromElement('gr-group-audit-log');
+
+suite('gr-group-audit-log tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('members', () => {
+    test('test _getNameForGroup', () => {
+      let group = {
+        member: {
+          name: 'test-name',
+        },
+      };
+      assert.equal(element._getNameForGroup(group.member), 'test-name');
+
+      group = {
+        member: {
+          id: 'test-id',
+        },
+      };
+      assert.equal(element._getNameForGroup(group.member), 'test-id');
+    });
+
+    test('test _isGroupEvent', () => {
+      assert.isTrue(element._isGroupEvent('ADD_GROUP'));
+      assert.isTrue(element._isGroupEvent('REMOVE_GROUP'));
+
+      assert.isFalse(element._isGroupEvent('ADD_USER'));
+      assert.isFalse(element._isGroupEvent('REMOVE_USER'));
+    });
+  });
+
+  suite('users', () => {
+    test('test _getIdForUser', () => {
+      const account = {
+        user: {
+          username: 'test-user',
+          _account_id: 12,
+        },
+      };
+      assert.equal(element._getIdForUser(account.user), ' (12)');
+    });
+
+    test('test _account_id not present', () => {
+      const account = {
+        user: {
+          username: 'test-user',
+        },
+      };
+      assert.equal(element._getIdForUser(account.user), '');
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      element.groupId = 1;
+
+      const response = {status: 404};
+      sinon.stub(
+          element.$.restAPI, 'getGroupAuditLog')
+          .callsFake((group, errFn) => {
+            errFn(response);
+          });
+
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      element._getAuditLogs();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
index 45f7612..ced9c69 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/gr-subpage-styles.js';
@@ -26,13 +25,11 @@
 import '../../shared/gr-overlay/gr-overlay.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-group-members_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 
 const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT = 'Group may not exist, or you may not have '+
@@ -41,14 +38,11 @@
 const URL_REGEX = '^(?:[a-z]+:)?//';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrGroupMembers extends mixinBehaviors( [
-  BaseUrlBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
+class GrGroupMembers extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-group-members'; }
@@ -122,12 +116,12 @@
           this._groupName = config.name;
 
           promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
-            this._isAdmin = isAdmin ? true : false;
+            this._isAdmin = !!isAdmin;
           }));
 
           promises.push(this.$.restAPI.getIsGroupOwner(config.name)
               .then(isOwner => {
-                this._groupOwner = isOwner ? true : false;
+                this._groupOwner = !!isOwner;
               }));
 
           promises.push(this.$.restAPI.getGroupMembers(config.name).then(
@@ -164,9 +158,9 @@
 
     // For GWT compatibility
     if (url.startsWith('#')) {
-      return this.getBaseUrl() + url.slice(1);
+      return getBaseUrl() + url.slice(1);
     }
-    return this.getBaseUrl() + url;
+    return getBaseUrl() + url;
   }
 
   _handleSavingGroupMember() {
@@ -231,16 +225,19 @@
 
   _handleSavingIncludedGroups() {
     return this.$.restAPI.saveIncludedGroup(this._groupName,
-        this._includedGroupSearchId.replace(/\+/g, ' '), err => {
-          if (err.status === 404) {
-            this.dispatchEvent(new CustomEvent('show-alert', {
-              detail: {message: SAVING_ERROR_TEXT},
-              bubbles: true,
-              composed: true,
-            }));
-            return err;
+        this._includedGroupSearchId.replace(/\+/g, ' '), (errResponse, err) => {
+          if (errResponse) {
+            if (errResponse.status === 404) {
+              this.dispatchEvent(new CustomEvent('show-alert', {
+                detail: {message: SAVING_ERROR_TEXT},
+                bubbles: true,
+                composed: true,
+              }));
+              return errResponse;
+            }
+            throw Error(err.statusText);
           }
-          throw Error(err.statusText);
+          throw err;
         })
         .then(config => {
           if (!config) {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js
deleted file mode 100644
index c5577c2..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.js
+++ /dev/null
@@ -1,184 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    .input {
-      width: 15em;
-    }
-    gr-autocomplete {
-      width: 20em;
-    }
-    a {
-      color: var(--primary-text-color);
-      text-decoration: none;
-    }
-    a:hover {
-      text-decoration: underline;
-    }
-    th {
-      border-bottom: 1px solid var(--border-color);
-      font-weight: var(--font-weight-bold);
-      text-align: left;
-    }
-    .canModify #groupMemberSearchInput,
-    .canModify #saveGroupMember,
-    .canModify .deleteHeader,
-    .canModify .deleteColumn,
-    .canModify #includedGroupSearchInput,
-    .canModify #saveIncludedGroups,
-    .canModify .deleteIncludedHeader,
-    .canModify #saveIncludedGroups {
-      display: none;
-    }
-  </style>
-  <main
-    class$="gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]"
-  >
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h1 id="Title">[[_groupName]]</h1>
-      <div id="form">
-        <h3 id="members">Members</h3>
-        <fieldset>
-          <span class="value">
-            <gr-autocomplete
-              id="groupMemberSearchInput"
-              text="{{_groupMemberSearchName}}"
-              value="{{_groupMemberSearchId}}"
-              query="[[_queryMembers]]"
-              placeholder="Name Or Email"
-            >
-            </gr-autocomplete>
-          </span>
-          <gr-button
-            id="saveGroupMember"
-            on-click="_handleSavingGroupMember"
-            disabled="[[!_groupMemberSearchId]]"
-          >
-            Add
-          </gr-button>
-          <table id="groupMembers">
-            <tbody>
-              <tr class="headerRow">
-                <th class="nameHeader">Name</th>
-                <th class="emailAddressHeader">Email Address</th>
-                <th class="deleteHeader">Delete Member</th>
-              </tr>
-            </tbody>
-            <tbody>
-              <template is="dom-repeat" items="[[_groupMembers]]">
-                <tr>
-                  <td class="nameColumn">
-                    <gr-account-link account="[[item]]"></gr-account-link>
-                  </td>
-                  <td>[[item.email]]</td>
-                  <td class="deleteColumn">
-                    <gr-button
-                      class="deleteMembersButton"
-                      on-click="_handleDeleteMember"
-                    >
-                      Delete
-                    </gr-button>
-                  </td>
-                </tr>
-              </template>
-            </tbody>
-          </table>
-        </fieldset>
-        <h3 id="includedGroups">Included Groups</h3>
-        <fieldset>
-          <span class="value">
-            <gr-autocomplete
-              id="includedGroupSearchInput"
-              text="{{_includedGroupSearchName}}"
-              value="{{_includedGroupSearchId}}"
-              query="[[_queryIncludedGroup]]"
-              placeholder="Group Name"
-            >
-            </gr-autocomplete>
-          </span>
-          <gr-button
-            id="saveIncludedGroups"
-            on-click="_handleSavingIncludedGroups"
-            disabled="[[!_includedGroupSearchId]]"
-          >
-            Add
-          </gr-button>
-          <table id="includedGroups">
-            <tbody>
-              <tr class="headerRow">
-                <th class="groupNameHeader">Group Name</th>
-                <th class="descriptionHeader">Description</th>
-                <th class="deleteIncludedHeader">
-                  Delete Group
-                </th>
-              </tr>
-            </tbody>
-            <tbody>
-              <template is="dom-repeat" items="[[_includedGroups]]">
-                <tr>
-                  <td class="nameColumn">
-                    <template is="dom-if" if="[[item.url]]">
-                      <a href$="[[_computeGroupUrl(item.url)]]" rel="noopener">
-                        [[item.name]]
-                      </a>
-                    </template>
-                    <template is="dom-if" if="[[!item.url]]">
-                      [[item.name]]
-                    </template>
-                  </td>
-                  <td>[[item.description]]</td>
-                  <td class="deleteColumn">
-                    <gr-button
-                      class="deleteIncludedGroupButton"
-                      on-click="_handleDeleteIncludedGroup"
-                    >
-                      Delete
-                    </gr-button>
-                  </td>
-                </tr>
-              </template>
-            </tbody>
-          </table>
-        </fieldset>
-      </div>
-    </div>
-  </main>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-confirm-delete-item-dialog
-      class="confirmDialog"
-      on-confirm="_handleDeleteConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      item="[[_itemName]]"
-      item-type="[[_itemType]]"
-    ></gr-confirm-delete-item-dialog>
-  </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
new file mode 100644
index 0000000..2d3f8fc
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
@@ -0,0 +1,184 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-table-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-subpage-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="shared-styles">
+    .input {
+      width: 15em;
+    }
+    gr-autocomplete {
+      width: 20em;
+    }
+    a {
+      color: var(--primary-text-color);
+      text-decoration: none;
+    }
+    a:hover {
+      text-decoration: underline;
+    }
+    th {
+      border-bottom: 1px solid var(--border-color);
+      font-weight: var(--font-weight-bold);
+      text-align: left;
+    }
+    .canModify #groupMemberSearchInput,
+    .canModify #saveGroupMember,
+    .canModify .deleteHeader,
+    .canModify .deleteColumn,
+    .canModify #includedGroupSearchInput,
+    .canModify #saveIncludedGroups,
+    .canModify .deleteIncludedHeader,
+    .canModify #saveIncludedGroups {
+      display: none;
+    }
+  </style>
+  <main
+    class$="gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]"
+  >
+    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+      Loading...
+    </div>
+    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <h1 id="Title" class="heading-1">[[_groupName]]</h1>
+      <div id="form">
+        <h3 id="members" class="heading-3">Members</h3>
+        <fieldset>
+          <span class="value">
+            <gr-autocomplete
+              id="groupMemberSearchInput"
+              text="{{_groupMemberSearchName}}"
+              value="{{_groupMemberSearchId}}"
+              query="[[_queryMembers]]"
+              placeholder="Name Or Email"
+            >
+            </gr-autocomplete>
+          </span>
+          <gr-button
+            id="saveGroupMember"
+            on-click="_handleSavingGroupMember"
+            disabled="[[!_groupMemberSearchId]]"
+          >
+            Add
+          </gr-button>
+          <table id="groupMembers">
+            <tbody>
+              <tr class="headerRow">
+                <th class="nameHeader">Name</th>
+                <th class="emailAddressHeader">Email Address</th>
+                <th class="deleteHeader">Delete Member</th>
+              </tr>
+            </tbody>
+            <tbody>
+              <template is="dom-repeat" items="[[_groupMembers]]">
+                <tr>
+                  <td class="nameColumn">
+                    <gr-account-link account="[[item]]"></gr-account-link>
+                  </td>
+                  <td>[[item.email]]</td>
+                  <td class="deleteColumn">
+                    <gr-button
+                      class="deleteMembersButton"
+                      on-click="_handleDeleteMember"
+                    >
+                      Delete
+                    </gr-button>
+                  </td>
+                </tr>
+              </template>
+            </tbody>
+          </table>
+        </fieldset>
+        <h3 id="includedGroups" class="heading-3">Included Groups</h3>
+        <fieldset>
+          <span class="value">
+            <gr-autocomplete
+              id="includedGroupSearchInput"
+              text="{{_includedGroupSearchName}}"
+              value="{{_includedGroupSearchId}}"
+              query="[[_queryIncludedGroup]]"
+              placeholder="Group Name"
+            >
+            </gr-autocomplete>
+          </span>
+          <gr-button
+            id="saveIncludedGroups"
+            on-click="_handleSavingIncludedGroups"
+            disabled="[[!_includedGroupSearchId]]"
+          >
+            Add
+          </gr-button>
+          <table id="includedGroups">
+            <tbody>
+              <tr class="headerRow">
+                <th class="groupNameHeader">Group Name</th>
+                <th class="descriptionHeader">Description</th>
+                <th class="deleteIncludedHeader">
+                  Delete Group
+                </th>
+              </tr>
+            </tbody>
+            <tbody>
+              <template is="dom-repeat" items="[[_includedGroups]]">
+                <tr>
+                  <td class="nameColumn">
+                    <template is="dom-if" if="[[item.url]]">
+                      <a href$="[[_computeGroupUrl(item.url)]]" rel="noopener">
+                        [[item.name]]
+                      </a>
+                    </template>
+                    <template is="dom-if" if="[[!item.url]]">
+                      [[item.name]]
+                    </template>
+                  </td>
+                  <td>[[item.description]]</td>
+                  <td class="deleteColumn">
+                    <gr-button
+                      class="deleteIncludedGroupButton"
+                      on-click="_handleDeleteIncludedGroup"
+                    >
+                      Delete
+                    </gr-button>
+                  </td>
+                </tr>
+              </template>
+            </tbody>
+          </table>
+        </fieldset>
+      </div>
+    </div>
+  </main>
+  <gr-overlay id="overlay" with-backdrop="">
+    <gr-confirm-delete-item-dialog
+      class="confirmDialog"
+      on-confirm="_handleDeleteConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      item="[[_itemName]]"
+      item-type="[[_itemType]]"
+    ></gr-confirm-delete-item-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
deleted file mode 100644
index 4dd9a7b..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
+++ /dev/null
@@ -1,374 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-group-members</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-group-members></gr-group-members>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-group-members.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-group-members tests', () => {
-  let element;
-  let sandbox;
-  let groups;
-  let groupMembers;
-  let includedGroups;
-  let groupStub;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-
-    groups = {
-      name: 'Administrators',
-      owner: 'Administrators',
-      group_id: 1,
-    };
-
-    groupMembers = [
-      {
-        _account_id: 1000097,
-        name: 'Jane Roe',
-        email: 'jane.roe@example.com',
-        username: 'jane',
-      },
-      {
-        _account_id: 1000096,
-        name: 'Test User',
-        email: 'john.doe@example.com',
-      },
-      {
-        _account_id: 1000095,
-        name: 'Gerrit',
-      },
-      {
-        _account_id: 1000098,
-      },
-    ];
-
-    includedGroups = [{
-      url: 'https://group/url',
-      options: {},
-      id: 'testId',
-      name: 'testName',
-    },
-    {
-      url: '/group/url',
-      options: {},
-      id: 'testId2',
-      name: 'testName2',
-    },
-    {
-      url: '#/group/url',
-      options: {},
-      id: 'testId3',
-      name: 'testName3',
-    },
-    ];
-
-    stub('gr-rest-api-interface', {
-      getSuggestedAccounts(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              _account_id: 1000096,
-              name: 'test-account',
-              email: 'test.account@example.com',
-              username: 'test123',
-            },
-            {
-              _account_id: 1001439,
-              name: 'test-admin',
-              email: 'test.admin@example.com',
-              username: 'test_admin',
-            },
-            {
-              _account_id: 1001439,
-              name: 'test-git',
-              username: 'test_git',
-            },
-          ]);
-        } else {
-          return Promise.resolve({});
-        }
-      },
-      getSuggestedGroups(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve({
-            'test-admin': {
-              id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
-            },
-            'test/Administrator (admin)': {
-              id: 'test%3Aadmin',
-            },
-          });
-        } else {
-          return Promise.resolve({});
-        }
-      },
-      getLoggedIn() { return Promise.resolve(true); },
-      getConfig() {
-        return Promise.resolve();
-      },
-      getGroupMembers() {
-        return Promise.resolve(groupMembers);
-      },
-      getIsGroupOwner() {
-        return Promise.resolve(true);
-      },
-      getIncludedGroup() {
-        return Promise.resolve(includedGroups);
-      },
-      getAccountCapabilities() {
-        return Promise.resolve();
-      },
-    });
-    element = fixture('basic');
-    sandbox.stub(element, 'getBaseUrl').returns('https://test/site');
-    element.groupId = 1;
-    groupStub = sandbox.stub(
-        element.$.restAPI,
-        'getGroupConfig',
-        () => Promise.resolve(groups));
-    return element._loadGroupDetails();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_includedGroups', () => {
-    assert.equal(element._includedGroups.length, 3);
-    assert.equal(dom(element.root)
-        .querySelectorAll('.nameColumn a')[0].href, includedGroups[0].url);
-    assert.equal(dom(element.root)
-        .querySelectorAll('.nameColumn a')[1].href,
-    'https://test/site/group/url');
-    assert.equal(dom(element.root)
-        .querySelectorAll('.nameColumn a')[2].href,
-    'https://test/site/group/url');
-  });
-
-  test('save members correctly', () => {
-    element._groupOwner = true;
-
-    const memberName = 'test-admin';
-
-    const saveStub = sandbox.stub(element.$.restAPI, 'saveGroupMembers',
-        () => Promise.resolve({}));
-
-    const button = element.$.saveGroupMember;
-
-    assert.isTrue(button.hasAttribute('disabled'));
-
-    element.$.groupMemberSearchInput.text = memberName;
-    element.$.groupMemberSearchInput.value = 1234;
-
-    assert.isFalse(button.hasAttribute('disabled'));
-
-    return element._handleSavingGroupMember().then(() => {
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
-      assert.isTrue(saveStub.lastCall.calledWithExactly('Administrators',
-          1234));
-    });
-  });
-
-  test('save included groups correctly', () => {
-    element._groupOwner = true;
-
-    const includedGroupName = 'testName';
-
-    const saveIncludedGroupStub = sandbox.stub(
-        element.$.restAPI, 'saveIncludedGroup', () => Promise.resolve({}));
-
-    const button = element.$.saveIncludedGroups;
-
-    assert.isTrue(button.hasAttribute('disabled'));
-
-    element.$.includedGroupSearchInput.text = includedGroupName;
-    element.$.includedGroupSearchInput.value = 'testId';
-
-    assert.isFalse(button.hasAttribute('disabled'));
-
-    return element._handleSavingIncludedGroups().then(() => {
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
-      assert.equal(saveIncludedGroupStub.lastCall.args[0], 'Administrators');
-      assert.equal(saveIncludedGroupStub.lastCall.args[1], 'testId');
-    });
-  });
-
-  test('add included group 404 shows helpful error text', () => {
-    element._groupOwner = true;
-
-    const memberName = 'bad-name';
-    const alertStub = sandbox.stub();
-    element.addEventListener('show-alert', alertStub);
-    const error = new Error('error');
-    error.status = 404;
-    sandbox.stub(element.$.restAPI, 'saveGroupMembers',
-        () => Promise.reject(error));
-
-    element.$.groupMemberSearchInput.text = memberName;
-    element.$.groupMemberSearchInput.value = 1234;
-
-    return element._handleSavingIncludedGroups().then(() => {
-      assert.isTrue(alertStub.called);
-    });
-  });
-
-  test('_getAccountSuggestions empty', done => {
-    element
-        ._getAccountSuggestions('nonexistent').then(accounts => {
-          assert.equal(accounts.length, 0);
-          done();
-        });
-  });
-
-  test('_getAccountSuggestions non-empty', done => {
-    element
-        ._getAccountSuggestions('test-').then(accounts => {
-          assert.equal(accounts.length, 3);
-          assert.equal(accounts[0].name,
-              'test-account <test.account@example.com>');
-          assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
-          assert.equal(accounts[2].name, 'test-git');
-          done();
-        });
-  });
-
-  test('_getGroupSuggestions empty', done => {
-    element
-        ._getGroupSuggestions('nonexistent').then(groups => {
-          assert.equal(groups.length, 0);
-          done();
-        });
-  });
-
-  test('_getGroupSuggestions non-empty', done => {
-    element
-        ._getGroupSuggestions('test').then(groups => {
-          assert.equal(groups.length, 2);
-          assert.equal(groups[0].name, 'test-admin');
-          assert.equal(groups[1].name, 'test/Administrator (admin)');
-          done();
-        });
-  });
-
-  test('_computeHideItemClass returns string for admin', () => {
-    const admin = true;
-    const owner = false;
-    assert.equal(element._computeHideItemClass(owner, admin), '');
-  });
-
-  test('_computeHideItemClass returns hideItem for admin and owner', () => {
-    const admin = false;
-    const owner = false;
-    assert.equal(element._computeHideItemClass(owner, admin), 'canModify');
-  });
-
-  test('_computeHideItemClass returns string for owner', () => {
-    const admin = false;
-    const owner = true;
-    assert.equal(element._computeHideItemClass(owner, admin), '');
-  });
-
-  test('delete member', () => {
-    const deletelBtns = dom(element.root)
-        .querySelectorAll('.deleteMembersButton');
-    MockInteractions.tap(deletelBtns[0]);
-    assert.equal(element._itemId, '1000097');
-    assert.equal(element._itemName, 'jane');
-    MockInteractions.tap(deletelBtns[1]);
-    assert.equal(element._itemId, '1000096');
-    assert.equal(element._itemName, 'Test User');
-    MockInteractions.tap(deletelBtns[2]);
-    assert.equal(element._itemId, '1000095');
-    assert.equal(element._itemName, 'Gerrit');
-    MockInteractions.tap(deletelBtns[3]);
-    assert.equal(element._itemId, '1000098');
-    assert.equal(element._itemName, '1000098');
-  });
-
-  test('delete included groups', () => {
-    const deletelBtns = dom(element.root)
-        .querySelectorAll('.deleteIncludedGroupButton');
-    MockInteractions.tap(deletelBtns[0]);
-    assert.equal(element._itemId, 'testId');
-    assert.equal(element._itemName, 'testName');
-    MockInteractions.tap(deletelBtns[1]);
-    assert.equal(element._itemId, 'testId2');
-    assert.equal(element._itemName, 'testName2');
-    MockInteractions.tap(deletelBtns[2]);
-    assert.equal(element._itemId, 'testId3');
-    assert.equal(element._itemName, 'testName3');
-  });
-
-  test('_computeLoadingClass', () => {
-    assert.equal(element._computeLoadingClass(true), 'loading');
-
-    assert.equal(element._computeLoadingClass(false), '');
-  });
-
-  test('_computeGroupUrl', () => {
-    assert.isUndefined(element._computeGroupUrl(undefined));
-
-    assert.isUndefined(element._computeGroupUrl(false));
-
-    let url = '#/admin/groups/uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
-    assert.equal(element._computeGroupUrl(url),
-        'https://test/site/admin/groups/' +
-        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498');
-
-    url = 'https://gerrit.local/admin/groups/' +
-        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
-    assert.equal(element._computeGroupUrl(url), url);
-  });
-
-  test('fires page-error', done => {
-    groupStub.restore();
-
-    element.groupId = 1;
-
-    const response = {status: 404};
-    sandbox.stub(
-        element.$.restAPI, 'getGroupConfig', (group, errFn) => {
-          errFn(response);
-        });
-    element.addEventListener('page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      done();
-    });
-
-    element._loadGroupDetails();
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
new file mode 100644
index 0000000..3e3e572
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
@@ -0,0 +1,381 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-group-members.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-group-members');
+
+suite('gr-group-members tests', () => {
+  let element;
+
+  let groups;
+  let groupMembers;
+  let includedGroups;
+  let groupStub;
+
+  setup(() => {
+    groups = {
+      name: 'Administrators',
+      owner: 'Administrators',
+      group_id: 1,
+    };
+
+    groupMembers = [
+      {
+        _account_id: 1000097,
+        name: 'Jane Roe',
+        email: 'jane.roe@example.com',
+        username: 'jane',
+      },
+      {
+        _account_id: 1000096,
+        name: 'Test User',
+        email: 'john.doe@example.com',
+      },
+      {
+        _account_id: 1000095,
+        name: 'Gerrit',
+      },
+      {
+        _account_id: 1000098,
+      },
+    ];
+
+    includedGroups = [{
+      url: 'https://group/url',
+      options: {},
+      id: 'testId',
+      name: 'testName',
+    },
+    {
+      url: '/group/url',
+      options: {},
+      id: 'testId2',
+      name: 'testName2',
+    },
+    {
+      url: '#/group/url',
+      options: {},
+      id: 'testId3',
+      name: 'testName3',
+    },
+    ];
+
+    stub('gr-rest-api-interface', {
+      getSuggestedAccounts(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve([
+            {
+              _account_id: 1000096,
+              name: 'test-account',
+              email: 'test.account@example.com',
+              username: 'test123',
+            },
+            {
+              _account_id: 1001439,
+              name: 'test-admin',
+              email: 'test.admin@example.com',
+              username: 'test_admin',
+            },
+            {
+              _account_id: 1001439,
+              name: 'test-git',
+              username: 'test_git',
+            },
+          ]);
+        } else {
+          return Promise.resolve({});
+        }
+      },
+      getSuggestedGroups(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve({
+            'test-admin': {
+              id: '1ce023d3fb4e4260776fb92cd08b52bbd21ce70a',
+            },
+            'test/Administrator (admin)': {
+              id: 'test%3Aadmin',
+            },
+          });
+        } else {
+          return Promise.resolve({});
+        }
+      },
+      getLoggedIn() { return Promise.resolve(true); },
+      getConfig() {
+        return Promise.resolve();
+      },
+      getGroupMembers() {
+        return Promise.resolve(groupMembers);
+      },
+      getIsGroupOwner() {
+        return Promise.resolve(true);
+      },
+      getIncludedGroup() {
+        return Promise.resolve(includedGroups);
+      },
+      getAccountCapabilities() {
+        return Promise.resolve();
+      },
+    });
+    element = basicFixture.instantiate();
+    stubBaseUrl('https://test/site');
+    element.groupId = 1;
+    groupStub = sinon.stub(
+        element.$.restAPI,
+        'getGroupConfig')
+        .callsFake(() => Promise.resolve(groups));
+    return element._loadGroupDetails();
+  });
+
+  test('_includedGroups', () => {
+    assert.equal(element._includedGroups.length, 3);
+    assert.equal(dom(element.root)
+        .querySelectorAll('.nameColumn a')[0].href, includedGroups[0].url);
+    assert.equal(dom(element.root)
+        .querySelectorAll('.nameColumn a')[1].href,
+    'https://test/site/group/url');
+    assert.equal(dom(element.root)
+        .querySelectorAll('.nameColumn a')[2].href,
+    'https://test/site/group/url');
+  });
+
+  test('save members correctly', () => {
+    element._groupOwner = true;
+
+    const memberName = 'test-admin';
+
+    const saveStub = sinon.stub(element.$.restAPI, 'saveGroupMembers')
+        .callsFake(() => Promise.resolve({}));
+
+    const button = element.$.saveGroupMember;
+
+    assert.isTrue(button.hasAttribute('disabled'));
+
+    element.$.groupMemberSearchInput.text = memberName;
+    element.$.groupMemberSearchInput.value = 1234;
+
+    assert.isFalse(button.hasAttribute('disabled'));
+
+    return element._handleSavingGroupMember().then(() => {
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+      assert.isTrue(saveStub.lastCall.calledWithExactly('Administrators',
+          1234));
+    });
+  });
+
+  test('save included groups correctly', () => {
+    element._groupOwner = true;
+
+    const includedGroupName = 'testName';
+
+    const saveIncludedGroupStub = sinon.stub(
+        element.$.restAPI, 'saveIncludedGroup')
+        .callsFake(() => Promise.resolve({}));
+
+    const button = element.$.saveIncludedGroups;
+
+    assert.isTrue(button.hasAttribute('disabled'));
+
+    element.$.includedGroupSearchInput.text = includedGroupName;
+    element.$.includedGroupSearchInput.value = 'testId';
+
+    assert.isFalse(button.hasAttribute('disabled'));
+
+    return element._handleSavingIncludedGroups().then(() => {
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+      assert.equal(saveIncludedGroupStub.lastCall.args[0], 'Administrators');
+      assert.equal(saveIncludedGroupStub.lastCall.args[1], 'testId');
+    });
+  });
+
+  test('add included group 404 shows helpful error text', () => {
+    element._groupOwner = true;
+
+    const memberName = 'bad-name';
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    const errorResponse = {
+      status: 404,
+      ok: false,
+    };
+    sinon.stub(element.$.restAPI._restApiHelper, 'fetch').callsFake(
+        () => Promise.resolve(errorResponse));
+
+    element.$.groupMemberSearchInput.text = memberName;
+    element.$.groupMemberSearchInput.value = 1234;
+
+    return element._handleSavingIncludedGroups().then(() => {
+      assert.isTrue(alertStub.called);
+    });
+  });
+
+  test('add included group network-error throws an exception', async () => {
+    element._groupOwner = true;
+
+    const memberName = 'bad-name';
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    const err = new Error();
+    sinon.stub(element.$.restAPI._restApiHelper, 'fetch').callsFake(
+        () => Promise.reject(err));
+
+    element.$.groupMemberSearchInput.text = memberName;
+    element.$.groupMemberSearchInput.value = 1234;
+
+    let exceptionThrown = false;
+    try {
+      await element._handleSavingIncludedGroups();
+    } catch (e) {
+      exceptionThrown = true;
+    }
+    assert.isTrue(exceptionThrown);
+  });
+
+  test('_getAccountSuggestions empty', done => {
+    element
+        ._getAccountSuggestions('nonexistent').then(accounts => {
+          assert.equal(accounts.length, 0);
+          done();
+        });
+  });
+
+  test('_getAccountSuggestions non-empty', done => {
+    element
+        ._getAccountSuggestions('test-').then(accounts => {
+          assert.equal(accounts.length, 3);
+          assert.equal(accounts[0].name,
+              'test-account <test.account@example.com>');
+          assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
+          assert.equal(accounts[2].name, 'test-git');
+          done();
+        });
+  });
+
+  test('_getGroupSuggestions empty', done => {
+    element
+        ._getGroupSuggestions('nonexistent').then(groups => {
+          assert.equal(groups.length, 0);
+          done();
+        });
+  });
+
+  test('_getGroupSuggestions non-empty', done => {
+    element
+        ._getGroupSuggestions('test').then(groups => {
+          assert.equal(groups.length, 2);
+          assert.equal(groups[0].name, 'test-admin');
+          assert.equal(groups[1].name, 'test/Administrator (admin)');
+          done();
+        });
+  });
+
+  test('_computeHideItemClass returns string for admin', () => {
+    const admin = true;
+    const owner = false;
+    assert.equal(element._computeHideItemClass(owner, admin), '');
+  });
+
+  test('_computeHideItemClass returns hideItem for admin and owner', () => {
+    const admin = false;
+    const owner = false;
+    assert.equal(element._computeHideItemClass(owner, admin), 'canModify');
+  });
+
+  test('_computeHideItemClass returns string for owner', () => {
+    const admin = false;
+    const owner = true;
+    assert.equal(element._computeHideItemClass(owner, admin), '');
+  });
+
+  test('delete member', () => {
+    const deleteBtns = dom(element.root)
+        .querySelectorAll('.deleteMembersButton');
+    MockInteractions.tap(deleteBtns[0]);
+    assert.equal(element._itemId, '1000097');
+    assert.equal(element._itemName, 'jane');
+    MockInteractions.tap(deleteBtns[1]);
+    assert.equal(element._itemId, '1000096');
+    assert.equal(element._itemName, 'Test User');
+    MockInteractions.tap(deleteBtns[2]);
+    assert.equal(element._itemId, '1000095');
+    assert.equal(element._itemName, 'Gerrit');
+    MockInteractions.tap(deleteBtns[3]);
+    assert.equal(element._itemId, '1000098');
+    assert.equal(element._itemName, '1000098');
+  });
+
+  test('delete included groups', () => {
+    const deleteBtns = dom(element.root)
+        .querySelectorAll('.deleteIncludedGroupButton');
+    MockInteractions.tap(deleteBtns[0]);
+    assert.equal(element._itemId, 'testId');
+    assert.equal(element._itemName, 'testName');
+    MockInteractions.tap(deleteBtns[1]);
+    assert.equal(element._itemId, 'testId2');
+    assert.equal(element._itemName, 'testName2');
+    MockInteractions.tap(deleteBtns[2]);
+    assert.equal(element._itemId, 'testId3');
+    assert.equal(element._itemName, 'testName3');
+  });
+
+  test('_computeLoadingClass', () => {
+    assert.equal(element._computeLoadingClass(true), 'loading');
+
+    assert.equal(element._computeLoadingClass(false), '');
+  });
+
+  test('_computeGroupUrl', () => {
+    assert.isUndefined(element._computeGroupUrl(undefined));
+
+    assert.isUndefined(element._computeGroupUrl(false));
+
+    let url = '#/admin/groups/uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
+    assert.equal(element._computeGroupUrl(url),
+        'https://test/site/admin/groups/' +
+        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498');
+
+    url = 'https://gerrit.local/admin/groups/' +
+        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
+    assert.equal(element._computeGroupUrl(url), url);
+  });
+
+  test('fires page-error', done => {
+    groupStub.restore();
+
+    element.groupId = 1;
+
+    const response = {status: 404};
+    sinon.stub(
+        element.$.restAPI, 'getGroupConfig')
+        .callsFake((group, errFn) => {
+          errFn(response);
+        });
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
+    });
+
+    element._loadGroupDetails();
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
index 1c7cc91..49cd8ea 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 
-import '../../../scripts/bundled-polymer.js';
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/gr-subpage-styles.js';
@@ -43,7 +42,7 @@
 };
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrGroup extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -142,12 +141,12 @@
           this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
 
           promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
-            this._isAdmin = isAdmin ? true : false;
+            this._isAdmin = !!isAdmin;
           }));
 
           promises.push(this.$.restAPI.getIsGroupOwner(config.name)
               .then(isOwner => {
-                this._groupOwner = isOwner ? true : false;
+                this._groupOwner = !!isOwner;
               }));
 
           // If visible to all is undefined, set to false. If it is defined
@@ -262,7 +261,7 @@
   }
 
   _computeGroupDisabled(owner, admin, groupIsInternal) {
-    return groupIsInternal && (admin || owner) ? false : true;
+    return !(groupIsInternal && (admin || owner));
   }
 
   _getGroupUUID(id) {
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js
deleted file mode 100644
index e11f989..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.js
+++ /dev/null
@@ -1,163 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    h3.edited:after {
-      color: var(--deemphasized-text-color);
-      content: ' *';
-    }
-    .inputUpdateBtn {
-      margin-top: var(--spacing-s);
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <main class="gr-form-styles read-only">
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h1 id="Title">[[_groupName]]</h1>
-      <h2 id="configurations">General</h2>
-      <div id="form">
-        <fieldset>
-          <h3 id="groupUUID">Group UUID</h3>
-          <fieldset>
-            <gr-copy-clipboard
-              id="uuid"
-              text="[[_getGroupUUID(_groupConfig.id)]]"
-            ></gr-copy-clipboard>
-          </fieldset>
-          <h3 id="groupName" class$="[[_computeHeaderClass(_rename)]]">
-            Group Name
-          </h3>
-          <fieldset>
-            <span class="value">
-              <gr-autocomplete
-                id="groupNameInput"
-                text="{{_groupConfig.name}}"
-                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-              ></gr-autocomplete>
-            </span>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button
-                id="inputUpdateNameBtn"
-                on-click="_handleSaveName"
-                disabled="[[!_rename]]"
-              >
-                Rename Group</gr-button
-              >
-            </span>
-          </fieldset>
-          <h3 id="groupOwner" class$="[[_computeHeaderClass(_owner)]]">
-            Owners
-          </h3>
-          <fieldset>
-            <span class="value">
-              <gr-autocomplete
-                id="groupOwnerInput"
-                text="{{_groupConfig.owner}}"
-                value="{{_groupConfigOwner}}"
-                query="[[_query]]"
-                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-              >
-              </gr-autocomplete>
-            </span>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button
-                id="inputUpdateOwnerBtn"
-                on-click="_handleSaveOwner"
-                disabled="[[!_owner]]"
-              >
-                Change Owners</gr-button
-              >
-            </span>
-          </fieldset>
-          <h3 class$="[[_computeHeaderClass(_description)]]">
-            Description
-          </h3>
-          <fieldset>
-            <div>
-              <iron-autogrow-textarea
-                class="description"
-                autocomplete="on"
-                bind-value="{{_groupConfig.description}}"
-                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-              ></iron-autogrow-textarea>
-            </div>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button
-                on-click="_handleSaveDescription"
-                disabled="[[!_description]]"
-              >
-                Save Description
-              </gr-button>
-            </span>
-          </fieldset>
-          <h3 id="options" class$="[[_computeHeaderClass(_options)]]">
-            Group Options
-          </h3>
-          <fieldset id="visableToAll">
-            <section>
-              <span class="title">
-                Make group visible to all registered users
-              </span>
-              <span class="value">
-                <gr-select
-                  id="visibleToAll"
-                  bind-value="{{_groupConfig.options.visible_to_all}}"
-                >
-                  <select
-                    disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-                  >
-                    <template is="dom-repeat" items="[[_submitTypes]]">
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <span
-              class="value"
-              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-            >
-              <gr-button on-click="_handleSaveOptions" disabled="[[!_options]]">
-                Save Group Options
-              </gr-button>
-            </span>
-          </fieldset>
-        </fieldset>
-      </div>
-    </div>
-  </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
new file mode 100644
index 0000000..aed73bf
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_html.ts
@@ -0,0 +1,169 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-subpage-styles">
+    h3.edited:after {
+      color: var(--deemphasized-text-color);
+      content: ' *';
+    }
+    .inputUpdateBtn {
+      margin-top: var(--spacing-s);
+    }
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <main class="gr-form-styles read-only">
+    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+      Loading...
+    </div>
+    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <h1 id="Title" class="heading-1">[[_groupName]]</h1>
+      <h2 id="configurations" class="heading-2">General</h2>
+      <div id="form">
+        <fieldset>
+          <h3 id="groupUUID" class="heading-3">Group UUID</h3>
+          <fieldset>
+            <gr-copy-clipboard
+              id="uuid"
+              text="[[_getGroupUUID(_groupConfig.id)]]"
+            ></gr-copy-clipboard>
+          </fieldset>
+          <h3
+            id="groupName"
+            class$="heading-3 [[_computeHeaderClass(_rename)]]"
+          >
+            Group Name
+          </h3>
+          <fieldset>
+            <span class="value">
+              <gr-autocomplete
+                id="groupNameInput"
+                text="{{_groupConfig.name}}"
+                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+              ></gr-autocomplete>
+            </span>
+            <span
+              class="value"
+              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+            >
+              <gr-button
+                id="inputUpdateNameBtn"
+                on-click="_handleSaveName"
+                disabled="[[!_rename]]"
+              >
+                Rename Group</gr-button
+              >
+            </span>
+          </fieldset>
+          <h3
+            id="groupOwner"
+            class$="heading-3 [[_computeHeaderClass(_owner)]]"
+          >
+            Owners
+          </h3>
+          <fieldset>
+            <span class="value">
+              <gr-autocomplete
+                id="groupOwnerInput"
+                text="{{_groupConfig.owner}}"
+                value="{{_groupConfigOwner}}"
+                query="[[_query]]"
+                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+              >
+              </gr-autocomplete>
+            </span>
+            <span
+              class="value"
+              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+            >
+              <gr-button
+                id="inputUpdateOwnerBtn"
+                on-click="_handleSaveOwner"
+                disabled="[[!_owner]]"
+              >
+                Change Owners</gr-button
+              >
+            </span>
+          </fieldset>
+          <h3 class$="heading-3 [[_computeHeaderClass(_description)]]">
+            Description
+          </h3>
+          <fieldset>
+            <div>
+              <iron-autogrow-textarea
+                class="description"
+                autocomplete="on"
+                bind-value="{{_groupConfig.description}}"
+                disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+              ></iron-autogrow-textarea>
+            </div>
+            <span
+              class="value"
+              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+            >
+              <gr-button
+                on-click="_handleSaveDescription"
+                disabled="[[!_description]]"
+              >
+                Save Description
+              </gr-button>
+            </span>
+          </fieldset>
+          <h3 id="options" class$="heading-3 [[_computeHeaderClass(_options)]]">
+            Group Options
+          </h3>
+          <fieldset>
+            <section>
+              <span class="title">
+                Make group visible to all registered users
+              </span>
+              <span class="value">
+                <gr-select
+                  id="visibleToAll"
+                  bind-value="{{_groupConfig.options.visible_to_all}}"
+                >
+                  <select
+                    disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+                  >
+                    <template is="dom-repeat" items="[[_submitTypes]]">
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <span
+              class="value"
+              disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
+            >
+              <gr-button on-click="_handleSaveOptions" disabled="[[!_options]]">
+                Save Group Options
+              </gr-button>
+            </span>
+          </fieldset>
+        </fieldset>
+      </div>
+    </div>
+  </main>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
deleted file mode 100644
index 5621fff..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
+++ /dev/null
@@ -1,289 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-group</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-group></gr-group>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-group.js';
-suite('gr-group tests', () => {
-  let element;
-  let sandbox;
-  let groupStub;
-  const group = {
-    id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    url: '#/admin/groups/uuid-6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    options: {},
-    description: 'Gerrit Site Administrators',
-    group_id: 1,
-    owner: 'Administrators',
-    owner_id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    name: 'Administrators',
-  };
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-    });
-    element = fixture('basic');
-    groupStub = sandbox.stub(
-        element.$.restAPI,
-        'getGroupConfig',
-        () => Promise.resolve(group)
-    );
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('loading displays before group config is loaded', () => {
-    assert.isTrue(element.$.loading.classList.contains('loading'));
-    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
-    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
-    assert.isTrue(getComputedStyle(element.$.loadedContent)
-        .display === 'none');
-  });
-
-  test('default values are populated with internal group', done => {
-    sandbox.stub(
-        element.$.restAPI,
-        'getIsGroupOwner',
-        () => Promise.resolve(true));
-    element.groupId = 1;
-    element._loadGroup().then(() => {
-      assert.isTrue(element._groupIsInternal);
-      assert.isFalse(element.$.visibleToAll.bindValue);
-      done();
-    });
-  });
-
-  test('default values with external group', done => {
-    const groupExternal = Object.assign({}, group);
-    groupExternal.id = 'external-group-id';
-    groupStub.restore();
-    groupStub = sandbox.stub(
-        element.$.restAPI,
-        'getGroupConfig',
-        () => Promise.resolve(groupExternal));
-    sandbox.stub(
-        element.$.restAPI,
-        'getIsGroupOwner',
-        () => Promise.resolve(true));
-    element.groupId = 1;
-    element._loadGroup().then(() => {
-      assert.isFalse(element._groupIsInternal);
-      assert.isFalse(element.$.visibleToAll.bindValue);
-      done();
-    });
-  });
-
-  test('rename group', done => {
-    const groupName = 'test-group';
-    const groupName2 = 'test-group2';
-    element.groupId = 1;
-    element._groupConfig = {
-      name: groupName,
-    };
-    element._groupName = groupName;
-
-    sandbox.stub(
-        element.$.restAPI,
-        'getIsGroupOwner',
-        () => Promise.resolve(true));
-
-    sandbox.stub(
-        element.$.restAPI,
-        'saveGroupName',
-        () => Promise.resolve({status: 200}));
-
-    const button = element.$.inputUpdateNameBtn;
-
-    element._loadGroup().then(() => {
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
-
-      element.$.groupNameInput.text = groupName2;
-
-      assert.isFalse(button.hasAttribute('disabled'));
-      assert.isTrue(element.$.groupName.classList.contains('edited'));
-
-      element._handleSaveName().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-        assert.equal(element._groupName, groupName2);
-        done();
-      });
-    });
-  });
-
-  test('rename group owner', done => {
-    const groupName = 'test-group';
-    element.groupId = 1;
-    element._groupConfig = {
-      name: groupName,
-    };
-    element._groupConfigOwner = 'testId';
-    element._groupOwner = true;
-
-    sandbox.stub(
-        element.$.restAPI,
-        'getIsGroupOwner',
-        () => Promise.resolve({status: 200}));
-
-    const button = element.$.inputUpdateOwnerBtn;
-
-    element._loadGroup().then(() => {
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
-
-      element.$.groupOwnerInput.text = 'testId2';
-
-      assert.isFalse(button.hasAttribute('disabled'));
-      assert.isTrue(element.$.groupOwner.classList.contains('edited'));
-
-      element._handleSaveOwner().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-        done();
-      });
-    });
-  });
-
-  test('test for undefined group name', done => {
-    groupStub.restore();
-
-    sandbox.stub(
-        element.$.restAPI,
-        'getGroupConfig',
-        () => Promise.resolve({}));
-
-    assert.isUndefined(element.groupId);
-
-    element.groupId = 1;
-
-    assert.isDefined(element.groupId);
-
-    // Test that loading shows instead of filling
-    // in group details
-    element._loadGroup().then(() => {
-      assert.isTrue(element.$.loading.classList.contains('loading'));
-
-      assert.isTrue(element._loading);
-
-      done();
-    });
-  });
-
-  test('test fire event', done => {
-    element._groupConfig = {
-      name: 'test-group',
-    };
-
-    sandbox.stub(element.$.restAPI, 'saveGroupName')
-        .returns(Promise.resolve({status: 200}));
-
-    const showStub = sandbox.stub(element, 'dispatchEvent');
-    element._handleSaveName()
-        .then(() => {
-          assert.isTrue(showStub.called);
-          done();
-        });
-  });
-
-  test('_computeGroupDisabled', () => {
-    let admin = true;
-    let owner = false;
-    let groupIsInternal = true;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), false);
-
-    admin = false;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-
-    owner = true;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), false);
-
-    owner = false;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-
-    groupIsInternal = false;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-
-    admin = true;
-    assert.equal(element._computeGroupDisabled(owner, admin,
-        groupIsInternal), true);
-  });
-
-  test('_computeLoadingClass', () => {
-    assert.equal(element._computeLoadingClass(true), 'loading');
-    assert.equal(element._computeLoadingClass(false), '');
-  });
-
-  test('fires page-error', done => {
-    groupStub.restore();
-
-    element.groupId = 1;
-
-    const response = {status: 404};
-    sandbox.stub(
-        element.$.restAPI, 'getGroupConfig', (group, errFn) => {
-          errFn(response);
-        });
-
-    element.addEventListener('page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      done();
-    });
-
-    element._loadGroup();
-  });
-
-  test('uuid', () => {
-    element._groupConfig = {
-      id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
-    };
-
-    assert.equal(element._groupConfig.id, element.$.uuid.text);
-
-    element._groupConfig = {
-      id: 'user%2Fgroup',
-    };
-
-    assert.equal('user/group', element.$.uuid.text);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
new file mode 100644
index 0000000..479f2b1
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
@@ -0,0 +1,269 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-group.js';
+
+const basicFixture = fixtureFromElement('gr-group');
+
+suite('gr-group tests', () => {
+  let element;
+
+  let groupStub;
+  const group = {
+    id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    url: '#/admin/groups/uuid-6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    options: {},
+    description: 'Gerrit Site Administrators',
+    group_id: 1,
+    owner: 'Administrators',
+    owner_id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    name: 'Administrators',
+  };
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+    });
+    element = basicFixture.instantiate();
+    groupStub = sinon.stub(
+        element.$.restAPI,
+        'getGroupConfig')
+        .callsFake(() => Promise.resolve(group));
+  });
+
+  test('loading displays before group config is loaded', () => {
+    assert.isTrue(element.$.loading.classList.contains('loading'));
+    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
+    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
+    assert.isTrue(getComputedStyle(element.$.loadedContent)
+        .display === 'none');
+  });
+
+  test('default values are populated with internal group', done => {
+    sinon.stub(
+        element.$.restAPI,
+        'getIsGroupOwner')
+        .callsFake(() => Promise.resolve(true));
+    element.groupId = 1;
+    element._loadGroup().then(() => {
+      assert.isTrue(element._groupIsInternal);
+      assert.isFalse(element.$.visibleToAll.bindValue);
+      done();
+    });
+  });
+
+  test('default values with external group', done => {
+    const groupExternal = {...group};
+    groupExternal.id = 'external-group-id';
+    groupStub.restore();
+    groupStub = sinon.stub(
+        element.$.restAPI,
+        'getGroupConfig')
+        .callsFake(() => Promise.resolve(groupExternal));
+    sinon.stub(
+        element.$.restAPI,
+        'getIsGroupOwner')
+        .callsFake(() => Promise.resolve(true));
+    element.groupId = 1;
+    element._loadGroup().then(() => {
+      assert.isFalse(element._groupIsInternal);
+      assert.isFalse(element.$.visibleToAll.bindValue);
+      done();
+    });
+  });
+
+  test('rename group', done => {
+    const groupName = 'test-group';
+    const groupName2 = 'test-group2';
+    element.groupId = 1;
+    element._groupConfig = {
+      name: groupName,
+    };
+    element._groupName = groupName;
+
+    sinon.stub(
+        element.$.restAPI,
+        'getIsGroupOwner')
+        .callsFake(() => Promise.resolve(true));
+
+    sinon.stub(
+        element.$.restAPI,
+        'saveGroupName')
+        .callsFake(() => Promise.resolve({status: 200}));
+
+    const button = element.$.inputUpdateNameBtn;
+
+    element._loadGroup().then(() => {
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+
+      element.$.groupNameInput.text = groupName2;
+
+      assert.isFalse(button.hasAttribute('disabled'));
+      assert.isTrue(element.$.groupName.classList.contains('edited'));
+
+      element._handleSaveName().then(() => {
+        assert.isTrue(button.hasAttribute('disabled'));
+        assert.isFalse(element.$.Title.classList.contains('edited'));
+        assert.equal(element._groupName, groupName2);
+        done();
+      });
+    });
+  });
+
+  test('rename group owner', done => {
+    const groupName = 'test-group';
+    element.groupId = 1;
+    element._groupConfig = {
+      name: groupName,
+    };
+    element._groupConfigOwner = 'testId';
+    element._groupOwner = true;
+
+    sinon.stub(
+        element.$.restAPI,
+        'getIsGroupOwner')
+        .callsFake(() => Promise.resolve({status: 200}));
+
+    const button = element.$.inputUpdateOwnerBtn;
+
+    element._loadGroup().then(() => {
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(element.$.Title.classList.contains('edited'));
+
+      element.$.groupOwnerInput.text = 'testId2';
+
+      assert.isFalse(button.hasAttribute('disabled'));
+      assert.isTrue(element.$.groupOwner.classList.contains('edited'));
+
+      element._handleSaveOwner().then(() => {
+        assert.isTrue(button.hasAttribute('disabled'));
+        assert.isFalse(element.$.Title.classList.contains('edited'));
+        done();
+      });
+    });
+  });
+
+  test('test for undefined group name', done => {
+    groupStub.restore();
+
+    sinon.stub(
+        element.$.restAPI,
+        'getGroupConfig')
+        .callsFake(() => Promise.resolve({}));
+
+    assert.isUndefined(element.groupId);
+
+    element.groupId = 1;
+
+    assert.isDefined(element.groupId);
+
+    // Test that loading shows instead of filling
+    // in group details
+    element._loadGroup().then(() => {
+      assert.isTrue(element.$.loading.classList.contains('loading'));
+
+      assert.isTrue(element._loading);
+
+      done();
+    });
+  });
+
+  test('test fire event', done => {
+    element._groupConfig = {
+      name: 'test-group',
+    };
+
+    sinon.stub(element.$.restAPI, 'saveGroupName')
+        .returns(Promise.resolve({status: 200}));
+
+    const showStub = sinon.stub(element, 'dispatchEvent');
+    element._handleSaveName()
+        .then(() => {
+          assert.isTrue(showStub.called);
+          done();
+        });
+  });
+
+  test('_computeGroupDisabled', () => {
+    let admin = true;
+    let owner = false;
+    let groupIsInternal = true;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), false);
+
+    admin = false;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), true);
+
+    owner = true;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), false);
+
+    owner = false;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), true);
+
+    groupIsInternal = false;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), true);
+
+    admin = true;
+    assert.equal(element._computeGroupDisabled(owner, admin,
+        groupIsInternal), true);
+  });
+
+  test('_computeLoadingClass', () => {
+    assert.equal(element._computeLoadingClass(true), 'loading');
+    assert.equal(element._computeLoadingClass(false), '');
+  });
+
+  test('fires page-error', done => {
+    groupStub.restore();
+
+    element.groupId = 1;
+
+    const response = {status: 404};
+    sinon.stub(
+        element.$.restAPI, 'getGroupConfig').callsFake((group, errFn) => {
+      errFn(response);
+    });
+
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
+    });
+
+    element._loadGroup();
+  });
+
+  test('uuid', () => {
+    element._groupConfig = {
+      id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+    };
+
+    assert.equal(element._groupConfig.id, element.$.uuid.text);
+
+    element._groupConfig = {
+      id: 'user%2Fgroup',
+    };
+
+    assert.equal('user/group', element.$.uuid.text);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
index ea4e05c..9675c92 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 
 import '@polymer/paper-toggle-button/paper-toggle-button.js';
 import '../../../styles/gr-form-styles.js';
@@ -25,12 +24,11 @@
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-rule-editor/gr-rule-editor.js';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-permission_html.js';
-import {AccessBehavior} from '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
+import {toSortedPermissionsArray} from '../../../utils/access-util.js';
 
 const MAX_AUTOCOMPLETE_RESULTS = 20;
 
@@ -48,13 +46,11 @@
  * Fired when a permission that was previously added was removed.
  *
  * @event added-permission-removed
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrPermission extends mixinBehaviors( [
-  AccessBehavior,
-], GestureEventListeners(
+class GrPermission extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-permission'; }
@@ -183,7 +179,7 @@
   }
 
   _sortPermission(permission) {
-    this._rules = this.toSortedArray(permission.value.rules);
+    this._rules = toSortedPermissionsArray(permission.value.rules);
   }
 
   _computeSectionClass(editing, deleted) {
@@ -211,11 +207,10 @@
     // It is possible to have a label name that is not included in the
     // 'labels' object. In this case, treat it like anything else.
     if (!labels[labelName]) { return; }
-    const label = {
+    return {
       name: labelName,
       values: this._computeLabelValues(labels[labelName].values),
     };
-    return label;
   }
 
   _computeLabelValues(values) {
@@ -299,7 +294,7 @@
     }
 
     // Wait for new rule to get value populated via gr-rule-editor, and then
-    // add to permission values as well, so that the change gets propogated
+    // add to permission values as well, so that the change gets propagated
     // back to the section. Since the rule is inside a dom-repeat, a flush
     // is needed.
     flush();
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js
deleted file mode 100644
index ed4f64a..0000000
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.js
+++ /dev/null
@@ -1,144 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-m);
-    }
-    .header {
-      align-items: baseline;
-      display: flex;
-      justify-content: space-between;
-      margin: var(--spacing-s) var(--spacing-m);
-    }
-    .rules {
-      background: var(--table-header-background-color);
-      border: 1px solid var(--border-color);
-      border-bottom: 0;
-    }
-    .editing .rules {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .title {
-      margin-bottom: var(--spacing-s);
-    }
-    #addRule,
-    #removeBtn {
-      display: none;
-    }
-    .right {
-      display: flex;
-      align-items: center;
-    }
-    .editing #removeBtn {
-      display: block;
-      margin-left: var(--spacing-xl);
-    }
-    .editing #addRule {
-      display: block;
-      padding: var(--spacing-m);
-    }
-    #deletedContainer,
-    .deleted #mainContainer {
-      display: none;
-    }
-    .deleted #deletedContainer {
-      align-items: baseline;
-      border: 1px solid var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-m);
-    }
-    #mainContainer {
-      display: block;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <section
-    id="permission"
-    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
-  >
-    <div id="mainContainer">
-      <div class="header">
-        <span class="title">[[name]]</span>
-        <div class="right">
-          <template
-            is="dom-if"
-            if="[[!_permissionIsOwnerOrGlobal(permission.id, section)]]"
-          >
-            <paper-toggle-button
-              id="exclusiveToggle"
-              checked="{{permission.value.exclusive}}"
-              on-change="_handleValueChange"
-              disabled$="[[!editing]]"
-              on-tap="_onTapExclusiveToggle"
-            ></paper-toggle-button
-            >Exclusive
-          </template>
-          <gr-button link="" id="removeBtn" on-click="_handleRemovePermission"
-            >Remove</gr-button
-          >
-        </div>
-      </div>
-      <!-- end header -->
-      <div class="rules">
-        <template is="dom-repeat" items="{{_rules}}" as="rule">
-          <gr-rule-editor
-            has-range="[[_computeHasRange(name)]]"
-            label="[[_label]]"
-            editing="[[editing]]"
-            group-id="[[rule.id]]"
-            group-name="[[_computeGroupName(groups, rule.id)]]"
-            permission="[[permission.id]]"
-            rule="{{rule}}"
-            section="[[section]]"
-            on-added-rule-removed="_handleAddedRuleRemoved"
-          ></gr-rule-editor>
-        </template>
-        <div id="addRule">
-          <gr-autocomplete
-            id="groupAutocomplete"
-            text="{{_groupFilter}}"
-            query="[[_query]]"
-            placeholder="Add group"
-            on-commit="_handleAddRuleItem"
-          >
-          </gr-autocomplete>
-        </div>
-        <!-- end addRule -->
-      </div>
-      <!-- end rules -->
-    </div>
-    <!-- end mainContainer -->
-    <div id="deletedContainer">
-      <span>[[name]] was deleted</span>
-      <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
-        >Undo</gr-button
-      >
-    </div>
-    <!-- end deletedContainer -->
-  </section>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
new file mode 100644
index 0000000..9795c92
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
@@ -0,0 +1,144 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-m);
+    }
+    .header {
+      align-items: baseline;
+      display: flex;
+      justify-content: space-between;
+      margin: var(--spacing-s) var(--spacing-m);
+    }
+    .rules {
+      background: var(--table-header-background-color);
+      border: 1px solid var(--border-color);
+      border-bottom: 0;
+    }
+    .editing .rules {
+      border-bottom: 1px solid var(--border-color);
+    }
+    .title {
+      margin-bottom: var(--spacing-s);
+    }
+    #addRule,
+    #removeBtn {
+      display: none;
+    }
+    .right {
+      display: flex;
+      align-items: center;
+    }
+    .editing #removeBtn {
+      display: block;
+      margin-left: var(--spacing-xl);
+    }
+    .editing #addRule {
+      display: block;
+      padding: var(--spacing-m);
+    }
+    #deletedContainer,
+    .deleted #mainContainer {
+      display: none;
+    }
+    .deleted #deletedContainer {
+      align-items: baseline;
+      border: 1px solid var(--border-color);
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-m);
+    }
+    #mainContainer {
+      display: block;
+    }
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-menu-page-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <section
+    id="permission"
+    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
+  >
+    <div id="mainContainer">
+      <div class="header">
+        <span class="title">[[name]]</span>
+        <div class="right">
+          <template
+            is="dom-if"
+            if="[[!_permissionIsOwnerOrGlobal(permission.id, section)]]"
+          >
+            <paper-toggle-button
+              id="exclusiveToggle"
+              checked="{{permission.value.exclusive}}"
+              on-change="_handleValueChange"
+              disabled$="[[!editing]]"
+              on-tap="_onTapExclusiveToggle"
+            ></paper-toggle-button
+            >Exclusive
+          </template>
+          <gr-button link="" id="removeBtn" on-click="_handleRemovePermission"
+            >Remove</gr-button
+          >
+        </div>
+      </div>
+      <!-- end header -->
+      <div class="rules">
+        <template is="dom-repeat" items="{{_rules}}" as="rule">
+          <gr-rule-editor
+            has-range="[[_computeHasRange(name)]]"
+            label="[[_label]]"
+            editing="[[editing]]"
+            group-id="[[rule.id]]"
+            group-name="[[_computeGroupName(groups, rule.id)]]"
+            permission="[[permission.id]]"
+            rule="{{rule}}"
+            section="[[section]]"
+            on-added-rule-removed="_handleAddedRuleRemoved"
+          ></gr-rule-editor>
+        </template>
+        <div id="addRule">
+          <gr-autocomplete
+            id="groupAutocomplete"
+            text="{{_groupFilter}}"
+            query="[[_query]]"
+            placeholder="Add group"
+            on-commit="_handleAddRuleItem"
+          >
+          </gr-autocomplete>
+        </div>
+        <!-- end addRule -->
+      </div>
+      <!-- end rules -->
+    </div>
+    <!-- end mainContainer -->
+    <div id="deletedContainer">
+      <span>[[name]] was deleted</span>
+      <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
+        >Undo</gr-button
+      >
+    </div>
+    <!-- end deletedContainer -->
+  </section>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
deleted file mode 100644
index 1ce492e..0000000
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
+++ /dev/null
@@ -1,434 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-permission</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-permission></gr-permission>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-permission.js';
-suite('gr-permission tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    sandbox.stub(element.$.restAPI, 'getSuggestedGroups').returns(
-        Promise.resolve({
-          'Administrators': {
-            id: '4c97682e6ce61b7247f3381b6f1789356666de7f',
-          },
-          'Anonymous Users': {
-            id: 'global%3AAnonymous-Users',
-          },
-        }));
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('unit tests', () => {
-    test('_sortPermission', () => {
-      const permission = {
-        id: 'submit',
-        value: {
-          rules: {
-            'global:Project-Owners': {
-              action: 'ALLOW',
-              force: false,
-            },
-            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-              action: 'ALLOW',
-              force: false,
-            },
-          },
-        },
-      };
-
-      const expectedRules = [
-        {
-          id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
-          value: {action: 'ALLOW', force: false},
-        },
-        {
-          id: 'global:Project-Owners',
-          value: {action: 'ALLOW', force: false},
-        },
-      ];
-
-      element._sortPermission(permission);
-      assert.deepEqual(element._rules, expectedRules);
-    });
-
-    test('_computeLabel and _computeLabelValues', () => {
-      const labels = {
-        'Code-Review': {
-          default_value: 0,
-          values: {
-            ' 0': 'No score',
-            '-1': 'I would prefer this is not merged as is',
-            '-2': 'This shall not be merged',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-        },
-      };
-      let permission = {
-        id: 'label-Code-Review',
-        value: {
-          label: 'Code-Review',
-          rules: {
-            'global:Project-Owners': {
-              action: 'ALLOW',
-              force: false,
-              min: -2,
-              max: 2,
-            },
-            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-              action: 'ALLOW',
-              force: false,
-              min: -2,
-              max: 2,
-            },
-          },
-        },
-      };
-
-      const expectedLabelValues = [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: 0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ];
-
-      const expectedLabel = {
-        name: 'Code-Review',
-        values: expectedLabelValues,
-      };
-
-      assert.deepEqual(element._computeLabelValues(
-          labels['Code-Review'].values), expectedLabelValues);
-
-      assert.deepEqual(element._computeLabel(permission, labels),
-          expectedLabel);
-
-      permission = {
-        id: 'label-reviewDB',
-        value: {
-          label: 'reviewDB',
-          rules: {
-            'global:Project-Owners': {
-              action: 'ALLOW',
-              force: false,
-            },
-            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-              action: 'ALLOW',
-              force: false,
-            },
-          },
-        },
-      };
-
-      assert.isNotOk(element._computeLabel(permission, labels));
-    });
-
-    test('_computeSectionClass', () => {
-      let deleted = true;
-      let editing = false;
-      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
-      deleted = false;
-      assert.equal(element._computeSectionClass(editing, deleted), '');
-
-      editing = true;
-      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
-      deleted = true;
-      assert.equal(element._computeSectionClass(editing, deleted),
-          'editing deleted');
-    });
-
-    test('_computeGroupName', () => {
-      const groups = {
-        abc123: {name: 'test group'},
-        bcd234: {},
-      };
-      assert.equal(element._computeGroupName(groups, 'abc123'), 'test group');
-      assert.equal(element._computeGroupName(groups, 'bcd234'), 'bcd234');
-    });
-
-    test('_computeGroupsWithRules', () => {
-      const rules = [
-        {
-          id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
-          value: {action: 'ALLOW', force: false},
-        },
-        {
-          id: 'global:Project-Owners',
-          value: {action: 'ALLOW', force: false},
-        },
-      ];
-      const groupsWithRules = {
-        '4c97682e6ce6b7247f3381b6f1789356666de7f': true,
-        'global:Project-Owners': true,
-      };
-      assert.deepEqual(element._computeGroupsWithRules(rules),
-          groupsWithRules);
-    });
-
-    test('_getGroupSuggestions without existing rules', done => {
-      element._groupsWithRules = {};
-
-      element._getGroupSuggestions().then(groups => {
-        assert.deepEqual(groups, [
-          {
-            name: 'Administrators',
-            value: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f'},
-          }, {
-            name: 'Anonymous Users',
-            value: {id: 'global%3AAnonymous-Users'},
-          },
-        ]);
-        done();
-      });
-    });
-
-    test('_getGroupSuggestions with existing rules filters them', done => {
-      element._groupsWithRules = {
-        '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
-      };
-
-      element._getGroupSuggestions().then(groups => {
-        assert.deepEqual(groups, [{
-          name: 'Anonymous Users',
-          value: {id: 'global%3AAnonymous-Users'},
-        }]);
-        done();
-      });
-    });
-
-    test('_handleRemovePermission', () => {
-      element.editing = true;
-      element.permission = {value: {rules: {}}};
-      element._handleRemovePermission();
-      assert.isTrue(element._deleted);
-      assert.isTrue(element.permission.value.deleted);
-
-      element.editing = false;
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.permission.value.deleted);
-    });
-
-    test('_handleUndoRemove', () => {
-      element.permission = {value: {deleted: true, rules: {}}};
-      element._handleUndoRemove();
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.permission.value.deleted);
-    });
-
-    test('_computeHasRange', () => {
-      assert.isTrue(element._computeHasRange('Query Limit'));
-
-      assert.isTrue(element._computeHasRange('Batch Changes Limit'));
-
-      assert.isFalse(element._computeHasRange('test'));
-    });
-  });
-
-  suite('interactions', () => {
-    setup(() => {
-      sandbox.spy(element, '_computeLabel');
-      element.name = 'Priority';
-      element.section = 'refs/*';
-      element.labels = {
-        'Code-Review': {
-          values: {
-            ' 0': 'No score',
-            '-1': 'I would prefer this is not merged as is',
-            '-2': 'This shall not be merged',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          default_value: 0,
-        },
-      };
-      element.permission = {
-        id: 'label-Code-Review',
-        value: {
-          label: 'Code-Review',
-          rules: {
-            'global:Project-Owners': {
-              action: 'ALLOW',
-              force: false,
-              min: -2,
-              max: 2,
-            },
-            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-              action: 'ALLOW',
-              force: false,
-              min: -2,
-              max: 2,
-            },
-          },
-        },
-      };
-      element._setupValues();
-      flushAsynchronousOperations();
-    });
-
-    test('adding a rule', () => {
-      element.name = 'Priority';
-      element.section = 'refs/*';
-      element.groups = {};
-      element.$.groupAutocomplete.text = 'ldap/tests te.st';
-      const e = {
-        detail: {
-          value: {
-            id: 'ldap:CN=test+te.st',
-          },
-        },
-      };
-      element.editing = true;
-      assert.equal(element._rules.length, 2);
-      assert.equal(Object.keys(element._groupsWithRules).length, 2);
-      element._handleAddRuleItem(e);
-      flushAsynchronousOperations();
-      assert.deepEqual(element.groups, {'ldap:CN=test te.st': {
-        name: 'ldap/tests te.st'}});
-      assert.equal(element._rules.length, 3);
-      assert.equal(Object.keys(element._groupsWithRules).length, 3);
-      assert.deepEqual(element.permission.value.rules['ldap:CN=test te.st'],
-          {action: 'ALLOW', min: -2, max: 2, added: true});
-      // New rule should be removed if cancel from editing.
-      element.editing = false;
-      assert.equal(element._rules.length, 2);
-      assert.equal(Object.keys(element.permission.value.rules).length, 2);
-    });
-
-    test('removing an added rule', () => {
-      element.name = 'Priority';
-      element.section = 'refs/*';
-      element.groups = {};
-      element.$.groupAutocomplete.text = 'new group name';
-      assert.equal(element._rules.length, 2);
-      element.shadowRoot
-          .querySelector('gr-rule-editor').dispatchEvent(
-              new CustomEvent('added-rule-removed', {
-                composed: true, bubbles: true,
-              }));
-      flushAsynchronousOperations();
-      assert.equal(element._rules.length, 1);
-    });
-
-    test('removing an added permission', () => {
-      const removeStub = sandbox.stub();
-      element.addEventListener('added-permission-removed', removeStub);
-      element.editing = true;
-      element.name = 'Priority';
-      element.section = 'refs/*';
-      element.permission.value.added = true;
-      MockInteractions.tap(element.$.removeBtn);
-      assert.isTrue(removeStub.called);
-    });
-
-    test('removing the permission', () => {
-      element.editing = true;
-      element.name = 'Priority';
-      element.section = 'refs/*';
-
-      const removeStub = sandbox.stub();
-      element.addEventListener('added-permission-removed', removeStub);
-
-      assert.isFalse(element.$.permission.classList.contains('deleted'));
-      assert.isFalse(element._deleted);
-      MockInteractions.tap(element.$.removeBtn);
-      assert.isTrue(element.$.permission.classList.contains('deleted'));
-      assert.isTrue(element._deleted);
-      MockInteractions.tap(element.$.undoRemoveBtn);
-      assert.isFalse(element.$.permission.classList.contains('deleted'));
-      assert.isFalse(element._deleted);
-      assert.isFalse(removeStub.called);
-    });
-
-    test('modify a permission', () => {
-      element.editing = true;
-      element.name = 'Priority';
-      element.section = 'refs/*';
-
-      assert.isFalse(element._originalExclusiveValue);
-      assert.isNotOk(element.permission.value.modified);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#exclusiveToggle'));
-      flushAsynchronousOperations();
-      assert.isTrue(element.permission.value.exclusive);
-      assert.isTrue(element.permission.value.modified);
-      assert.isFalse(element._originalExclusiveValue);
-      element.editing = false;
-      assert.isFalse(element.permission.value.exclusive);
-    });
-
-    test('_handleValueChange', () => {
-      const modifiedHandler = sandbox.stub();
-      element.permission = {value: {rules: {}}};
-      element.addEventListener('access-modified', modifiedHandler);
-      assert.isNotOk(element.permission.value.modified);
-      element._handleValueChange();
-      assert.isTrue(element.permission.value.modified);
-      assert.isTrue(modifiedHandler.called);
-    });
-
-    test('Exclusive hidden for owner permission', () => {
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#exclusiveToggle')).display,
-      'flex');
-      element.set(['permission', 'id'], 'owner');
-      flushAsynchronousOperations();
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#exclusiveToggle')).display,
-      'none');
-    });
-
-    test('Exclusive hidden for any global permissions', () => {
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#exclusiveToggle')).display,
-      'flex');
-      element.section = 'GLOBAL_CAPABILITIES';
-      flushAsynchronousOperations();
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#exclusiveToggle')).display,
-      'none');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
new file mode 100644
index 0000000..835c90a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
@@ -0,0 +1,413 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-permission.js';
+
+const basicFixture = fixtureFromElement('gr-permission');
+
+suite('gr-permission tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    sinon.stub(element.$.restAPI, 'getSuggestedGroups').returns(
+        Promise.resolve({
+          'Administrators': {
+            id: '4c97682e6ce61b7247f3381b6f1789356666de7f',
+          },
+          'Anonymous Users': {
+            id: 'global%3AAnonymous-Users',
+          },
+        }));
+  });
+
+  suite('unit tests', () => {
+    test('_sortPermission', () => {
+      const permission = {
+        id: 'submit',
+        value: {
+          rules: {
+            'global:Project-Owners': {
+              action: 'ALLOW',
+              force: false,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: 'ALLOW',
+              force: false,
+            },
+          },
+        },
+      };
+
+      const expectedRules = [
+        {
+          id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
+          value: {action: 'ALLOW', force: false},
+        },
+        {
+          id: 'global:Project-Owners',
+          value: {action: 'ALLOW', force: false},
+        },
+      ];
+
+      element._sortPermission(permission);
+      assert.deepEqual(element._rules, expectedRules);
+    });
+
+    test('_computeLabel and _computeLabelValues', () => {
+      const labels = {
+        'Code-Review': {
+          default_value: 0,
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not merged as is',
+            '-2': 'This shall not be merged',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+        },
+      };
+      let permission = {
+        id: 'label-Code-Review',
+        value: {
+          label: 'Code-Review',
+          rules: {
+            'global:Project-Owners': {
+              action: 'ALLOW',
+              force: false,
+              min: -2,
+              max: 2,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: 'ALLOW',
+              force: false,
+              min: -2,
+              max: 2,
+            },
+          },
+        },
+      };
+
+      const expectedLabelValues = [
+        {value: -2, text: 'This shall not be merged'},
+        {value: -1, text: 'I would prefer this is not merged as is'},
+        {value: 0, text: 'No score'},
+        {value: 1, text: 'Looks good to me, but someone else must approve'},
+        {value: 2, text: 'Looks good to me, approved'},
+      ];
+
+      const expectedLabel = {
+        name: 'Code-Review',
+        values: expectedLabelValues,
+      };
+
+      assert.deepEqual(element._computeLabelValues(
+          labels['Code-Review'].values), expectedLabelValues);
+
+      assert.deepEqual(element._computeLabel(permission, labels),
+          expectedLabel);
+
+      permission = {
+        id: 'label-reviewDB',
+        value: {
+          label: 'reviewDB',
+          rules: {
+            'global:Project-Owners': {
+              action: 'ALLOW',
+              force: false,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: 'ALLOW',
+              force: false,
+            },
+          },
+        },
+      };
+
+      assert.isNotOk(element._computeLabel(permission, labels));
+    });
+
+    test('_computeSectionClass', () => {
+      let deleted = true;
+      let editing = false;
+      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
+
+      deleted = false;
+      assert.equal(element._computeSectionClass(editing, deleted), '');
+
+      editing = true;
+      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+
+      deleted = true;
+      assert.equal(element._computeSectionClass(editing, deleted),
+          'editing deleted');
+    });
+
+    test('_computeGroupName', () => {
+      const groups = {
+        abc123: {name: 'test group'},
+        bcd234: {},
+      };
+      assert.equal(element._computeGroupName(groups, 'abc123'), 'test group');
+      assert.equal(element._computeGroupName(groups, 'bcd234'), 'bcd234');
+    });
+
+    test('_computeGroupsWithRules', () => {
+      const rules = [
+        {
+          id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
+          value: {action: 'ALLOW', force: false},
+        },
+        {
+          id: 'global:Project-Owners',
+          value: {action: 'ALLOW', force: false},
+        },
+      ];
+      const groupsWithRules = {
+        '4c97682e6ce6b7247f3381b6f1789356666de7f': true,
+        'global:Project-Owners': true,
+      };
+      assert.deepEqual(element._computeGroupsWithRules(rules),
+          groupsWithRules);
+    });
+
+    test('_getGroupSuggestions without existing rules', done => {
+      element._groupsWithRules = {};
+
+      element._getGroupSuggestions().then(groups => {
+        assert.deepEqual(groups, [
+          {
+            name: 'Administrators',
+            value: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f'},
+          }, {
+            name: 'Anonymous Users',
+            value: {id: 'global%3AAnonymous-Users'},
+          },
+        ]);
+        done();
+      });
+    });
+
+    test('_getGroupSuggestions with existing rules filters them', done => {
+      element._groupsWithRules = {
+        '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
+      };
+
+      element._getGroupSuggestions().then(groups => {
+        assert.deepEqual(groups, [{
+          name: 'Anonymous Users',
+          value: {id: 'global%3AAnonymous-Users'},
+        }]);
+        done();
+      });
+    });
+
+    test('_handleRemovePermission', () => {
+      element.editing = true;
+      element.permission = {value: {rules: {}}};
+      element._handleRemovePermission();
+      assert.isTrue(element._deleted);
+      assert.isTrue(element.permission.value.deleted);
+
+      element.editing = false;
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.permission.value.deleted);
+    });
+
+    test('_handleUndoRemove', () => {
+      element.permission = {value: {deleted: true, rules: {}}};
+      element._handleUndoRemove();
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.permission.value.deleted);
+    });
+
+    test('_computeHasRange', () => {
+      assert.isTrue(element._computeHasRange('Query Limit'));
+
+      assert.isTrue(element._computeHasRange('Batch Changes Limit'));
+
+      assert.isFalse(element._computeHasRange('test'));
+    });
+  });
+
+  suite('interactions', () => {
+    setup(() => {
+      sinon.spy(element, '_computeLabel');
+      element.name = 'Priority';
+      element.section = 'refs/*';
+      element.labels = {
+        'Code-Review': {
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not merged as is',
+            '-2': 'This shall not be merged',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      };
+      element.permission = {
+        id: 'label-Code-Review',
+        value: {
+          label: 'Code-Review',
+          rules: {
+            'global:Project-Owners': {
+              action: 'ALLOW',
+              force: false,
+              min: -2,
+              max: 2,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: 'ALLOW',
+              force: false,
+              min: -2,
+              max: 2,
+            },
+          },
+        },
+      };
+      element._setupValues();
+      flushAsynchronousOperations();
+    });
+
+    test('adding a rule', () => {
+      element.name = 'Priority';
+      element.section = 'refs/*';
+      element.groups = {};
+      element.$.groupAutocomplete.text = 'ldap/tests te.st';
+      const e = {
+        detail: {
+          value: {
+            id: 'ldap:CN=test+te.st',
+          },
+        },
+      };
+      element.editing = true;
+      assert.equal(element._rules.length, 2);
+      assert.equal(Object.keys(element._groupsWithRules).length, 2);
+      element._handleAddRuleItem(e);
+      flushAsynchronousOperations();
+      assert.deepEqual(element.groups, {'ldap:CN=test te.st': {
+        name: 'ldap/tests te.st'}});
+      assert.equal(element._rules.length, 3);
+      assert.equal(Object.keys(element._groupsWithRules).length, 3);
+      assert.deepEqual(element.permission.value.rules['ldap:CN=test te.st'],
+          {action: 'ALLOW', min: -2, max: 2, added: true});
+      // New rule should be removed if cancel from editing.
+      element.editing = false;
+      assert.equal(element._rules.length, 2);
+      assert.equal(Object.keys(element.permission.value.rules).length, 2);
+    });
+
+    test('removing an added rule', () => {
+      element.name = 'Priority';
+      element.section = 'refs/*';
+      element.groups = {};
+      element.$.groupAutocomplete.text = 'new group name';
+      assert.equal(element._rules.length, 2);
+      element.shadowRoot
+          .querySelector('gr-rule-editor').dispatchEvent(
+              new CustomEvent('added-rule-removed', {
+                composed: true, bubbles: true,
+              }));
+      flushAsynchronousOperations();
+      assert.equal(element._rules.length, 1);
+    });
+
+    test('removing an added permission', () => {
+      const removeStub = sinon.stub();
+      element.addEventListener('added-permission-removed', removeStub);
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*';
+      element.permission.value.added = true;
+      MockInteractions.tap(element.$.removeBtn);
+      assert.isTrue(removeStub.called);
+    });
+
+    test('removing the permission', () => {
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*';
+
+      const removeStub = sinon.stub();
+      element.addEventListener('added-permission-removed', removeStub);
+
+      assert.isFalse(element.$.permission.classList.contains('deleted'));
+      assert.isFalse(element._deleted);
+      MockInteractions.tap(element.$.removeBtn);
+      assert.isTrue(element.$.permission.classList.contains('deleted'));
+      assert.isTrue(element._deleted);
+      MockInteractions.tap(element.$.undoRemoveBtn);
+      assert.isFalse(element.$.permission.classList.contains('deleted'));
+      assert.isFalse(element._deleted);
+      assert.isFalse(removeStub.called);
+    });
+
+    test('modify a permission', () => {
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*';
+
+      assert.isFalse(element._originalExclusiveValue);
+      assert.isNotOk(element.permission.value.modified);
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#exclusiveToggle'));
+      flushAsynchronousOperations();
+      assert.isTrue(element.permission.value.exclusive);
+      assert.isTrue(element.permission.value.modified);
+      assert.isFalse(element._originalExclusiveValue);
+      element.editing = false;
+      assert.isFalse(element.permission.value.exclusive);
+    });
+
+    test('_handleValueChange', () => {
+      const modifiedHandler = sinon.stub();
+      element.permission = {value: {rules: {}}};
+      element.addEventListener('access-modified', modifiedHandler);
+      assert.isNotOk(element.permission.value.modified);
+      element._handleValueChange();
+      assert.isTrue(element.permission.value.modified);
+      assert.isTrue(modifiedHandler.called);
+    });
+
+    test('Exclusive hidden for owner permission', () => {
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#exclusiveToggle')).display,
+      'flex');
+      element.set(['permission', 'id'], 'owner');
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#exclusiveToggle')).display,
+      'none');
+    });
+
+    test('Exclusive hidden for any global permissions', () => {
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#exclusiveToggle')).display,
+      'flex');
+      element.section = 'GLOBAL_CAPABILITIES';
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#exclusiveToggle')).display,
+      'none');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
index 318c2c3..d9d37f6 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '@polymer/paper-toggle-button/paper-toggle-button.js';
 import '../../../styles/gr-form-styles.js';
@@ -27,7 +25,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-plugin-config-array-editor_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrPluginConfigArrayEditor extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.js
deleted file mode 100644
index be35035..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    .wrapper {
-      width: 30em;
-    }
-    .existingItems {
-      background: var(--table-header-background-color);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-    }
-    gr-button {
-      float: right;
-      margin-left: var(--spacing-m);
-      width: 4.5em;
-    }
-    .row {
-      align-items: center;
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-m) 0;
-      width: 100%;
-    }
-    .existingItems .row {
-      padding: var(--spacing-m);
-    }
-    .existingItems .row:not(:first-of-type) {
-      border-top: 1px solid var(--border-color);
-    }
-    input {
-      flex-grow: 1;
-    }
-    .hide {
-      display: none;
-    }
-    .placeholder {
-      color: var(--deemphasized-text-color);
-      padding-top: var(--spacing-m);
-    }
-  </style>
-  <div class="wrapper gr-form-styles">
-    <template is="dom-if" if="[[pluginOption.info.values.length]]">
-      <div class="existingItems">
-        <template is="dom-repeat" items="[[pluginOption.info.values]]">
-          <div class="row">
-            <span>[[item]]</span>
-            <gr-button
-              link=""
-              disabled$="[[disabled]]"
-              data-item$="[[item]]"
-              on-click="_handleDelete"
-              >Delete</gr-button
-            >
-          </div>
-        </template>
-      </div>
-    </template>
-    <template is="dom-if" if="[[!pluginOption.info.values.length]]">
-      <div class="row placeholder">None configured.</div>
-    </template>
-    <div class$="row [[_computeShowInputRow(disabled)]]">
-      <iron-input on-keydown="_handleInputKeydown" bind-value="{{_newValue}}">
-        <input
-          is="iron-input"
-          id="input"
-          on-keydown="_handleInputKeydown"
-          bind-value="{{_newValue}}"
-        />
-      </iron-input>
-      <gr-button
-        id="addButton"
-        disabled$="[[!_newValue.length]]"
-        link=""
-        on-click="_handleAddTap"
-        >Add</gr-button
-      >
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.ts
new file mode 100644
index 0000000..c96b86c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.ts
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    .wrapper {
+      width: 30em;
+    }
+    .existingItems {
+      background: var(--table-header-background-color);
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+    }
+    gr-button {
+      float: right;
+      margin-left: var(--spacing-m);
+      width: 4.5em;
+    }
+    .row {
+      align-items: center;
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-m) 0;
+      width: 100%;
+    }
+    .existingItems .row {
+      padding: var(--spacing-m);
+    }
+    .existingItems .row:not(:first-of-type) {
+      border-top: 1px solid var(--border-color);
+    }
+    input {
+      flex-grow: 1;
+    }
+    .hide {
+      display: none;
+    }
+    .placeholder {
+      color: var(--deemphasized-text-color);
+      padding-top: var(--spacing-m);
+    }
+  </style>
+  <div class="wrapper gr-form-styles">
+    <template is="dom-if" if="[[pluginOption.info.values.length]]">
+      <div class="existingItems">
+        <template is="dom-repeat" items="[[pluginOption.info.values]]">
+          <div class="row">
+            <span>[[item]]</span>
+            <gr-button
+              link=""
+              disabled$="[[disabled]]"
+              data-item$="[[item]]"
+              on-click="_handleDelete"
+              >Delete</gr-button
+            >
+          </div>
+        </template>
+      </div>
+    </template>
+    <template is="dom-if" if="[[!pluginOption.info.values.length]]">
+      <div class="row placeholder">None configured.</div>
+    </template>
+    <div class$="row [[_computeShowInputRow(disabled)]]">
+      <iron-input on-keydown="_handleInputKeydown" bind-value="{{_newValue}}">
+        <input
+          is="iron-input"
+          id="input"
+          on-keydown="_handleInputKeydown"
+          bind-value="{{_newValue}}"
+        />
+      </iron-input>
+      <gr-button
+        id="addButton"
+        disabled$="[[!_newValue.length]]"
+        link=""
+        on-click="_handleAddTap"
+        >Add</gr-button
+      >
+    </div>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
deleted file mode 100644
index 5eff42d..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html
+++ /dev/null
@@ -1,144 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-plugin-config-array-editor</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-plugin-config-array-editor></gr-plugin-config-array-editor>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-plugin-config-array-editor.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-plugin-config-array-editor tests', () => {
-  let element;
-  let sandbox;
-  let dispatchStub;
-
-  const getAll = str => dom(element.root).querySelectorAll(str);
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    element.pluginOption = {
-      _key: 'test-key',
-      info: {
-        values: [],
-      },
-    };
-  });
-
-  teardown(() => sandbox.restore());
-
-  test('_computeShowInputRow', () => {
-    assert.equal(element._computeShowInputRow(true), 'hide');
-    assert.equal(element._computeShowInputRow(false), '');
-  });
-
-  test('_computeDisabled', () => {
-    assert.isTrue(element._computeDisabled({}));
-    assert.isTrue(element._computeDisabled({base: {}}));
-    assert.isTrue(element._computeDisabled({base: {info: {}}}));
-    assert.isTrue(
-        element._computeDisabled({base: {info: {editable: false}}}));
-    assert.isFalse(
-        element._computeDisabled({base: {info: {editable: true}}}));
-  });
-
-  suite('adding', () => {
-    setup(() => {
-      dispatchStub = sandbox.stub(element, '_dispatchChanged');
-    });
-
-    test('with enter', () => {
-      element._newValue = '';
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
-      flushAsynchronousOperations();
-
-      assert.isFalse(dispatchStub.called);
-      element._newValue = 'test';
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
-      flushAsynchronousOperations();
-
-      assert.isTrue(dispatchStub.called);
-      assert.equal(dispatchStub.lastCall.args[0], 'test');
-      assert.equal(element._newValue, '');
-    });
-
-    test('with add btn', () => {
-      element._newValue = '';
-      MockInteractions.tap(element.$.addButton);
-      flushAsynchronousOperations();
-
-      assert.isFalse(dispatchStub.called);
-      element._newValue = 'test';
-      MockInteractions.tap(element.$.addButton);
-      flushAsynchronousOperations();
-
-      assert.isTrue(dispatchStub.called);
-      assert.equal(dispatchStub.lastCall.args[0], 'test');
-      assert.equal(element._newValue, '');
-    });
-  });
-
-  test('deleting', () => {
-    dispatchStub = sandbox.stub(element, '_dispatchChanged');
-    element.pluginOption = {info: {values: ['test', 'test2']}};
-    flushAsynchronousOperations();
-
-    const rows = getAll('.existingItems .row');
-    assert.equal(rows.length, 2);
-    const button = rows[0].querySelector('gr-button');
-
-    MockInteractions.tap(button);
-    flushAsynchronousOperations();
-
-    assert.isFalse(dispatchStub.called);
-    element.pluginOption.info.editable = true;
-    element.notifyPath('pluginOption.info.editable');
-    flushAsynchronousOperations();
-
-    MockInteractions.tap(button);
-    flushAsynchronousOperations();
-
-    assert.isTrue(dispatchStub.called);
-    assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
-  });
-
-  test('_dispatchChanged', () => {
-    const eventStub = sandbox.stub(element, 'dispatchEvent');
-    element._dispatchChanged(['new-test-value']);
-
-    assert.isTrue(eventStub.called);
-    const {detail} = eventStub.lastCall.args[0];
-    assert.equal(detail._key, 'test-key');
-    assert.deepEqual(detail.info, {values: ['new-test-value']});
-    assert.equal(detail.notifyPath, 'test-key.values');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js
new file mode 100644
index 0000000..9e9eb1c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js
@@ -0,0 +1,127 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-plugin-config-array-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-plugin-config-array-editor');
+
+suite('gr-plugin-config-array-editor tests', () => {
+  let element;
+
+  let dispatchStub;
+
+  const getAll = str => dom(element.root).querySelectorAll(str);
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.pluginOption = {
+      _key: 'test-key',
+      info: {
+        values: [],
+      },
+    };
+  });
+
+  test('_computeShowInputRow', () => {
+    assert.equal(element._computeShowInputRow(true), 'hide');
+    assert.equal(element._computeShowInputRow(false), '');
+  });
+
+  test('_computeDisabled', () => {
+    assert.isTrue(element._computeDisabled({}));
+    assert.isTrue(element._computeDisabled({base: {}}));
+    assert.isTrue(element._computeDisabled({base: {info: {}}}));
+    assert.isTrue(
+        element._computeDisabled({base: {info: {editable: false}}}));
+    assert.isFalse(
+        element._computeDisabled({base: {info: {editable: true}}}));
+  });
+
+  suite('adding', () => {
+    setup(() => {
+      dispatchStub = sinon.stub(element, '_dispatchChanged');
+    });
+
+    test('with enter', () => {
+      element._newValue = '';
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+      flushAsynchronousOperations();
+
+      assert.isFalse(dispatchStub.called);
+      element._newValue = 'test';
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
+      flushAsynchronousOperations();
+
+      assert.isTrue(dispatchStub.called);
+      assert.equal(dispatchStub.lastCall.args[0], 'test');
+      assert.equal(element._newValue, '');
+    });
+
+    test('with add btn', () => {
+      element._newValue = '';
+      MockInteractions.tap(element.$.addButton);
+      flushAsynchronousOperations();
+
+      assert.isFalse(dispatchStub.called);
+      element._newValue = 'test';
+      MockInteractions.tap(element.$.addButton);
+      flushAsynchronousOperations();
+
+      assert.isTrue(dispatchStub.called);
+      assert.equal(dispatchStub.lastCall.args[0], 'test');
+      assert.equal(element._newValue, '');
+    });
+  });
+
+  test('deleting', () => {
+    dispatchStub = sinon.stub(element, '_dispatchChanged');
+    element.pluginOption = {info: {values: ['test', 'test2']}};
+    flushAsynchronousOperations();
+
+    const rows = getAll('.existingItems .row');
+    assert.equal(rows.length, 2);
+    const button = rows[0].querySelector('gr-button');
+
+    MockInteractions.tap(button);
+    flushAsynchronousOperations();
+
+    assert.isFalse(dispatchStub.called);
+    element.pluginOption.info.editable = true;
+    element.notifyPath('pluginOption.info.editable');
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(button);
+    flushAsynchronousOperations();
+
+    assert.isTrue(dispatchStub.called);
+    assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
+  });
+
+  test('_dispatchChanged', () => {
+    const eventStub = sinon.stub(element, 'dispatchEvent');
+    element._dispatchChanged(['new-test-value']);
+
+    assert.isTrue(eventStub.called);
+    const {detail} = eventStub.lastCall.args[0];
+    assert.equal(detail._key, 'test-key');
+    assert.deepEqual(detail.info, {values: ['new-test-value']});
+    assert.equal(detail.notifyPath, 'test-key.values');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
index 154af6e..3a54ad4 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
@@ -14,26 +14,21 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/gr-table-styles.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-list-view/gr-list-view.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-plugin-list_html.js';
-import {ListViewBehavior} from '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
 
 /**
  * @appliesMixin ListViewMixin
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrPluginList extends mixinBehaviors( [
-  ListViewBehavior,
-], GestureEventListeners(
+class GrPluginList extends ListViewMixin(GestureEventListeners(
     LegacyElementMixin(
         PolymerElement))) {
   static get template() { return htmlTemplate; }
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.js
deleted file mode 100644
index bd2bea3..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.js
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    .placeholder {
-      color: var(--deemphasized-text-color);
-    }
-  </style>
-  <gr-list-view
-    filter="[[_filter]]"
-    items-per-page="[[_pluginsPerPage]]"
-    items="[[_plugins]]"
-    loading="[[_loading]]"
-    offset="[[_offset]]"
-    path="[[_path]]"
-  >
-    <table id="list" class="genericList">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Plugin Name</th>
-          <th class="version topHeader">Version</th>
-          <th class="apiVersion topHeader">API Version</th>
-          <th class="status topHeader">Status</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_shownPlugins]]">
-          <tr class="table">
-            <td class="name">
-              <template is="dom-if" if="[[item.index_url]]">
-                <a href$="[[_computePluginUrl(item.index_url)]]">[[item.id]]</a>
-              </template>
-              <template is="dom-if" if="[[!item.index_url]]">
-                [[item.id]]
-              </template>
-            </td>
-            <td class="version">
-              <template is="dom-if" if="[[item.version]]">
-                [[item.version]]
-              </template>
-              <template is="dom-if" if="[[!item.version]]">
-                <span class="placeholder">--</span>
-              </template>
-            </td>
-            <td class="apiVersion">
-              <template is="dom-if" if="[[item.api_version]]">
-                [[item.api_version]]
-              </template>
-              <template is="dom-if" if="[[!item.api_version]]">
-                <span class="placeholder">--</span>
-              </template>
-            </td>
-            <td class="status">[[_status(item)]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </gr-list-view>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
new file mode 100644
index 0000000..d5318b5
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-table-styles">
+    .placeholder {
+      color: var(--deemphasized-text-color);
+    }
+  </style>
+  <gr-list-view
+    filter="[[_filter]]"
+    items-per-page="[[_pluginsPerPage]]"
+    items="[[_plugins]]"
+    loading="[[_loading]]"
+    offset="[[_offset]]"
+    path="[[_path]]"
+  >
+    <table id="list" class="genericList">
+      <tbody>
+        <tr class="headerRow">
+          <th class="name topHeader">Plugin Name</th>
+          <th class="version topHeader">Version</th>
+          <th class="apiVersion topHeader">API Version</th>
+          <th class="status topHeader">Status</th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+      </tbody>
+      <tbody class$="[[computeLoadingClass(_loading)]]">
+        <template is="dom-repeat" items="[[_shownPlugins]]">
+          <tr class="table">
+            <td class="name">
+              <template is="dom-if" if="[[item.index_url]]">
+                <a href$="[[_computePluginUrl(item.index_url)]]">[[item.id]]</a>
+              </template>
+              <template is="dom-if" if="[[!item.index_url]]">
+                [[item.id]]
+              </template>
+            </td>
+            <td class="version">
+              <template is="dom-if" if="[[item.version]]">
+                [[item.version]]
+              </template>
+              <template is="dom-if" if="[[!item.version]]">
+                <span class="placeholder">--</span>
+              </template>
+            </td>
+            <td class="apiVersion">
+              <template is="dom-if" if="[[item.api_version]]">
+                [[item.api_version]]
+              </template>
+              <template is="dom-if" if="[[!item.api_version]]">
+                <span class="placeholder">--</span>
+              </template>
+            </td>
+            <td class="status">[[_status(item)]]</td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </gr-list-view>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
deleted file mode 100644
index 6ca8afa4..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
+++ /dev/null
@@ -1,209 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-plugin-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-plugin-list></gr-plugin-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-plugin-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-let counter;
-const pluginGenerator = () => {
-  const plugin = {
-    id: `test${++counter}`,
-    disabled: false,
-  };
-
-  if (counter !== 2) {
-    plugin.index_url = `plugins/test${counter}/`;
-  }
-  if (counter !== 3) {
-    plugin.version = `version-${counter}`;
-  }
-  if (counter !== 4) {
-    plugin.api_version = `api-version-${counter}`;
-  }
-  return plugin;
-};
-
-suite('gr-plugin-list tests', () => {
-  let element;
-  let plugins;
-  let sandbox;
-  let value;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    counter = 0;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('list with plugins', () => {
-    setup(done => {
-      plugins = _.times(26, pluginGenerator);
-
-      stub('gr-rest-api-interface', {
-        getPlugins(num, offset) {
-          return Promise.resolve(plugins);
-        },
-      });
-
-      element._paramsChanged(value).then(() => { flush(done); });
-    });
-
-    test('plugin in the list is formatted correctly', done => {
-      flush(() => {
-        assert.equal(element._plugins[4].id, 'test5');
-        assert.equal(element._plugins[4].index_url, 'plugins/test5/');
-        assert.equal(element._plugins[4].version, 'version-5');
-        assert.equal(element._plugins[4].api_version, 'api-version-5');
-        assert.equal(element._plugins[4].disabled, false);
-        done();
-      });
-    });
-
-    test('with and without urls', done => {
-      flush(() => {
-        const names = dom(element.root).querySelectorAll('.name');
-        assert.isOk(names[1].querySelector('a'));
-        assert.equal(names[1].querySelector('a').innerText, 'test1');
-        assert.isNotOk(names[2].querySelector('a'));
-        assert.equal(names[2].innerText, 'test2');
-        done();
-      });
-    });
-
-    test('versions', done => {
-      flush(() => {
-        const versions = Polymer.dom(element.root).querySelectorAll('.version');
-        assert.equal(versions[2].innerText, 'version-2');
-        assert.equal(versions[3].innerText, '--');
-        done();
-      });
-    });
-
-    test('api versions', done => {
-      flush(() => {
-        const apiVersions = Polymer.dom(element.root).querySelectorAll(
-            '.apiVersion');
-        assert.equal(apiVersions[3].innerText, 'api-version-3');
-        assert.equal(apiVersions[4].innerText, '--');
-        done();
-      });
-    });
-
-    test('_shownPlugins', () => {
-      assert.equal(element._shownPlugins.length, 25);
-    });
-  });
-
-  suite('list with less then 26 plugins', () => {
-    setup(done => {
-      plugins = _.times(25, pluginGenerator);
-
-      stub('gr-rest-api-interface', {
-        getPlugins(num, offset) {
-          return Promise.resolve(plugins);
-        },
-      });
-
-      element._paramsChanged(value).then(() => { flush(done); });
-    });
-
-    test('_shownPlugins', () => {
-      assert.equal(element._shownPlugins.length, 25);
-    });
-  });
-
-  suite('filter', () => {
-    test('_paramsChanged', done => {
-      sandbox.stub(
-          element.$.restAPI,
-          'getPlugins',
-          () => Promise.resolve(plugins));
-      const value = {
-        filter: 'test',
-        offset: 25,
-      };
-      element._paramsChanged(value).then(() => {
-        assert.equal(element.$.restAPI.getPlugins.lastCall.args[0],
-            'test');
-        assert.equal(element.$.restAPI.getPlugins.lastCall.args[1],
-            25);
-        assert.equal(element.$.restAPI.getPlugins.lastCall.args[2],
-            25);
-        done();
-      });
-    });
-  });
-
-  suite('loading', () => {
-    test('correct contents are displayed', () => {
-      assert.isTrue(element._loading);
-      assert.equal(element.computeLoadingClass(element._loading), 'loading');
-      assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-      element._loading = false;
-      element._plugins = _.times(25, pluginGenerator);
-
-      flushAsynchronousOperations();
-      assert.equal(element.computeLoadingClass(element._loading), '');
-      assert.equal(getComputedStyle(element.$.loading).display, 'none');
-    });
-  });
-
-  suite('404', () => {
-    test('fires page-error', done => {
-      const response = {status: 404};
-      sandbox.stub(element.$.restAPI, 'getPlugins',
-          (filter, pluginsPerPage, opt_offset, errFn) => {
-            errFn(response);
-          });
-
-      element.addEventListener('page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      const value = {
-        filter: 'test',
-        offset: 25,
-      };
-      element._paramsChanged(value);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
new file mode 100644
index 0000000..d60483e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
@@ -0,0 +1,189 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-plugin-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-plugin-list');
+
+let counter;
+const pluginGenerator = () => {
+  const plugin = {
+    id: `test${++counter}`,
+    disabled: false,
+  };
+
+  if (counter !== 2) {
+    plugin.index_url = `plugins/test${counter}/`;
+  }
+  if (counter !== 3) {
+    plugin.version = `version-${counter}`;
+  }
+  if (counter !== 4) {
+    plugin.api_version = `api-version-${counter}`;
+  }
+  return plugin;
+};
+
+suite('gr-plugin-list tests', () => {
+  let element;
+  let plugins;
+
+  let value;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    counter = 0;
+  });
+
+  suite('list with plugins', () => {
+    setup(done => {
+      plugins = _.times(26, pluginGenerator);
+
+      stub('gr-rest-api-interface', {
+        getPlugins(num, offset) {
+          return Promise.resolve(plugins);
+        },
+      });
+
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
+
+    test('plugin in the list is formatted correctly', done => {
+      flush(() => {
+        assert.equal(element._plugins[4].id, 'test5');
+        assert.equal(element._plugins[4].index_url, 'plugins/test5/');
+        assert.equal(element._plugins[4].version, 'version-5');
+        assert.equal(element._plugins[4].api_version, 'api-version-5');
+        assert.equal(element._plugins[4].disabled, false);
+        done();
+      });
+    });
+
+    test('with and without urls', done => {
+      flush(() => {
+        const names = dom(element.root).querySelectorAll('.name');
+        assert.isOk(names[1].querySelector('a'));
+        assert.equal(names[1].querySelector('a').innerText, 'test1');
+        assert.isNotOk(names[2].querySelector('a'));
+        assert.equal(names[2].innerText, 'test2');
+        done();
+      });
+    });
+
+    test('versions', done => {
+      flush(() => {
+        const versions = element.root.querySelectorAll('.version');
+        assert.equal(versions[2].innerText, 'version-2');
+        assert.equal(versions[3].innerText, '--');
+        done();
+      });
+    });
+
+    test('api versions', done => {
+      flush(() => {
+        const apiVersions = element.root.querySelectorAll(
+            '.apiVersion');
+        assert.equal(apiVersions[3].innerText, 'api-version-3');
+        assert.equal(apiVersions[4].innerText, '--');
+        done();
+      });
+    });
+
+    test('_shownPlugins', () => {
+      assert.equal(element._shownPlugins.length, 25);
+    });
+  });
+
+  suite('list with less then 26 plugins', () => {
+    setup(done => {
+      plugins = _.times(25, pluginGenerator);
+
+      stub('gr-rest-api-interface', {
+        getPlugins(num, offset) {
+          return Promise.resolve(plugins);
+        },
+      });
+
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
+
+    test('_shownPlugins', () => {
+      assert.equal(element._shownPlugins.length, 25);
+    });
+  });
+
+  suite('filter', () => {
+    test('_paramsChanged', done => {
+      sinon.stub(
+          element.$.restAPI,
+          'getPlugins')
+          .callsFake(() => Promise.resolve(plugins));
+      const value = {
+        filter: 'test',
+        offset: 25,
+      };
+      element._paramsChanged(value).then(() => {
+        assert.equal(element.$.restAPI.getPlugins.lastCall.args[0],
+            'test');
+        assert.equal(element.$.restAPI.getPlugins.lastCall.args[1],
+            25);
+        assert.equal(element.$.restAPI.getPlugins.lastCall.args[2],
+            25);
+        done();
+      });
+    });
+  });
+
+  suite('loading', () => {
+    test('correct contents are displayed', () => {
+      assert.isTrue(element._loading);
+      assert.equal(element.computeLoadingClass(element._loading), 'loading');
+      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+      element._loading = false;
+      element._plugins = _.times(25, pluginGenerator);
+
+      flushAsynchronousOperations();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      const response = {status: 404};
+      sinon.stub(element.$.restAPI, 'getPlugins').callsFake(
+          (filter, pluginsPerPage, opt_offset, errFn) => {
+            errFn(response);
+          });
+
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      const value = {
+        filter: 'test',
+        offset: 25,
+      };
+      element._paramsChanged(value);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
index 9aa81f8..8f48382 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
@@ -14,23 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/gr-menu-page-styles.js';
 import '../../../styles/gr-subpage-styles.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-access-section/gr-access-section.js';
 import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-repo-access_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {AccessBehavior} from '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {
+  encodeURL,
+  getBaseUrl,
+  singleDecodeURL,
+} from '../../../utils/url-util.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {toSortedPermissionsArray} from '../../../utils/access-util.js';
 
 const Defs = {};
 
@@ -83,15 +83,11 @@
 Defs.projectAccessInput;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrRepoAccess extends mixinBehaviors( [
-  AccessBehavior,
-  BaseUrlBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
+class GrRepoAccess extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-repo-access'; }
@@ -186,10 +182,10 @@
           // Keep a copy of the original inherit from values separate from
           // the ones data bound to gr-autocomplete, so the original value
           // can be restored if the user cancels.
-          this._inheritsFrom = res.inherits_from ? Object.assign({},
-              res.inherits_from) : null;
-          this._originalInheritsFrom = res.inherits_from ? Object.assign({},
-              res.inherits_from) : null;
+          this._inheritsFrom = res.inherits_from ? ({
+            ...res.inherits_from}) : null;
+          this._originalInheritsFrom = res.inherits_from ? ({
+            ...res.inherits_from}) : null;
           // Initialize the filter value so when the user clicks edit, the
           // current value appears. If there is no parent repo, it is
           // initialized as an empty string.
@@ -200,7 +196,7 @@
           this._weblinks = res.config_web_links || [];
           this._canUpload = res.can_upload;
           this._ownerOf = res.owner_of || [];
-          return this.toSortedArray(this._local);
+          return toSortedPermissionsArray(this._local);
         }));
 
     promises.push(this.$.restAPI.getCapabilities(errFn)
@@ -286,7 +282,7 @@
     }
     // Restore inheritFrom.
     if (this._inheritsFrom) {
-      this._inheritsFrom = Object.assign({}, this._originalInheritsFrom);
+      this._inheritsFrom = {...this._originalInheritsFrom};
       this._inheritFromFilter = this._inheritsFrom.name;
     }
     for (const key of Object.keys(this._local)) {
@@ -398,11 +394,9 @@
     };
 
     const originalInheritsFromId = this._originalInheritsFrom ?
-      this.singleDecodeURL(this._originalInheritsFrom.id) :
-      null;
+      singleDecodeURL(this._originalInheritsFrom.id) : null;
     const inheritsFromId = this._inheritsFrom ?
-      this.singleDecodeURL(this._inheritsFrom.id) :
-      null;
+      singleDecodeURL(this._inheritsFrom.id) : null;
 
     const inheritFromChanged =
         // Inherit from changed
@@ -516,8 +510,8 @@
   }
 
   _computeParentHref(repoName) {
-    return this.getBaseUrl() +
-        `/admin/repos/${this.encodeURL(repoName, true)},access`;
+    return getBaseUrl() +
+        `/admin/repos/${encodeURL(repoName, true)},access`;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js
deleted file mode 100644
index 5f0739a..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.js
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    gr-button,
-    #inheritsFrom,
-    #editInheritFromInput,
-    .editing #inheritFromName,
-    .weblinks,
-    .editing .invisible {
-      display: none;
-    }
-    #inheritsFrom.show {
-      display: flex;
-      min-height: 2em;
-      align-items: center;
-    }
-    .weblink {
-      margin-right: var(--spacing-xs);
-    }
-    .weblinks.show,
-    .referenceContainer {
-      display: block;
-    }
-    .rightsText {
-      margin-right: var(--spacing-s);
-    }
-
-    .editing gr-button,
-    .admin #editBtn {
-      display: inline-block;
-      margin: var(--spacing-l) 0;
-    }
-    .editing #editInheritFromInput {
-      display: inline-block;
-    }
-  </style>
-  <style include="gr-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <main class$="[[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h3 id="inheritsFrom" class$="[[_computeShowInherit(_inheritsFrom)]]">
-        <span class="rightsText">Rights Inherit From</span>
-        <a
-          href$="[[_computeParentHref(_inheritsFrom.name)]]"
-          rel="noopener"
-          id="inheritFromName"
-        >
-          [[_inheritsFrom.name]]</a
-        >
-        <gr-autocomplete
-          id="editInheritFromInput"
-          text="{{_inheritFromFilter}}"
-          query="[[_query]]"
-          on-commit="_handleUpdateInheritFrom"
-        ></gr-autocomplete>
-      </h3>
-      <div class$="weblinks [[_computeWebLinkClass(_weblinks)]]">
-        History:
-        <template is="dom-repeat" items="[[_weblinks]]" as="link">
-          <a
-            href="[[link.url]]"
-            class="weblink"
-            rel="noopener"
-            target="[[link.target]]"
-          >
-            [[link.name]]
-          </a>
-        </template>
-      </div>
-      <gr-button id="editBtn" on-click="_handleEdit"
-        >[[_editOrCancel(_editing)]]</gr-button
-      >
-      <gr-button
-        id="saveBtn"
-        primary=""
-        class$="[[_computeSaveBtnClass(_ownerOf)]]"
-        on-click="_handleSave"
-        disabled="[[!_modified]]"
-        >Save</gr-button
-      >
-      <gr-button
-        id="saveReviewBtn"
-        primary=""
-        class$="[[_computeSaveReviewBtnClass(_canUpload)]]"
-        on-click="_handleSaveForReview"
-        disabled="[[!_modified]]"
-        >Save for review</gr-button
-      >
-      <template
-        is="dom-repeat"
-        items="{{_sections}}"
-        initial-count="5"
-        target-framerate="60"
-        as="section"
-      >
-        <gr-access-section
-          capabilities="[[_capabilities]]"
-          section="{{section}}"
-          labels="[[_labels]]"
-          can-upload="[[_canUpload]]"
-          editing="[[_editing]]"
-          owner-of="[[_ownerOf]]"
-          groups="[[_groups]]"
-          on-added-section-removed="_handleAddedSectionRemoved"
-        ></gr-access-section>
-      </template>
-      <div class="referenceContainer">
-        <gr-button id="addReferenceBtn" on-click="_handleCreateSection"
-          >Add Reference</gr-button
-        >
-      </div>
-    </div>
-  </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
new file mode 100644
index 0000000..4e76360
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
@@ -0,0 +1,147 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-subpage-styles">
+    gr-button,
+    #inheritsFrom,
+    #editInheritFromInput,
+    .editing #inheritFromName,
+    .weblinks,
+    .editing .invisible {
+      display: none;
+    }
+    #inheritsFrom.show {
+      display: flex;
+      min-height: 2em;
+      align-items: center;
+    }
+    .weblink {
+      margin-right: var(--spacing-xs);
+    }
+    gr-access-section {
+      margin-top: var(--spacing-l);
+    }
+    .weblinks.show,
+    .referenceContainer {
+      display: block;
+    }
+    .rightsText {
+      margin-right: var(--spacing-s);
+    }
+
+    .editing gr-button,
+    .admin #editBtn {
+      display: inline-block;
+      margin: var(--spacing-l) 0;
+    }
+    .editing #editInheritFromInput {
+      display: inline-block;
+    }
+  </style>
+  <style include="gr-menu-page-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <main class$="[[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
+    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+      Loading...
+    </div>
+    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <h3
+        id="inheritsFrom"
+        class$="heading-3 [[_computeShowInherit(_inheritsFrom)]]"
+      >
+        <span class="rightsText">Rights Inherit From</span>
+        <a
+          href$="[[_computeParentHref(_inheritsFrom.name)]]"
+          rel="noopener"
+          id="inheritFromName"
+        >
+          [[_inheritsFrom.name]]</a
+        >
+        <gr-autocomplete
+          id="editInheritFromInput"
+          text="{{_inheritFromFilter}}"
+          query="[[_query]]"
+          on-commit="_handleUpdateInheritFrom"
+        ></gr-autocomplete>
+      </h3>
+      <div class$="weblinks [[_computeWebLinkClass(_weblinks)]]">
+        History:
+        <template is="dom-repeat" items="[[_weblinks]]" as="link">
+          <a
+            href="[[link.url]]"
+            class="weblink"
+            rel="noopener"
+            target="[[link.target]]"
+          >
+            [[link.name]]
+          </a>
+        </template>
+      </div>
+      <template
+        is="dom-repeat"
+        items="{{_sections}}"
+        initial-count="5"
+        target-framerate="60"
+        as="section"
+      >
+        <gr-access-section
+          capabilities="[[_capabilities]]"
+          section="{{section}}"
+          labels="[[_labels]]"
+          can-upload="[[_canUpload]]"
+          editing="[[_editing]]"
+          owner-of="[[_ownerOf]]"
+          groups="[[_groups]]"
+          on-added-section-removed="_handleAddedSectionRemoved"
+        ></gr-access-section>
+      </template>
+      <div class="referenceContainer">
+        <gr-button id="addReferenceBtn" on-click="_handleCreateSection"
+          >Add Reference</gr-button
+        >
+      </div>
+      <div>
+        <gr-button id="editBtn" on-click="_handleEdit"
+          >[[_editOrCancel(_editing)]]</gr-button
+        >
+        <gr-button
+          id="saveBtn"
+          primary=""
+          class$="[[_computeSaveBtnClass(_ownerOf)]]"
+          on-click="_handleSave"
+          disabled="[[!_modified]]"
+          >Save</gr-button
+        >
+        <gr-button
+          id="saveReviewBtn"
+          primary=""
+          class$="[[_computeSaveReviewBtnClass(_canUpload)]]"
+          on-click="_handleSaveForReview"
+          disabled="[[!_modified]]"
+          >Save for review</gr-button
+        >
+      </div>
+    </div>
+  </main>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
deleted file mode 100644
index 7d66cb0..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.html
+++ /dev/null
@@ -1,1254 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-repo-access</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-repo-access></gr-repo-access>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-access.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-repo-access tests', () => {
-  let element;
-  let sandbox;
-  let repoStub;
-
-  const accessRes = {
-    local: {
-      'refs/*': {
-        permissions: {
-          owner: {
-            rules: {
-              234: {action: 'ALLOW'},
-              123: {action: 'DENY'},
-            },
-          },
-          read: {
-            rules: {
-              234: {action: 'ALLOW'},
-            },
-          },
-        },
-      },
-    },
-    groups: {
-      Administrators: {
-        name: 'Administrators',
-      },
-      Maintainers: {
-        name: 'Maintainers',
-      },
-    },
-    config_web_links: [{
-      name: 'gitiles',
-      target: '_blank',
-      url: 'https://my/site/+log/123/project.config',
-    }],
-    can_upload: true,
-  };
-  const accessRes2 = {
-    local: {
-      GLOBAL_CAPABILITIES: {
-        permissions: {
-          accessDatabase: {
-            rules: {
-              group1: {
-                action: 'ALLOW',
-              },
-            },
-          },
-        },
-      },
-    },
-  };
-  const repoRes = {
-    labels: {
-      'Code-Review': {
-        values: {
-          ' 0': 'No score',
-          '-1': 'I would prefer this is not merged as is',
-          '-2': 'This shall not be merged',
-          '+1': 'Looks good to me, but someone else must approve',
-          '+2': 'Looks good to me, approved',
-        },
-      },
-    },
-  };
-  const capabilitiesRes = {
-    accessDatabase: {
-      id: 'accessDatabase',
-      name: 'Access Database',
-    },
-    createAccount: {
-      id: 'createAccount',
-      name: 'Create Account',
-    },
-  };
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(null); },
-    });
-    repoStub = sandbox.stub(element.$.restAPI, 'getRepo').returns(
-        Promise.resolve(repoRes));
-    element._loading = false;
-    element._ownerOf = [];
-    element._canUpload = false;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_repoChanged called when repo name changes', () => {
-    sandbox.stub(element, '_repoChanged');
-    element.repo = 'New Repo';
-    assert.isTrue(element._repoChanged.called);
-  });
-
-  test('_repoChanged', done => {
-    const accessStub = sandbox.stub(element.$.restAPI,
-        'getRepoAccessRights');
-
-    accessStub.withArgs('New Repo').returns(
-        Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-    accessStub.withArgs('Another New Repo')
-        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-    const capabilitiesStub = sandbox.stub(element.$.restAPI,
-        'getCapabilities');
-    capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
-
-    element._repoChanged('New Repo').then(() => {
-      assert.isTrue(accessStub.called);
-      assert.isTrue(capabilitiesStub.called);
-      assert.isTrue(repoStub.called);
-      assert.isNotOk(element._inheritsFrom);
-      assert.deepEqual(element._local, accessRes.local);
-      assert.deepEqual(element._sections,
-          element.toSortedArray(accessRes.local));
-      assert.deepEqual(element._labels, repoRes.labels);
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('.weblinks')).display,
-      'block');
-      return element._repoChanged('Another New Repo');
-    })
-        .then(() => {
-          assert.deepEqual(element._sections,
-              element.toSortedArray(accessRes2.local));
-          assert.equal(getComputedStyle(element.shadowRoot
-              .querySelector('.weblinks')).display,
-          'none');
-          done();
-        });
-  });
-
-  test('_repoChanged when repo changes to undefined returns', done => {
-    const capabilitiesRes = {
-      accessDatabase: {
-        id: 'accessDatabase',
-        name: 'Access Database',
-      },
-    };
-    const accessStub = sandbox.stub(element.$.restAPI, 'getRepoAccessRights')
-        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-    const capabilitiesStub = sandbox.stub(element.$.restAPI,
-        'getCapabilities').returns(Promise.resolve(capabilitiesRes));
-
-    element._repoChanged().then(() => {
-      assert.isFalse(accessStub.called);
-      assert.isFalse(capabilitiesStub.called);
-      assert.isFalse(repoStub.called);
-      done();
-    });
-  });
-
-  test('_computeParentHref', () => {
-    const repoName = 'test-repo';
-    assert.equal(element._computeParentHref(repoName),
-        '/admin/repos/test-repo,access');
-  });
-
-  test('_computeMainClass', () => {
-    let ownerOf = ['refs/*'];
-    const editing = true;
-    const canUpload = false;
-    assert.equal(element._computeMainClass(ownerOf, canUpload), 'admin');
-    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
-        'admin editing');
-    ownerOf = [];
-    assert.equal(element._computeMainClass(ownerOf, canUpload), '');
-    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
-        'editing');
-  });
-
-  test('inherit section', () => {
-    element._local = {};
-    element._ownerOf = [];
-    sandbox.stub(element, '_computeParentHref');
-    // Nothing should appear when no inherit from and not in edit mode.
-    assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
-    // The autocomplete should be hidden, and the link should be  displayed.
-    assert.isFalse(element._computeParentHref.called);
-    // When it edit mode, the autocomplete should appear.
-    element._editing = true;
-    // When editing, the autocomplete should still not be shown.
-    assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
-    element._editing = false;
-    element._inheritsFrom = {
-      name: 'another-repo',
-    };
-    // When there is a parent project, the link should be displayed.
-    flushAsynchronousOperations();
-    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
-    assert.notEqual(getComputedStyle(element.$.inheritFromName).display,
-        'none');
-    assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
-        'none');
-    assert.isTrue(element._computeParentHref.called);
-    element._editing = true;
-    // When editing, the autocomplete should be shown.
-    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
-    assert.equal(getComputedStyle(element.$.inheritFromName).display, 'none');
-    assert.notEqual(getComputedStyle(element.$.editInheritFromInput).display,
-        'none');
-  });
-
-  test('_handleUpdateInheritFrom', () => {
-    element._inheritFromFilter = 'foo bar baz';
-    element._handleUpdateInheritFrom({detail: {value: 'abc+123'}});
-    assert.isOk(element._inheritsFrom);
-    assert.equal(element._inheritsFrom.id, 'abc+123');
-    assert.equal(element._inheritsFrom.name, 'foo bar baz');
-  });
-
-  test('_computeLoadingClass', () => {
-    assert.equal(element._computeLoadingClass(true), 'loading');
-    assert.equal(element._computeLoadingClass(false), '');
-  });
-
-  test('fires page-error', done => {
-    const response = {status: 404};
-
-    sandbox.stub(
-        element.$.restAPI, 'getRepoAccessRights', (repoName, errFn) => {
-          errFn(response);
-        });
-
-    element.addEventListener('page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      done();
-    });
-
-    element.repo = 'test';
-  });
-
-  suite('with defined sections', () => {
-    const testEditSaveCancelBtns = (shouldShowSave, shouldShowSaveReview) => {
-      // Edit button is visible and Save button is hidden.
-      assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
-      assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
-      assert.equal(element.$.editBtn.innerText, 'EDIT');
-      assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
-          'none');
-      element._inheritsFrom = {
-        id: 'test-project',
-      };
-      flushAsynchronousOperations();
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#editInheritFromInput'))
-          .display, 'none');
-
-      MockInteractions.tap(element.$.editBtn);
-      flushAsynchronousOperations();
-
-      // Edit button changes to Cancel button, and Save button is visible but
-      // disabled.
-      assert.equal(element.$.editBtn.innerText, 'CANCEL');
-      if (shouldShowSaveReview) {
-        assert.notEqual(getComputedStyle(element.$.saveReviewBtn).display,
-            'none');
-        assert.isTrue(element.$.saveReviewBtn.disabled);
-      }
-      if (shouldShowSave) {
-        assert.notEqual(getComputedStyle(element.$.saveBtn).display, 'none');
-        assert.isTrue(element.$.saveBtn.disabled);
-      }
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('#editInheritFromInput'))
-          .display, 'none');
-
-      // Save button should be enabled after access is modified
-      element.dispatchEvent(
-          new CustomEvent('access-modified', {
-            composed: true, bubbles: true,
-          }));
-      if (shouldShowSaveReview) {
-        assert.isFalse(element.$.saveReviewBtn.disabled);
-      }
-      if (shouldShowSave) {
-        assert.isFalse(element.$.saveBtn.disabled);
-      }
-    };
-
-    setup(() => {
-      // Create deep copies of these objects so the originals are not modified
-      // by any tests.
-      element._local = JSON.parse(JSON.stringify(accessRes.local));
-      element._ownerOf = [];
-      element._sections = element.toSortedArray(element._local);
-      element._groups = JSON.parse(JSON.stringify(accessRes.groups));
-      element._capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
-      element._labels = JSON.parse(JSON.stringify(repoRes.labels));
-      flushAsynchronousOperations();
-    });
-
-    test('removing an added section', () => {
-      element.editing = true;
-      assert.equal(element._sections.length, 1);
-      element.shadowRoot
-          .querySelector('gr-access-section').dispatchEvent(
-              new CustomEvent('added-section-removed', {
-                composed: true, bubbles: true,
-              }));
-      flushAsynchronousOperations();
-      assert.equal(element._sections.length, 0);
-    });
-
-    test('button visibility for non ref owner', () => {
-      assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
-    });
-
-    test('button visibility for non ref owner with upload privilege', () => {
-      element._canUpload = true;
-      testEditSaveCancelBtns(false, true);
-    });
-
-    test('button visibility for ref owner', () => {
-      element._ownerOf = ['refs/for/*'];
-      testEditSaveCancelBtns(true, false);
-    });
-
-    test('button visibility for ref owner and upload', () => {
-      element._ownerOf = ['refs/for/*'];
-      element._canUpload = true;
-      testEditSaveCancelBtns(true, false);
-    });
-
-    test('_handleAccessModified called with event fired', () => {
-      sandbox.spy(element, '_handleAccessModified');
-      element.dispatchEvent(
-          new CustomEvent('access-modified', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleAccessModified.called);
-    });
-
-    test('_handleAccessModified called when parent changes', () => {
-      element._inheritsFrom = {
-        id: 'test-project',
-      };
-      flushAsynchronousOperations();
-      element.shadowRoot.querySelector('#editInheritFromInput').dispatchEvent(
-          new CustomEvent('commit', {
-            detail: {},
-            composed: true, bubbles: true,
-          }));
-      sandbox.spy(element, '_handleAccessModified');
-      element.dispatchEvent(
-          new CustomEvent('access-modified', {
-            detail: {},
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleAccessModified.called);
-    });
-
-    test('_handleSaveForReview', () => {
-      const saveStub =
-          sandbox.stub(element.$.restAPI, 'setRepoAccessRightsForReview');
-      sandbox.stub(element, '_computeAddAndRemove').returns({
-        add: {},
-        remove: {},
-      });
-      element._handleSaveForReview();
-      assert.isFalse(saveStub.called);
-    });
-
-    test('_recursivelyRemoveDeleted', () => {
-      const obj = {
-        'refs/*': {
-          permissions: {
-            owner: {
-              rules: {
-                234: {action: 'ALLOW'},
-                123: {action: 'DENY', deleted: true},
-              },
-            },
-            read: {
-              deleted: true,
-              rules: {
-                234: {action: 'ALLOW'},
-              },
-            },
-          },
-        },
-      };
-      const expectedResult = {
-        'refs/*': {
-          permissions: {
-            owner: {
-              rules: {
-                234: {action: 'ALLOW'},
-              },
-            },
-          },
-        },
-      };
-      element._recursivelyRemoveDeleted(obj);
-      assert.deepEqual(obj, expectedResult);
-    });
-
-    test('_recursivelyUpdateAddRemoveObj on new added section', () => {
-      const obj = {
-        'refs/for/*': {
-          permissions: {
-            'label-Code-Review': {
-              rules: {
-                e798fed07afbc9173a587f876ef8760c78d240c1: {
-                  min: -2,
-                  max: 2,
-                  action: 'ALLOW',
-                  added: true,
-                },
-              },
-              added: true,
-              label: 'Code-Review',
-            },
-            'labelAs-Code-Review': {
-              rules: {
-                'ldap:gerritcodereview-eng': {
-                  min: -2,
-                  max: 2,
-                  action: 'ALLOW',
-                  added: true,
-                  deleted: true,
-                },
-              },
-              added: true,
-              label: 'Code-Review',
-            },
-          },
-          added: true,
-        },
-      };
-
-      const expectedResult = {
-        add: {
-          'refs/for/*': {
-            permissions: {
-              'label-Code-Review': {
-                rules: {
-                  e798fed07afbc9173a587f876ef8760c78d240c1: {
-                    min: -2,
-                    max: 2,
-                    action: 'ALLOW',
-                    added: true,
-                  },
-                },
-                added: true,
-                label: 'Code-Review',
-              },
-              'labelAs-Code-Review': {
-                rules: {},
-                added: true,
-                label: 'Code-Review',
-              },
-            },
-            added: true,
-          },
-        },
-        remove: {},
-      };
-      const updateObj = {add: {}, remove: {}};
-      element._recursivelyUpdateAddRemoveObj(obj, updateObj);
-      assert.deepEqual(updateObj, expectedResult);
-    });
-
-    test('_handleSaveForReview with no changes', () => {
-      assert.deepEqual(element._computeAddAndRemove(), {add: {}, remove: {}});
-    });
-
-    test('_handleSaveForReview parent change', () => {
-      element._inheritsFrom = {
-        id: 'test-project',
-      };
-      element._originalInheritsFrom = {
-        id: 'test-project-original',
-      };
-      assert.deepEqual(element._computeAddAndRemove(), {
-        parent: 'test-project', add: {}, remove: {},
-      });
-    });
-
-    test('_handleSaveForReview new parent with spaces', () => {
-      element._inheritsFrom = {id: 'spaces+in+project+name'};
-      element._originalInheritsFrom = {id: 'old-project'};
-      assert.deepEqual(element._computeAddAndRemove(), {
-        parent: 'spaces in project name', add: {}, remove: {},
-      });
-    });
-
-    test('_handleSaveForReview rules', () => {
-      // Delete a rule.
-      element._local['refs/*'].permissions.owner.rules[123].deleted = true;
-      let expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Undo deleting a rule.
-      delete element._local['refs/*'].permissions.owner.rules[123].deleted;
-
-      // Modify a rule.
-      element._local['refs/*'].permissions.owner.rules[123].modified = true;
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {action: 'DENY', modified: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    test('_computeAddAndRemove permissions', () => {
-      // Add a new rule to a permission.
-      let expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                  },
-                },
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-
-      element.shadowRoot
-          .querySelector('gr-access-section').shadowRoot
-          .querySelector('gr-permission')
-          ._handleAddRuleItem(
-              {detail: {value: {id: 'Maintainers'}}});
-
-      flushAsynchronousOperations();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Remove the added rule.
-      delete element._local['refs/*'].permissions.owner.rules.Maintainers;
-
-      // Delete a permission.
-      element._local['refs/*'].permissions.owner.deleted = true;
-      expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Undo delete permission.
-      delete element._local['refs/*'].permissions.owner.deleted;
-
-      // Modify a permission.
-      element._local['refs/*'].permissions.owner.modified = true;
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                  123: {action: 'DENY'},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    test('_computeAddAndRemove sections', () => {
-      // Add a new permission to a section
-      let expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {},
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-      element.shadowRoot
-          .querySelector('gr-access-section')._handleAddPermission();
-      flushAsynchronousOperations();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add a new rule to the new permission.
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    min: -2,
-                    max: 2,
-                    action: 'ALLOW',
-                    added: true,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-      const newPermission =
-          dom(element.shadowRoot
-              .querySelector('gr-access-section').root).querySelectorAll(
-              'gr-permission')[2];
-      newPermission._handleAddRuleItem(
-          {detail: {value: {id: 'Maintainers'}}});
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Modify a section reference.
-      element._local['refs/*'].updatedId = 'refs/for/bar';
-      element._local['refs/*'].modified = true;
-      expectedInput = {
-        add: {
-          'refs/for/bar': {
-            modified: true,
-            updatedId: 'refs/for/bar',
-            permissions: {
-              'owner': {
-                rules: {
-                  234: {action: 'ALLOW'},
-                  123: {action: 'DENY'},
-                },
-              },
-              'read': {
-                rules: {
-                  234: {action: 'ALLOW'},
-                },
-              },
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    min: -2,
-                    max: 2,
-                    action: 'ALLOW',
-                    added: true,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Delete a section.
-      element._local['refs/*'].deleted = true;
-      expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    test('_computeAddAndRemove new section', () => {
-      // Add a new permission to a section
-      let expectedInput = {
-        add: {
-          'refs/for/*': {
-            added: true,
-            permissions: {},
-          },
-        },
-        remove: {},
-      };
-      MockInteractions.tap(element.$.addReferenceBtn);
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      expectedInput = {
-        add: {
-          'refs/for/*': {
-            added: true,
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {},
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-      const newSection = dom(element.root)
-          .querySelectorAll('gr-access-section')[1];
-      newSection._handleAddPermission();
-      flushAsynchronousOperations();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add rule to the new permission.
-      expectedInput = {
-        add: {
-          'refs/for/*': {
-            added: true,
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-
-      newSection.shadowRoot
-          .querySelector('gr-permission')._handleAddRuleItem(
-              {detail: {value: {id: 'Maintainers'}}});
-
-      flushAsynchronousOperations();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Modify a the reference from the default value.
-      element._local['refs/for/*'].updatedId = 'refs/for/new';
-      expectedInput = {
-        add: {
-          'refs/for/new': {
-            added: true,
-            updatedId: 'refs/for/new',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    test('_computeAddAndRemove combinations', () => {
-      // Modify rule and delete permission that it is inside of.
-      element._local['refs/*'].permissions.owner.rules[123].modified = true;
-      element._local['refs/*'].permissions.owner.deleted = true;
-      let expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      // Delete rule and delete permission that it is inside of.
-      element._local['refs/*'].permissions.owner.rules[123].modified = false;
-      element._local['refs/*'].permissions.owner.rules[123].deleted = true;
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Also modify a different rule inside of another permission.
-      element._local['refs/*'].permissions.read.modified = true;
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              read: {
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-              read: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      // Modify both permissions with an exclusive bit. Owner is still
-      // deleted.
-      element._local['refs/*'].permissions.owner.exclusive = true;
-      element._local['refs/*'].permissions.owner.modified = true;
-      element._local['refs/*'].permissions.read.exclusive = true;
-      element._local['refs/*'].permissions.read.modified = true;
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              read: {
-                exclusive: true,
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-              read: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add a rule to the existing permission;
-      const readPermission =
-          dom(element.shadowRoot
-              .querySelector('gr-access-section').root).querySelectorAll(
-              'gr-permission')[1];
-      readPermission._handleAddRuleItem(
-          {detail: {value: {id: 'Maintainers'}}});
-
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              read: {
-                exclusive: true,
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                  Maintainers: {action: 'ALLOW', added: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-              read: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Change one of the refs
-      element._local['refs/*'].updatedId = 'refs/for/bar';
-      element._local['refs/*'].modified = true;
-
-      expectedInput = {
-        add: {
-          'refs/for/bar': {
-            modified: true,
-            updatedId: 'refs/for/bar',
-            permissions: {
-              read: {
-                exclusive: true,
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                  Maintainers: {action: 'ALLOW', added: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      element._local['refs/*'].deleted = true;
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add a new section.
-      MockInteractions.tap(element.$.addReferenceBtn);
-      let newSection = dom(element.root)
-          .querySelectorAll('gr-access-section')[1];
-      newSection._handleAddPermission();
-      flushAsynchronousOperations();
-      newSection.shadowRoot
-          .querySelector('gr-permission')._handleAddRuleItem(
-              {detail: {value: {id: 'Maintainers'}}});
-      // Modify a the reference from the default value.
-      element._local['refs/for/*'].updatedId = 'refs/for/new';
-
-      expectedInput = {
-        add: {
-          'refs/for/new': {
-            added: true,
-            updatedId: 'refs/for/new',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Modify newly added rule inside new ref.
-      element._local['refs/for/*'].permissions['label-Code-Review'].
-          rules['Maintainers'].modified = true;
-      expectedInput = {
-        add: {
-          'refs/for/new': {
-            added: true,
-            updatedId: 'refs/for/new',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    modified: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add a second new section.
-      MockInteractions.tap(element.$.addReferenceBtn);
-      newSection = dom(element.root)
-          .querySelectorAll('gr-access-section')[2];
-      newSection._handleAddPermission();
-      flushAsynchronousOperations();
-      newSection.shadowRoot
-          .querySelector('gr-permission')._handleAddRuleItem(
-              {detail: {value: {id: 'Maintainers'}}});
-      // Modify a the reference from the default value.
-      element._local['refs/for/**'].updatedId = 'refs/for/new2';
-      expectedInput = {
-        add: {
-          'refs/for/new': {
-            added: true,
-            updatedId: 'refs/for/new',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    modified: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-          'refs/for/new2': {
-            added: true,
-            updatedId: 'refs/for/new2',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    test('Unsaved added refs are discarded when edit cancelled', () => {
-      // Unsaved changes are discarded when editing is cancelled.
-      MockInteractions.tap(element.$.editBtn);
-      assert.equal(element._sections.length, 1);
-      assert.equal(Object.keys(element._local).length, 1);
-      MockInteractions.tap(element.$.addReferenceBtn);
-      assert.equal(element._sections.length, 2);
-      assert.equal(Object.keys(element._local).length, 2);
-      MockInteractions.tap(element.$.editBtn);
-      assert.equal(element._sections.length, 1);
-      assert.equal(Object.keys(element._local).length, 1);
-    });
-
-    test('_handleSave', done => {
-      const repoAccessInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {action: 'DENY', modified: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      sandbox.stub(element.$.restAPI, 'getRepoAccessRights').returns(
-          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-      sandbox.stub(GerritNav, 'navigateToChange');
-      let resolver;
-      const saveStub = sandbox.stub(element.$.restAPI,
-          'setRepoAccessRights')
-          .returns(new Promise(r => resolver = r));
-
-      element.repo = 'test-repo';
-      sandbox.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
-
-      element._modified = true;
-      MockInteractions.tap(element.$.saveBtn);
-      assert.equal(element.$.saveBtn.hasAttribute('loading'), true);
-      resolver({_number: 1});
-      flush(() => {
-        assert.isTrue(saveStub.called);
-        assert.isTrue(GerritNav.navigateToChange.notCalled);
-        done();
-      });
-    });
-
-    test('_handleSaveForReview', done => {
-      const repoAccessInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {action: 'DENY', modified: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      sandbox.stub(element.$.restAPI, 'getRepoAccessRights').returns(
-          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-      sandbox.stub(GerritNav, 'navigateToChange');
-      let resolver;
-      const saveForReviewStub = sandbox.stub(element.$.restAPI,
-          'setRepoAccessRightsForReview')
-          .returns(new Promise(r => resolver = r));
-
-      element.repo = 'test-repo';
-      sandbox.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
-
-      element._modified = true;
-      MockInteractions.tap(element.$.saveReviewBtn);
-      assert.equal(element.$.saveReviewBtn.hasAttribute('loading'), true);
-      resolver({_number: 1});
-      flush(() => {
-        assert.isTrue(saveForReviewStub.called);
-        assert.isTrue(GerritNav.navigateToChange
-            .lastCall.calledWithExactly({_number: 1}));
-        done();
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
new file mode 100644
index 0000000..c60a1fe
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
@@ -0,0 +1,1235 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-repo-access.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {toSortedPermissionsArray} from '../../../utils/access-util.js';
+
+const basicFixture = fixtureFromElement('gr-repo-access');
+
+suite('gr-repo-access tests', () => {
+  let element;
+
+  let repoStub;
+
+  const accessRes = {
+    local: {
+      'refs/*': {
+        permissions: {
+          owner: {
+            rules: {
+              234: {action: 'ALLOW'},
+              123: {action: 'DENY'},
+            },
+          },
+          read: {
+            rules: {
+              234: {action: 'ALLOW'},
+            },
+          },
+        },
+      },
+    },
+    groups: {
+      Administrators: {
+        name: 'Administrators',
+      },
+      Maintainers: {
+        name: 'Maintainers',
+      },
+    },
+    config_web_links: [{
+      name: 'gitiles',
+      target: '_blank',
+      url: 'https://my/site/+log/123/project.config',
+    }],
+    can_upload: true,
+  };
+  const accessRes2 = {
+    local: {
+      GLOBAL_CAPABILITIES: {
+        permissions: {
+          accessDatabase: {
+            rules: {
+              group1: {
+                action: 'ALLOW',
+              },
+            },
+          },
+        },
+      },
+    },
+  };
+  const repoRes = {
+    labels: {
+      'Code-Review': {
+        values: {
+          ' 0': 'No score',
+          '-1': 'I would prefer this is not merged as is',
+          '-2': 'This shall not be merged',
+          '+1': 'Looks good to me, but someone else must approve',
+          '+2': 'Looks good to me, approved',
+        },
+      },
+    },
+  };
+  const capabilitiesRes = {
+    accessDatabase: {
+      id: 'accessDatabase',
+      name: 'Access Database',
+    },
+    createAccount: {
+      id: 'createAccount',
+      name: 'Create Account',
+    },
+  };
+  setup(() => {
+    element = basicFixture.instantiate();
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(null); },
+    });
+    repoStub = sinon.stub(element.$.restAPI, 'getRepo').returns(
+        Promise.resolve(repoRes));
+    element._loading = false;
+    element._ownerOf = [];
+    element._canUpload = false;
+  });
+
+  test('_repoChanged called when repo name changes', () => {
+    sinon.stub(element, '_repoChanged');
+    element.repo = 'New Repo';
+    assert.isTrue(element._repoChanged.called);
+  });
+
+  test('_repoChanged', done => {
+    const accessStub = sinon.stub(element.$.restAPI,
+        'getRepoAccessRights');
+
+    accessStub.withArgs('New Repo').returns(
+        Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+    accessStub.withArgs('Another New Repo')
+        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
+    const capabilitiesStub = sinon.stub(element.$.restAPI,
+        'getCapabilities');
+    capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
+
+    element._repoChanged('New Repo').then(() => {
+      assert.isTrue(accessStub.called);
+      assert.isTrue(capabilitiesStub.called);
+      assert.isTrue(repoStub.called);
+      assert.isNotOk(element._inheritsFrom);
+      assert.deepEqual(element._local, accessRes.local);
+      assert.deepEqual(element._sections,
+          toSortedPermissionsArray(accessRes.local));
+      assert.deepEqual(element._labels, repoRes.labels);
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('.weblinks')).display,
+      'block');
+      return element._repoChanged('Another New Repo');
+    })
+        .then(() => {
+          assert.deepEqual(element._sections,
+              toSortedPermissionsArray(accessRes2.local));
+          assert.equal(getComputedStyle(element.shadowRoot
+              .querySelector('.weblinks')).display,
+          'none');
+          done();
+        });
+  });
+
+  test('_repoChanged when repo changes to undefined returns', done => {
+    const capabilitiesRes = {
+      accessDatabase: {
+        id: 'accessDatabase',
+        name: 'Access Database',
+      },
+    };
+    const accessStub = sinon.stub(element.$.restAPI, 'getRepoAccessRights')
+        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
+    const capabilitiesStub = sinon.stub(element.$.restAPI,
+        'getCapabilities').returns(Promise.resolve(capabilitiesRes));
+
+    element._repoChanged().then(() => {
+      assert.isFalse(accessStub.called);
+      assert.isFalse(capabilitiesStub.called);
+      assert.isFalse(repoStub.called);
+      done();
+    });
+  });
+
+  test('_computeParentHref', () => {
+    const repoName = 'test-repo';
+    assert.equal(element._computeParentHref(repoName),
+        '/admin/repos/test-repo,access');
+  });
+
+  test('_computeMainClass', () => {
+    let ownerOf = ['refs/*'];
+    const editing = true;
+    const canUpload = false;
+    assert.equal(element._computeMainClass(ownerOf, canUpload), 'admin');
+    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
+        'admin editing');
+    ownerOf = [];
+    assert.equal(element._computeMainClass(ownerOf, canUpload), '');
+    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
+        'editing');
+  });
+
+  test('inherit section', () => {
+    element._local = {};
+    element._ownerOf = [];
+    sinon.stub(element, '_computeParentHref');
+    // Nothing should appear when no inherit from and not in edit mode.
+    assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
+    // The autocomplete should be hidden, and the link should be  displayed.
+    assert.isFalse(element._computeParentHref.called);
+    // When it edit mode, the autocomplete should appear.
+    element._editing = true;
+    // When editing, the autocomplete should still not be shown.
+    assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
+    element._editing = false;
+    element._inheritsFrom = {
+      name: 'another-repo',
+    };
+    // When there is a parent project, the link should be displayed.
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
+    assert.notEqual(getComputedStyle(element.$.inheritFromName).display,
+        'none');
+    assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
+        'none');
+    assert.isTrue(element._computeParentHref.called);
+    element._editing = true;
+    // When editing, the autocomplete should be shown.
+    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
+    assert.equal(getComputedStyle(element.$.inheritFromName).display, 'none');
+    assert.notEqual(getComputedStyle(element.$.editInheritFromInput).display,
+        'none');
+  });
+
+  test('_handleUpdateInheritFrom', () => {
+    element._inheritFromFilter = 'foo bar baz';
+    element._handleUpdateInheritFrom({detail: {value: 'abc+123'}});
+    assert.isOk(element._inheritsFrom);
+    assert.equal(element._inheritsFrom.id, 'abc+123');
+    assert.equal(element._inheritsFrom.name, 'foo bar baz');
+  });
+
+  test('_computeLoadingClass', () => {
+    assert.equal(element._computeLoadingClass(true), 'loading');
+    assert.equal(element._computeLoadingClass(false), '');
+  });
+
+  test('fires page-error', done => {
+    const response = {status: 404};
+
+    sinon.stub(
+        element.$.restAPI, 'getRepoAccessRights')
+        .callsFake((repoName, errFn) => {
+          errFn(response);
+        });
+
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
+    });
+
+    element.repo = 'test';
+  });
+
+  suite('with defined sections', () => {
+    const testEditSaveCancelBtns = (shouldShowSave, shouldShowSaveReview) => {
+      // Edit button is visible and Save button is hidden.
+      assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
+      assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
+      assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
+      assert.equal(element.$.editBtn.innerText, 'EDIT');
+      assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
+          'none');
+      element._inheritsFrom = {
+        id: 'test-project',
+      };
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('#editInheritFromInput'))
+          .display, 'none');
+
+      MockInteractions.tap(element.$.editBtn);
+      flushAsynchronousOperations();
+
+      // Edit button changes to Cancel button, and Save button is visible but
+      // disabled.
+      assert.equal(element.$.editBtn.innerText, 'CANCEL');
+      if (shouldShowSaveReview) {
+        assert.notEqual(getComputedStyle(element.$.saveReviewBtn).display,
+            'none');
+        assert.isTrue(element.$.saveReviewBtn.disabled);
+      }
+      if (shouldShowSave) {
+        assert.notEqual(getComputedStyle(element.$.saveBtn).display, 'none');
+        assert.isTrue(element.$.saveBtn.disabled);
+      }
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('#editInheritFromInput'))
+          .display, 'none');
+
+      // Save button should be enabled after access is modified
+      element.dispatchEvent(
+          new CustomEvent('access-modified', {
+            composed: true, bubbles: true,
+          }));
+      if (shouldShowSaveReview) {
+        assert.isFalse(element.$.saveReviewBtn.disabled);
+      }
+      if (shouldShowSave) {
+        assert.isFalse(element.$.saveBtn.disabled);
+      }
+    };
+
+    setup(() => {
+      // Create deep copies of these objects so the originals are not modified
+      // by any tests.
+      element._local = JSON.parse(JSON.stringify(accessRes.local));
+      element._ownerOf = [];
+      element._sections = toSortedPermissionsArray(element._local);
+      element._groups = JSON.parse(JSON.stringify(accessRes.groups));
+      element._capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
+      element._labels = JSON.parse(JSON.stringify(repoRes.labels));
+      flushAsynchronousOperations();
+    });
+
+    test('removing an added section', () => {
+      element.editing = true;
+      assert.equal(element._sections.length, 1);
+      element.shadowRoot
+          .querySelector('gr-access-section').dispatchEvent(
+              new CustomEvent('added-section-removed', {
+                composed: true, bubbles: true,
+              }));
+      flushAsynchronousOperations();
+      assert.equal(element._sections.length, 0);
+    });
+
+    test('button visibility for non ref owner', () => {
+      assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
+      assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
+    });
+
+    test('button visibility for non ref owner with upload privilege', () => {
+      element._canUpload = true;
+      testEditSaveCancelBtns(false, true);
+    });
+
+    test('button visibility for ref owner', () => {
+      element._ownerOf = ['refs/for/*'];
+      testEditSaveCancelBtns(true, false);
+    });
+
+    test('button visibility for ref owner and upload', () => {
+      element._ownerOf = ['refs/for/*'];
+      element._canUpload = true;
+      testEditSaveCancelBtns(true, false);
+    });
+
+    test('_handleAccessModified called with event fired', () => {
+      sinon.spy(element, '_handleAccessModified');
+      element.dispatchEvent(
+          new CustomEvent('access-modified', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleAccessModified.called);
+    });
+
+    test('_handleAccessModified called when parent changes', () => {
+      element._inheritsFrom = {
+        id: 'test-project',
+      };
+      flushAsynchronousOperations();
+      element.shadowRoot.querySelector('#editInheritFromInput').dispatchEvent(
+          new CustomEvent('commit', {
+            detail: {},
+            composed: true, bubbles: true,
+          }));
+      sinon.spy(element, '_handleAccessModified');
+      element.dispatchEvent(
+          new CustomEvent('access-modified', {
+            detail: {},
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleAccessModified.called);
+    });
+
+    test('_handleSaveForReview', () => {
+      const saveStub =
+          sinon.stub(element.$.restAPI, 'setRepoAccessRightsForReview');
+      sinon.stub(element, '_computeAddAndRemove').returns({
+        add: {},
+        remove: {},
+      });
+      element._handleSaveForReview();
+      assert.isFalse(saveStub.called);
+    });
+
+    test('_recursivelyRemoveDeleted', () => {
+      const obj = {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {
+                234: {action: 'ALLOW'},
+                123: {action: 'DENY', deleted: true},
+              },
+            },
+            read: {
+              deleted: true,
+              rules: {
+                234: {action: 'ALLOW'},
+              },
+            },
+          },
+        },
+      };
+      const expectedResult = {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {
+                234: {action: 'ALLOW'},
+              },
+            },
+          },
+        },
+      };
+      element._recursivelyRemoveDeleted(obj);
+      assert.deepEqual(obj, expectedResult);
+    });
+
+    test('_recursivelyUpdateAddRemoveObj on new added section', () => {
+      const obj = {
+        'refs/for/*': {
+          permissions: {
+            'label-Code-Review': {
+              rules: {
+                e798fed07afbc9173a587f876ef8760c78d240c1: {
+                  min: -2,
+                  max: 2,
+                  action: 'ALLOW',
+                  added: true,
+                },
+              },
+              added: true,
+              label: 'Code-Review',
+            },
+            'labelAs-Code-Review': {
+              rules: {
+                'ldap:gerritcodereview-eng': {
+                  min: -2,
+                  max: 2,
+                  action: 'ALLOW',
+                  added: true,
+                  deleted: true,
+                },
+              },
+              added: true,
+              label: 'Code-Review',
+            },
+          },
+          added: true,
+        },
+      };
+
+      const expectedResult = {
+        add: {
+          'refs/for/*': {
+            permissions: {
+              'label-Code-Review': {
+                rules: {
+                  e798fed07afbc9173a587f876ef8760c78d240c1: {
+                    min: -2,
+                    max: 2,
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+                added: true,
+                label: 'Code-Review',
+              },
+              'labelAs-Code-Review': {
+                rules: {},
+                added: true,
+                label: 'Code-Review',
+              },
+            },
+            added: true,
+          },
+        },
+        remove: {},
+      };
+      const updateObj = {add: {}, remove: {}};
+      element._recursivelyUpdateAddRemoveObj(obj, updateObj);
+      assert.deepEqual(updateObj, expectedResult);
+    });
+
+    test('_handleSaveForReview with no changes', () => {
+      assert.deepEqual(element._computeAddAndRemove(), {add: {}, remove: {}});
+    });
+
+    test('_handleSaveForReview parent change', () => {
+      element._inheritsFrom = {
+        id: 'test-project',
+      };
+      element._originalInheritsFrom = {
+        id: 'test-project-original',
+      };
+      assert.deepEqual(element._computeAddAndRemove(), {
+        parent: 'test-project', add: {}, remove: {},
+      });
+    });
+
+    test('_handleSaveForReview new parent with spaces', () => {
+      element._inheritsFrom = {id: 'spaces+in+project+name'};
+      element._originalInheritsFrom = {id: 'old-project'};
+      assert.deepEqual(element._computeAddAndRemove(), {
+        parent: 'spaces in project name', add: {}, remove: {},
+      });
+    });
+
+    test('_handleSaveForReview rules', () => {
+      // Delete a rule.
+      element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+      let expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Undo deleting a rule.
+      delete element._local['refs/*'].permissions.owner.rules[123].deleted;
+
+      // Modify a rule.
+      element._local['refs/*'].permissions.owner.rules[123].modified = true;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('_computeAddAndRemove permissions', () => {
+      // Add a new rule to a permission.
+      let expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+
+      element.shadowRoot
+          .querySelector('gr-access-section').shadowRoot
+          .querySelector('gr-permission')
+          ._handleAddRuleItem(
+              {detail: {value: {id: 'Maintainers'}}});
+
+      flushAsynchronousOperations();
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Remove the added rule.
+      delete element._local['refs/*'].permissions.owner.rules.Maintainers;
+
+      // Delete a permission.
+      element._local['refs/*'].permissions.owner.deleted = true;
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Undo delete permission.
+      delete element._local['refs/*'].permissions.owner.deleted;
+
+      // Modify a permission.
+      element._local['refs/*'].permissions.owner.modified = true;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  123: {action: 'DENY'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('_computeAddAndRemove sections', () => {
+      // Add a new permission to a section
+      let expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {},
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      element.shadowRoot
+          .querySelector('gr-access-section')._handleAddPermission();
+      flushAsynchronousOperations();
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add a new rule to the new permission.
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    min: -2,
+                    max: 2,
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      const newPermission =
+          dom(element.shadowRoot
+              .querySelector('gr-access-section').root).querySelectorAll(
+              'gr-permission')[2];
+      newPermission._handleAddRuleItem(
+          {detail: {value: {id: 'Maintainers'}}});
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Modify a section reference.
+      element._local['refs/*'].updatedId = 'refs/for/bar';
+      element._local['refs/*'].modified = true;
+      expectedInput = {
+        add: {
+          'refs/for/bar': {
+            modified: true,
+            updatedId: 'refs/for/bar',
+            permissions: {
+              'owner': {
+                rules: {
+                  234: {action: 'ALLOW'},
+                  123: {action: 'DENY'},
+                },
+              },
+              'read': {
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    min: -2,
+                    max: 2,
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Delete a section.
+      element._local['refs/*'].deleted = true;
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('_computeAddAndRemove new section', () => {
+      // Add a new permission to a section
+      let expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {},
+          },
+        },
+        remove: {},
+      };
+      MockInteractions.tap(element.$.addReferenceBtn);
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {},
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      const newSection = dom(element.root)
+          .querySelectorAll('gr-access-section')[1];
+      newSection._handleAddPermission();
+      flushAsynchronousOperations();
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add rule to the new permission.
+      expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+
+      newSection.shadowRoot
+          .querySelector('gr-permission')._handleAddRuleItem(
+              {detail: {value: {id: 'Maintainers'}}});
+
+      flushAsynchronousOperations();
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Modify a the reference from the default value.
+      element._local['refs/for/*'].updatedId = 'refs/for/new';
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('_computeAddAndRemove combinations', () => {
+      // Modify rule and delete permission that it is inside of.
+      element._local['refs/*'].permissions.owner.rules[123].modified = true;
+      element._local['refs/*'].permissions.owner.deleted = true;
+      let expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      // Delete rule and delete permission that it is inside of.
+      element._local['refs/*'].permissions.owner.rules[123].modified = false;
+      element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Also modify a different rule inside of another permission.
+      element._local['refs/*'].permissions.read.modified = true;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+      // Modify both permissions with an exclusive bit. Owner is still
+      // deleted.
+      element._local['refs/*'].permissions.owner.exclusive = true;
+      element._local['refs/*'].permissions.owner.modified = true;
+      element._local['refs/*'].permissions.read.exclusive = true;
+      element._local['refs/*'].permissions.read.modified = true;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add a rule to the existing permission;
+      const readPermission =
+          dom(element.shadowRoot
+              .querySelector('gr-access-section').root).querySelectorAll(
+              'gr-permission')[1];
+      readPermission._handleAddRuleItem(
+          {detail: {value: {id: 'Maintainers'}}});
+
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  Maintainers: {action: 'ALLOW', added: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Change one of the refs
+      element._local['refs/*'].updatedId = 'refs/for/bar';
+      element._local['refs/*'].modified = true;
+
+      expectedInput = {
+        add: {
+          'refs/for/bar': {
+            modified: true,
+            updatedId: 'refs/for/bar',
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  Maintainers: {action: 'ALLOW', added: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      element._local['refs/*'].deleted = true;
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add a new section.
+      MockInteractions.tap(element.$.addReferenceBtn);
+      let newSection = dom(element.root)
+          .querySelectorAll('gr-access-section')[1];
+      newSection._handleAddPermission();
+      flushAsynchronousOperations();
+      newSection.shadowRoot
+          .querySelector('gr-permission')._handleAddRuleItem(
+              {detail: {value: {id: 'Maintainers'}}});
+      // Modify a the reference from the default value.
+      element._local['refs/for/*'].updatedId = 'refs/for/new';
+
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Modify newly added rule inside new ref.
+      element._local['refs/for/*'].permissions['label-Code-Review'].
+          rules['Maintainers'].modified = true;
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    modified: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+
+      // Add a second new section.
+      MockInteractions.tap(element.$.addReferenceBtn);
+      newSection = dom(element.root)
+          .querySelectorAll('gr-access-section')[2];
+      newSection._handleAddPermission();
+      flushAsynchronousOperations();
+      newSection.shadowRoot
+          .querySelector('gr-permission')._handleAddRuleItem(
+              {detail: {value: {id: 'Maintainers'}}});
+      // Modify a the reference from the default value.
+      element._local['refs/for/**'].updatedId = 'refs/for/new2';
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    modified: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+          'refs/for/new2': {
+            added: true,
+            updatedId: 'refs/for/new2',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
+    });
+
+    test('Unsaved added refs are discarded when edit cancelled', () => {
+      // Unsaved changes are discarded when editing is cancelled.
+      MockInteractions.tap(element.$.editBtn);
+      assert.equal(element._sections.length, 1);
+      assert.equal(Object.keys(element._local).length, 1);
+      MockInteractions.tap(element.$.addReferenceBtn);
+      assert.equal(element._sections.length, 2);
+      assert.equal(Object.keys(element._local).length, 2);
+      MockInteractions.tap(element.$.editBtn);
+      assert.equal(element._sections.length, 1);
+      assert.equal(Object.keys(element._local).length, 1);
+    });
+
+    test('_handleSave', done => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      sinon.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+      sinon.stub(GerritNav, 'navigateToChange');
+      let resolver;
+      const saveStub = sinon.stub(element.$.restAPI,
+          'setRepoAccessRights')
+          .returns(new Promise(r => resolver = r));
+
+      element.repo = 'test-repo';
+      sinon.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
+
+      element._modified = true;
+      MockInteractions.tap(element.$.saveBtn);
+      assert.equal(element.$.saveBtn.hasAttribute('loading'), true);
+      resolver({_number: 1});
+      flush(() => {
+        assert.isTrue(saveStub.called);
+        assert.isTrue(GerritNav.navigateToChange.notCalled);
+        done();
+      });
+    });
+
+    test('_handleSaveForReview', done => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      sinon.stub(element.$.restAPI, 'getRepoAccessRights').returns(
+          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+      sinon.stub(GerritNav, 'navigateToChange');
+      let resolver;
+      const saveForReviewStub = sinon.stub(element.$.restAPI,
+          'setRepoAccessRightsForReview')
+          .returns(new Promise(r => resolver = r));
+
+      element.repo = 'test-repo';
+      sinon.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
+
+      element._modified = true;
+      MockInteractions.tap(element.$.saveReviewBtn);
+      assert.equal(element.$.saveReviewBtn.hasAttribute('loading'), true);
+      resolver({_number: 1});
+      flush(() => {
+        assert.isTrue(saveForReviewStub.called);
+        assert.isTrue(GerritNav.navigateToChange
+            .lastCall.calledWithExactly({_number: 1}));
+        done();
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
deleted file mode 100644
index 53b4989..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-repo-command_html.js';
-
-/** @extends Polymer.Element */
-class GrRepoCommand extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-repo-command'; }
-
-  static get properties() {
-    return {
-      title: String,
-      disabled: Boolean,
-      tooltip: String,
-    };
-  }
-
-  /**
-   * Fired when command button is tapped.
-   *
-   * @event command-tap
-   */
-
-  _onCommandTap() {
-    this.dispatchEvent(
-        new CustomEvent('command-tap', {bubbles: true, composed: true}));
-  }
-}
-
-customElements.define(GrRepoCommand.is, GrRepoCommand);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
deleted file mode 100644
index cf934b0..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_html.js
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-xxl);
-    }
-  </style>
-  <h3>[[title]]</h3>
-  <gr-button
-    title$="[[tooltip]]"
-    disabled$="[[disabled]]"
-    on-click="_onCommandTap"
-  >
-    [[title]]
-  </gr-button>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html b/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
deleted file mode 100644
index a73f071..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-command/gr-repo-command_test.html
+++ /dev/null
@@ -1,53 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-repo-command</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-repo-command></gr-repo-command>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-command.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-repo-command tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('dispatched command-tap on button tap', done => {
-    element.addEventListener('command-tap', () => {
-      done();
-    });
-    MockInteractions.tap(
-        dom(element.root).querySelector('gr-button'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
index 1f1dc5b..4ab1b98 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/gr-subpage-styles.js';
@@ -26,7 +24,6 @@
 import '../../shared/gr-overlay/gr-overlay.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-create-change-dialog/gr-create-change-dialog.js';
-import '../gr-repo-command/gr-repo-command.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
@@ -43,7 +40,7 @@
 const CREATE_CHANGE_SUCCEEDED_MESSAGE = 'Navigating to change';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrRepoCommands extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -62,6 +59,10 @@
       /** @type {?} */
       _repoConfig: Object,
       _canCreate: Boolean,
+      // states
+      _creatingChange: Boolean,
+      _editingConfig: Boolean,
+      _runningGC: Boolean,
     };
   }
 
@@ -104,13 +105,17 @@
   }
 
   _handleRunningGC() {
+    this._runningGC = true;
     return this.$.restAPI.runRepoGC(this.repo).then(response => {
       if (response.status === 200) {
         this.dispatchEvent(new CustomEvent(
             'show-alert',
             {detail: {message: GC_MESSAGE}, bubbles: true, composed: true}));
       }
-    });
+    })
+        .finally(() => {
+          this._runningGC = false;
+        });
   }
 
   _createNewChange() {
@@ -118,7 +123,11 @@
   }
 
   _handleCreateChange() {
-    this.$.createNewChangeModal.handleCreateChange();
+    this._creatingChange = true;
+    this.$.createNewChangeModal.handleCreateChange()
+        .finally(() => {
+          this._creatingChange = false;
+        });
     this._handleCloseCreateChange();
   }
 
@@ -127,6 +136,7 @@
   }
 
   _handleEditRepoConfig() {
+    this._editingConfig = true;
     return this.$.restAPI.createChange(this.repo, CONFIG_BRANCH,
         EDIT_CONFIG_SUBJECT, undefined, false, true).then(change => {
       const message = change ?
@@ -138,7 +148,10 @@
 
       GerritNav.navigateToRelativeUrl(GerritNav.getEditUrlForDiff(
           change, CONFIG_PATH, INITIAL_PATCHSET));
-    });
+    })
+        .finally(() => {
+          this._editingConfig = false;
+        });
   }
 }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js
deleted file mode 100644
index b27c36b..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.js
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <main class="gr-form-styles read-only">
-    <h1 id="Title">Repository Commands</h1>
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h2 id="options">Command</h2>
-      <div id="form">
-        <gr-repo-command
-          title="Create change"
-          on-command-tap="_createNewChange"
-        >
-        </gr-repo-command>
-        <gr-repo-command
-          id="editRepoConfig"
-          title="Edit repo config"
-          on-command-tap="_handleEditRepoConfig"
-        >
-        </gr-repo-command>
-        <gr-repo-command
-          title="[[_repoConfig.actions.gc.label]]"
-          tooltip="[[_repoConfig.actions.gc.title]]"
-          hidden$="[[!_repoConfig.actions.gc.enabled]]"
-          on-command-tap="_handleRunningGC"
-        >
-        </gr-repo-command>
-        <gr-endpoint-decorator name="repo-command">
-          <gr-endpoint-param name="config" value="[[_repoConfig]]">
-          </gr-endpoint-param>
-          <gr-endpoint-param name="repoName" value="[[repo]]">
-          </gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </div>
-    </div>
-  </main>
-  <gr-overlay id="createChangeOverlay" with-backdrop="">
-    <gr-dialog
-      id="createChangeDialog"
-      confirm-label="Create"
-      disabled="[[!_canCreate]]"
-      on-confirm="_handleCreateChange"
-      on-cancel="_handleCloseCreateChange"
-    >
-      <div class="header" slot="header">
-        Create Change
-      </div>
-      <div class="main" slot="main">
-        <gr-create-change-dialog
-          id="createNewChangeModal"
-          can-create="{{_canCreate}}"
-          repo-name="[[repo]]"
-        ></gr-create-change-dialog>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
new file mode 100644
index 0000000..3880e4a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-subpage-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    #form gr-button {
+      margin-bottom: var(--spacing-xxl);
+    }
+  </style>
+  <main class="gr-form-styles read-only">
+    <h1 id="Title" class="heading-1">Repository Commands</h1>
+    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+      Loading...
+    </div>
+    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <h2 id="options" class="heading-2">Command</h2>
+      <div id="form">
+        <h3>Create change</h3>
+        <gr-button loading="[[_creatingChange]]" on-click="_createNewChange">
+          Create change
+        </gr-button>
+        <h3>Edit repo config</h3>
+        <gr-button
+          id="editRepoConfig"
+          loading="[[_editingConfig]]"
+          on-click="_handleEditRepoConfig"
+        >
+          Edit repo config
+        </gr-button>
+        <h3 hidden="[[!_repoConfig.actions.gc.enabled]]">
+          [[_repoConfig.actions.gc.label]]
+        </h3>
+        <gr-button
+          hidden="[[!_repoConfig.actions.gc.enabled]]"
+          title="[[_repoConfig.actions.gc.title]]"
+          loading="[[_runningGC]]"
+          on-click="_handleRunningGC"
+        >
+          [[_repoConfig.actions.gc.label]]
+        </gr-button>
+        <gr-endpoint-decorator name="repo-command">
+          <gr-endpoint-param name="config" value="[[_repoConfig]]">
+          </gr-endpoint-param>
+          <gr-endpoint-param name="repoName" value="[[repo]]">
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>
+    </div>
+  </main>
+  <gr-overlay id="createChangeOverlay" with-backdrop="">
+    <gr-dialog
+      id="createChangeDialog"
+      confirm-label="Create"
+      disabled="[[!_canCreate]]"
+      on-confirm="_handleCreateChange"
+      on-cancel="_handleCloseCreateChange"
+    >
+      <div class="header" slot="header">
+        Create Change
+      </div>
+      <div class="main" slot="main">
+        <gr-create-change-dialog
+          id="createNewChangeModal"
+          can-create="{{_canCreate}}"
+          repo-name="[[repo]]"
+        ></gr-create-change-dialog>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
deleted file mode 100644
index db2bfcf..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.html
+++ /dev/null
@@ -1,151 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-repo-commands</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-repo-commands></gr-repo-commands>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-commands.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-repo-commands tests', () => {
-  let element;
-  let sandbox;
-  let repoStub;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    repoStub = sandbox.stub(
-        element.$.restAPI,
-        'getProjectConfig',
-        () => Promise.resolve({}));
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('create new change dialog', () => {
-    test('_createNewChange opens modal', () => {
-      const openStub = sandbox.stub(element.$.createChangeOverlay, 'open');
-      element._createNewChange();
-      assert.isTrue(openStub.called);
-    });
-
-    test('_handleCreateChange called when confirm fired', () => {
-      sandbox.stub(element, '_handleCreateChange');
-      element.$.createChangeDialog.dispatchEvent(
-          new CustomEvent('confirm', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCreateChange.called);
-    });
-
-    test('_handleCloseCreateChange called when cancel fired', () => {
-      sandbox.stub(element, '_handleCloseCreateChange');
-      element.$.createChangeDialog.dispatchEvent(
-          new CustomEvent('cancel', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCloseCreateChange.called);
-    });
-  });
-
-  suite('edit repo config', () => {
-    let createChangeStub;
-    let urlStub;
-    let handleSpy;
-    let alertStub;
-
-    setup(() => {
-      createChangeStub = sandbox.stub(element.$.restAPI, 'createChange');
-      urlStub = sandbox.stub(GerritNav, 'getEditUrlForDiff');
-      sandbox.stub(GerritNav, 'navigateToRelativeUrl');
-      handleSpy = sandbox.spy(element, '_handleEditRepoConfig');
-      alertStub = sandbox.stub();
-      element.addEventListener('show-alert', alertStub);
-    });
-
-    test('successful creation of change', () => {
-      const change = {_number: '1'};
-      createChangeStub.returns(Promise.resolve(change));
-      MockInteractions.tap(element.$.editRepoConfig.shadowRoot
-          .querySelector('gr-button'));
-      return handleSpy.lastCall.returnValue.then(() => {
-        flushAsynchronousOperations();
-
-        assert.isTrue(alertStub.called);
-        assert.equal(alertStub.lastCall.args[0].detail.message,
-            'Navigating to change');
-        assert.isTrue(urlStub.called);
-        assert.deepEqual(urlStub.lastCall.args,
-            [change, 'project.config', 1]);
-      });
-    });
-
-    test('unsuccessful creation of change', () => {
-      createChangeStub.returns(Promise.resolve(null));
-      MockInteractions.tap(element.$.editRepoConfig.shadowRoot
-          .querySelector('gr-button'));
-      return handleSpy.lastCall.returnValue.then(() => {
-        flushAsynchronousOperations();
-
-        assert.isTrue(alertStub.called);
-        assert.equal(alertStub.lastCall.args[0].detail.message,
-            'Failed to create change.');
-        assert.isFalse(urlStub.called);
-      });
-    });
-  });
-
-  suite('404', () => {
-    test('fires page-error', done => {
-      repoStub.restore();
-
-      element.repo = 'test';
-
-      const response = {status: 404};
-      sandbox.stub(
-          element.$.restAPI, 'getProjectConfig', (repo, errFn) => {
-            errFn(response);
-          });
-      element.addEventListener('page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element._loadRepo();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
new file mode 100644
index 0000000..0bb0c55
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
@@ -0,0 +1,133 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-repo-commands.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-repo-commands');
+
+suite('gr-repo-commands tests', () => {
+  let element;
+
+  let repoStub;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    repoStub = sinon.stub(
+        element.$.restAPI,
+        'getProjectConfig')
+        .callsFake(() => Promise.resolve({}));
+  });
+
+  suite('create new change dialog', () => {
+    test('_createNewChange opens modal', () => {
+      const openStub = sinon.stub(element.$.createChangeOverlay, 'open');
+      element._createNewChange();
+      assert.isTrue(openStub.called);
+    });
+
+    test('_handleCreateChange called when confirm fired', () => {
+      sinon.stub(element, '_handleCreateChange');
+      element.$.createChangeDialog.dispatchEvent(
+          new CustomEvent('confirm', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCreateChange.called);
+    });
+
+    test('_handleCloseCreateChange called when cancel fired', () => {
+      sinon.stub(element, '_handleCloseCreateChange');
+      element.$.createChangeDialog.dispatchEvent(
+          new CustomEvent('cancel', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCloseCreateChange.called);
+    });
+  });
+
+  suite('edit repo config', () => {
+    let createChangeStub;
+    let urlStub;
+    let handleSpy;
+    let alertStub;
+
+    setup(() => {
+      createChangeStub = sinon.stub(element.$.restAPI, 'createChange');
+      urlStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
+      sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      handleSpy = sinon.spy(element, '_handleEditRepoConfig');
+      alertStub = sinon.stub();
+      element.addEventListener('show-alert', alertStub);
+    });
+
+    test('successful creation of change', () => {
+      const change = {_number: '1'};
+      createChangeStub.returns(Promise.resolve(change));
+      MockInteractions.tap(element.$.editRepoConfig);
+      assert.isTrue(element.$.editRepoConfig.loading);
+      return handleSpy.lastCall.returnValue.then(() => {
+        flushAsynchronousOperations();
+
+        assert.isTrue(alertStub.called);
+        assert.equal(alertStub.lastCall.args[0].detail.message,
+            'Navigating to change');
+        assert.isTrue(urlStub.called);
+        assert.deepEqual(urlStub.lastCall.args,
+            [change, 'project.config', 1]);
+        assert.isFalse(element.$.editRepoConfig.loading);
+      });
+    });
+
+    test('unsuccessful creation of change', () => {
+      createChangeStub.returns(Promise.resolve(null));
+      MockInteractions.tap(element.$.editRepoConfig);
+      assert.isTrue(element.$.editRepoConfig.loading);
+      return handleSpy.lastCall.returnValue.then(() => {
+        flushAsynchronousOperations();
+
+        assert.isTrue(alertStub.called);
+        assert.equal(alertStub.lastCall.args[0].detail.message,
+            'Failed to create change.');
+        assert.isFalse(urlStub.called);
+        assert.isFalse(element.$.editRepoConfig.loading);
+      });
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      repoStub.restore();
+
+      element.repo = 'test';
+
+      const response = {status: 404};
+      sinon.stub(
+          element.$.restAPI, 'getProjectConfig')
+          .callsFake((repo, errFn) => {
+            errFn(response);
+          });
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      element._loadRepo();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
index 072fc721..f47ff76 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
@@ -26,7 +25,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrRepoDashboards extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.js
deleted file mode 100644
index 8ce69df..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-xxl);
-    }
-    .loading #dashboards,
-    #loadingContainer {
-      display: none;
-    }
-    .loading #loadingContainer {
-      display: block;
-    }
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <table id="list" class$="genericList [[_computeLoadingClass(_loading)]]">
-    <tbody>
-      <tr class="headerRow">
-        <th class="topHeader">Dashboard name</th>
-        <th class="topHeader">Dashboard title</th>
-        <th class="topHeader">Dashboard description</th>
-        <th class="topHeader">Inherited from</th>
-        <th class="topHeader">Default</th>
-      </tr>
-      <tr id="loadingContainer">
-        <td>Loading...</td>
-      </tr>
-    </tbody>
-    <tbody id="dashboards">
-      <template is="dom-repeat" items="[[_dashboards]]">
-        <tr class="groupHeader">
-          <td colspan="5">[[item.section]]</td>
-        </tr>
-        <template is="dom-repeat" items="[[item.dashboards]]">
-          <tr class="table">
-            <td class="name">
-              <a href$="[[_getUrl(item.project, item.id)]]">[[item.path]]</a>
-            </td>
-            <td class="title">[[item.title]]</td>
-            <td class="desc">[[item.description]]</td>
-            <td class="inherited">
-              [[_computeInheritedFrom(item.project, item.defining_project)]]
-            </td>
-            <td class="default">[[_computeIsDefault(item.is_default)]]</td>
-          </tr>
-        </template>
-      </template>
-    </tbody>
-  </table>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
new file mode 100644
index 0000000..7cdd10e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-xxl);
+    }
+    .loading #dashboards,
+    #loadingContainer {
+      display: none;
+    }
+    .loading #loadingContainer {
+      display: block;
+    }
+  </style>
+  <style include="gr-table-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <table id="list" class$="genericList [[_computeLoadingClass(_loading)]]">
+    <tbody>
+      <tr class="headerRow">
+        <th class="topHeader">Dashboard name</th>
+        <th class="topHeader">Dashboard title</th>
+        <th class="topHeader">Dashboard description</th>
+        <th class="topHeader">Inherited from</th>
+        <th class="topHeader">Default</th>
+      </tr>
+      <tr id="loadingContainer">
+        <td>Loading...</td>
+      </tr>
+    </tbody>
+    <tbody id="dashboards">
+      <template is="dom-repeat" items="[[_dashboards]]">
+        <tr class="groupHeader">
+          <td colspan="5">[[item.section]]</td>
+        </tr>
+        <template is="dom-repeat" items="[[item.dashboards]]">
+          <tr class="table">
+            <td class="name">
+              <a href$="[[_getUrl(item.project, item.id)]]">[[item.path]]</a>
+            </td>
+            <td class="title">[[item.title]]</td>
+            <td class="desc">[[item.description]]</td>
+            <td class="inherited">
+              [[_computeInheritedFrom(item.project, item.defining_project)]]
+            </td>
+            <td class="default">[[_computeIsDefault(item.is_default)]]</td>
+          </tr>
+        </template>
+      </template>
+    </tbody>
+  </table>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
deleted file mode 100644
index dc12eff..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
+++ /dev/null
@@ -1,161 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-repo-dashboards</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-repo-dashboards></gr-repo-dashboards>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-dashboards.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-repo-dashboards tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('dashboard table', () => {
-    setup(() => {
-      sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
-          Promise.resolve([
-            {
-              id: 'default:contributor',
-              project: 'gerrit',
-              defining_project: 'gerrit',
-              ref: 'default',
-              path: 'contributor',
-              description: 'Own contributions.',
-              foreach: 'owner:self',
-              url: '/dashboard/?params',
-              title: 'Contributor Dashboard',
-              sections: [
-                {
-                  name: 'Mine To Rebase',
-                  query: 'is:open -is:mergeable',
-                },
-                {
-                  name: 'My Recently Merged',
-                  query: 'is:merged limit:10',
-                },
-              ],
-            },
-            {
-              id: 'custom:custom2',
-              project: 'gerrit',
-              defining_project: 'Public-Projects',
-              ref: 'custom',
-              path: 'open',
-              description: 'Recent open changes.',
-              url: '/dashboard/?params',
-              title: 'Open Changes',
-              sections: [
-                {
-                  name: 'Open Changes',
-                  query: 'status:open project:${project} -age:7w',
-                },
-              ],
-            },
-            {
-              id: 'default:abc',
-              project: 'gerrit',
-              ref: 'default',
-            },
-            {
-              id: 'custom:custom1',
-              project: 'gerrit',
-              ref: 'custom',
-            },
-          ]));
-    });
-
-    test('loading, sections, and ordering', done => {
-      assert.isTrue(element._loading);
-      assert.notEqual(getComputedStyle(element.$.loadingContainer).display,
-          'none');
-      assert.equal(getComputedStyle(element.$.dashboards).display,
-          'none');
-      element.repo = 'test';
-      flush(() => {
-        assert.equal(getComputedStyle(element.$.loadingContainer).display,
-            'none');
-        assert.notEqual(getComputedStyle(element.$.dashboards).display,
-            'none');
-
-        assert.equal(element._dashboards.length, 2);
-        assert.equal(element._dashboards[0].section, 'custom');
-        assert.equal(element._dashboards[1].section, 'default');
-
-        const dashboards = element._dashboards[0].dashboards;
-        assert.equal(dashboards.length, 2);
-        assert.equal(dashboards[0].id, 'custom:custom1');
-        assert.equal(dashboards[1].id, 'custom:custom2');
-
-        done();
-      });
-    });
-  });
-
-  suite('test url', () => {
-    test('_getUrl', () => {
-      sandbox.stub(GerritNav, 'getUrlForRepoDashboard',
-          () => '/r/dashboard/test');
-
-      assert.equal(element._getUrl('/dashboard/test', {}), '/r/dashboard/test');
-
-      assert.equal(element._getUrl(undefined, undefined), '');
-    });
-  });
-
-  suite('404', () => {
-    test('fires page-error', done => {
-      const response = {status: 404};
-      sandbox.stub(
-          element.$.restAPI, 'getRepoDashboards', (repo, errFn) => {
-            errFn(response);
-          });
-
-      element.addEventListener('page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element.repo = 'test';
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
new file mode 100644
index 0000000..b4d3575
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
@@ -0,0 +1,141 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-repo-dashboards.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-repo-dashboards');
+
+suite('gr-repo-dashboards tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('dashboard table', () => {
+    setup(() => {
+      sinon.stub(element.$.restAPI, 'getRepoDashboards').returns(
+          Promise.resolve([
+            {
+              id: 'default:contributor',
+              project: 'gerrit',
+              defining_project: 'gerrit',
+              ref: 'default',
+              path: 'contributor',
+              description: 'Own contributions.',
+              foreach: 'owner:self',
+              url: '/dashboard/?params',
+              title: 'Contributor Dashboard',
+              sections: [
+                {
+                  name: 'Mine To Rebase',
+                  query: 'is:open -is:mergeable',
+                },
+                {
+                  name: 'My Recently Merged',
+                  query: 'is:merged limit:10',
+                },
+              ],
+            },
+            {
+              id: 'custom:custom2',
+              project: 'gerrit',
+              defining_project: 'Public-Projects',
+              ref: 'custom',
+              path: 'open',
+              description: 'Recent open changes.',
+              url: '/dashboard/?params',
+              title: 'Open Changes',
+              sections: [
+                {
+                  name: 'Open Changes',
+                  query: 'status:open project:${project} -age:7w',
+                },
+              ],
+            },
+            {
+              id: 'default:abc',
+              project: 'gerrit',
+              ref: 'default',
+            },
+            {
+              id: 'custom:custom1',
+              project: 'gerrit',
+              ref: 'custom',
+            },
+          ]));
+    });
+
+    test('loading, sections, and ordering', done => {
+      assert.isTrue(element._loading);
+      assert.notEqual(getComputedStyle(element.$.loadingContainer).display,
+          'none');
+      assert.equal(getComputedStyle(element.$.dashboards).display,
+          'none');
+      element.repo = 'test';
+      flush(() => {
+        assert.equal(getComputedStyle(element.$.loadingContainer).display,
+            'none');
+        assert.notEqual(getComputedStyle(element.$.dashboards).display,
+            'none');
+
+        assert.equal(element._dashboards.length, 2);
+        assert.equal(element._dashboards[0].section, 'custom');
+        assert.equal(element._dashboards[1].section, 'default');
+
+        const dashboards = element._dashboards[0].dashboards;
+        assert.equal(dashboards.length, 2);
+        assert.equal(dashboards[0].id, 'custom:custom1');
+        assert.equal(dashboards[1].id, 'custom:custom2');
+
+        done();
+      });
+    });
+  });
+
+  suite('test url', () => {
+    test('_getUrl', () => {
+      sinon.stub(GerritNav, 'getUrlForRepoDashboard').callsFake(
+          () => '/r/dashboard/test');
+
+      assert.equal(element._getUrl('/dashboard/test', {}), '/r/dashboard/test');
+
+      assert.equal(element._getUrl(undefined, undefined), '');
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', done => {
+      const response = {status: 404};
+      sinon.stub(
+          element.$.restAPI, 'getRepoDashboards')
+          .callsFake((repo, errFn) => {
+            errFn(response);
+          });
+
+      element.addEventListener('page-error', e => {
+        assert.deepEqual(e.detail.response, response);
+        done();
+      });
+
+      element.repo = 'test';
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
index e8b3d9a..4989365 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
@@ -16,7 +16,6 @@
  */
 
 import '@polymer/iron-input/iron-input.js';
-import '../../../scripts/bundled-polymer.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/gr-table-styles.js';
 import '../../../styles/shared-styles.js';
@@ -30,13 +29,12 @@
 import '../gr-create-pointer-dialog/gr-create-pointer-dialog.js';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-repo-detail-list_html.js';
-import {ListViewBehavior} from '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
+import {encodeURL} from '../../../utils/url-util.js';
 
 const DETAIL_TYPES = {
   BRANCHES: 'branches',
@@ -47,12 +45,9 @@
 
 /**
  * @appliesMixin ListViewMixin
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrRepoDetailList extends mixinBehaviors( [
-  ListViewBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
+class GrRepoDetailList extends ListViewMixin(GestureEventListeners(
     LegacyElementMixin(
         PolymerElement))) {
   static get template() { return htmlTemplate; }
@@ -171,7 +166,7 @@
   }
 
   _getPath(repo) {
-    return `/admin/repos/${this.encodeURL(repo, false)},` +
+    return `/admin/repos/${encodeURL(repo, false)},` +
         `${this.detailType}`;
   }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.js
deleted file mode 100644
index 21971e7..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.js
+++ /dev/null
@@ -1,222 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    .tags td.name {
-      min-width: 25em;
-    }
-    td.name,
-    td.revision,
-    td.message {
-      word-break: break-word;
-    }
-    td.revision.tags {
-      width: 27em;
-    }
-    td.message,
-    td.tagger {
-      max-width: 15em;
-    }
-    .editing .editItem {
-      display: inherit;
-    }
-    .editItem,
-    .editing .editBtn,
-    .canEdit .revisionNoEditing,
-    .editing .revisionWithEditing,
-    .revisionEdit,
-    .hideItem {
-      display: none;
-    }
-    .revisionEdit gr-button {
-      margin-left: var(--spacing-m);
-    }
-    .editBtn {
-      margin-left: var(--spacing-l);
-    }
-    .canEdit .revisionEdit {
-      align-items: center;
-      display: flex;
-    }
-    .deleteButton:not(.show) {
-      display: none;
-    }
-    .tagger.hide {
-      display: none;
-    }
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <gr-list-view
-    create-new="[[_loggedIn]]"
-    filter="[[_filter]]"
-    items-per-page="[[_itemsPerPage]]"
-    items="[[_items]]"
-    loading="[[_loading]]"
-    offset="[[_offset]]"
-    on-create-clicked="_handleCreateClicked"
-    path="[[_getPath(_repo, detailType)]]"
-  >
-    <table id="list" class="genericList gr-form-styles">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Name</th>
-          <th class="revision topHeader">Revision</th>
-          <th class$="message topHeader [[_hideIfBranch(detailType)]]">
-            Message
-          </th>
-          <th class$="tagger topHeader [[_hideIfBranch(detailType)]]">
-            Tagger
-          </th>
-          <th class="repositoryBrowser topHeader">
-            Repository Browser
-          </th>
-          <th class="delete topHeader"></th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_shownItems]]">
-          <tr class="table">
-            <td class$="[[detailType]] name">
-              [[_stripRefs(item.ref, detailType)]]
-            </td>
-            <td
-              class$="[[detailType]] revision [[_computeCanEditClass(item.ref, detailType, _isOwner)]]"
-            >
-              <span class="revisionNoEditing">
-                [[item.revision]]
-              </span>
-              <span class$="revisionEdit [[_computeEditingClass(_isEditing)]]">
-                <span class="revisionWithEditing">
-                  [[item.revision]]
-                </span>
-                <gr-button
-                  link=""
-                  on-click="_handleEditRevision"
-                  class="editBtn"
-                >
-                  edit
-                </gr-button>
-                <iron-input bind-value="{{_revisedRef}}" class="editItem">
-                  <input is="iron-input" bind-value="{{_revisedRef}}" />
-                </iron-input>
-                <gr-button
-                  link=""
-                  on-click="_handleCancelRevision"
-                  class="cancelBtn editItem"
-                >
-                  Cancel
-                </gr-button>
-                <gr-button
-                  link=""
-                  on-click="_handleSaveRevision"
-                  class="saveBtn editItem"
-                  disabled="[[!_revisedRef]]"
-                >
-                  Save
-                </gr-button>
-              </span>
-            </td>
-            <td class$="message [[_hideIfBranch(detailType)]]">
-              [[_computeMessage(item.message)]]
-            </td>
-            <td class$="tagger [[_hideIfBranch(detailType)]]">
-              <div class$="tagger [[_computeHideTagger(item.tagger)]]">
-                <gr-account-link account="[[item.tagger]]"> </gr-account-link>
-                (<gr-date-formatter
-                  has-tooltip=""
-                  date-str="[[item.tagger.date]]"
-                >
-                </gr-date-formatter
-                >)
-              </div>
-            </td>
-            <td class="repositoryBrowser">
-              <template
-                is="dom-repeat"
-                items="[[_computeWeblink(item)]]"
-                as="link"
-              >
-                <a
-                  href$="[[link.url]]"
-                  class="webLink"
-                  rel="noopener"
-                  target="_blank"
-                >
-                  ([[link.name]])
-                </a>
-              </template>
-            </td>
-            <td class="delete">
-              <gr-button
-                link=""
-                class$="deleteButton [[_computeHideDeleteClass(_isOwner, item.can_delete)]]"
-                on-click="_handleDeleteItem"
-              >
-                Delete
-              </gr-button>
-            </td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-    <gr-overlay id="overlay" with-backdrop="">
-      <gr-confirm-delete-item-dialog
-        class="confirmDialog"
-        on-confirm="_handleDeleteItemConfirm"
-        on-cancel="_handleConfirmDialogCancel"
-        item="[[_refName]]"
-        item-type="[[detailType]]"
-      ></gr-confirm-delete-item-dialog>
-    </gr-overlay>
-  </gr-list-view>
-  <gr-overlay id="createOverlay" with-backdrop="">
-    <gr-dialog
-      id="createDialog"
-      disabled="[[!_hasNewItemName]]"
-      confirm-label="Create"
-      on-confirm="_handleCreateItem"
-      on-cancel="_handleCloseCreate"
-    >
-      <div class="header" slot="header">
-        Create [[_computeItemName(detailType)]]
-      </div>
-      <div class="main" slot="main">
-        <gr-create-pointer-dialog
-          id="createNewModal"
-          detail-type="[[_computeItemName(detailType)]]"
-          has-new-item-name="{{_hasNewItemName}}"
-          item-detail="[[detailType]]"
-          repo-name="[[_repo]]"
-        ></gr-create-pointer-dialog>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
new file mode 100644
index 0000000..8955092
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
@@ -0,0 +1,222 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-table-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="shared-styles">
+    .tags td.name {
+      min-width: 25em;
+    }
+    td.name,
+    td.revision,
+    td.message {
+      word-break: break-word;
+    }
+    td.revision.tags {
+      width: 27em;
+    }
+    td.message,
+    td.tagger {
+      max-width: 15em;
+    }
+    .editing .editItem {
+      display: inherit;
+    }
+    .editItem,
+    .editing .editBtn,
+    .canEdit .revisionNoEditing,
+    .editing .revisionWithEditing,
+    .revisionEdit,
+    .hideItem {
+      display: none;
+    }
+    .revisionEdit gr-button {
+      margin-left: var(--spacing-m);
+    }
+    .editBtn {
+      margin-left: var(--spacing-l);
+    }
+    .canEdit .revisionEdit {
+      align-items: center;
+      display: flex;
+    }
+    .deleteButton:not(.show) {
+      display: none;
+    }
+    .tagger.hide {
+      display: none;
+    }
+  </style>
+  <style include="gr-table-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <gr-list-view
+    create-new="[[_loggedIn]]"
+    filter="[[_filter]]"
+    items-per-page="[[_itemsPerPage]]"
+    items="[[_items]]"
+    loading="[[_loading]]"
+    offset="[[_offset]]"
+    on-create-clicked="_handleCreateClicked"
+    path="[[_getPath(_repo, detailType)]]"
+  >
+    <table id="list" class="genericList gr-form-styles">
+      <tbody>
+        <tr class="headerRow">
+          <th class="name topHeader">Name</th>
+          <th class="revision topHeader">Revision</th>
+          <th class$="message topHeader [[_hideIfBranch(detailType)]]">
+            Message
+          </th>
+          <th class$="tagger topHeader [[_hideIfBranch(detailType)]]">
+            Tagger
+          </th>
+          <th class="repositoryBrowser topHeader">
+            Repository Browser
+          </th>
+          <th class="delete topHeader"></th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+      </tbody>
+      <tbody class$="[[computeLoadingClass(_loading)]]">
+        <template is="dom-repeat" items="[[_shownItems]]">
+          <tr class="table">
+            <td class$="[[detailType]] name">
+              [[_stripRefs(item.ref, detailType)]]
+            </td>
+            <td
+              class$="[[detailType]] revision [[_computeCanEditClass(item.ref, detailType, _isOwner)]]"
+            >
+              <span class="revisionNoEditing">
+                [[item.revision]]
+              </span>
+              <span class$="revisionEdit [[_computeEditingClass(_isEditing)]]">
+                <span class="revisionWithEditing">
+                  [[item.revision]]
+                </span>
+                <gr-button
+                  link=""
+                  on-click="_handleEditRevision"
+                  class="editBtn"
+                >
+                  edit
+                </gr-button>
+                <iron-input bind-value="{{_revisedRef}}" class="editItem">
+                  <input is="iron-input" bind-value="{{_revisedRef}}" />
+                </iron-input>
+                <gr-button
+                  link=""
+                  on-click="_handleCancelRevision"
+                  class="cancelBtn editItem"
+                >
+                  Cancel
+                </gr-button>
+                <gr-button
+                  link=""
+                  on-click="_handleSaveRevision"
+                  class="saveBtn editItem"
+                  disabled="[[!_revisedRef]]"
+                >
+                  Save
+                </gr-button>
+              </span>
+            </td>
+            <td class$="message [[_hideIfBranch(detailType)]]">
+              [[_computeMessage(item.message)]]
+            </td>
+            <td class$="tagger [[_hideIfBranch(detailType)]]">
+              <div class$="tagger [[_computeHideTagger(item.tagger)]]">
+                <gr-account-link account="[[item.tagger]]"> </gr-account-link>
+                (<gr-date-formatter
+                  has-tooltip=""
+                  date-str="[[item.tagger.date]]"
+                >
+                </gr-date-formatter
+                >)
+              </div>
+            </td>
+            <td class="repositoryBrowser">
+              <template
+                is="dom-repeat"
+                items="[[_computeWeblink(item)]]"
+                as="link"
+              >
+                <a
+                  href$="[[link.url]]"
+                  class="webLink"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  ([[link.name]])
+                </a>
+              </template>
+            </td>
+            <td class="delete">
+              <gr-button
+                link=""
+                class$="deleteButton [[_computeHideDeleteClass(_isOwner, item.can_delete)]]"
+                on-click="_handleDeleteItem"
+              >
+                Delete
+              </gr-button>
+            </td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+    <gr-overlay id="overlay" with-backdrop="">
+      <gr-confirm-delete-item-dialog
+        class="confirmDialog"
+        on-confirm="_handleDeleteItemConfirm"
+        on-cancel="_handleConfirmDialogCancel"
+        item="[[_refName]]"
+        item-type="[[detailType]]"
+      ></gr-confirm-delete-item-dialog>
+    </gr-overlay>
+  </gr-list-view>
+  <gr-overlay id="createOverlay" with-backdrop="">
+    <gr-dialog
+      id="createDialog"
+      disabled="[[!_hasNewItemName]]"
+      confirm-label="Create"
+      on-confirm="_handleCreateItem"
+      on-cancel="_handleCloseCreate"
+    >
+      <div class="header" slot="header">
+        Create [[_computeItemName(detailType)]]
+      </div>
+      <div class="main" slot="main">
+        <gr-create-pointer-dialog
+          id="createNewModal"
+          detail-type="[[_computeItemName(detailType)]]"
+          has-new-item-name="{{_hasNewItemName}}"
+          item-detail="[[detailType]]"
+          repo-name="[[_repo]]"
+        ></gr-create-pointer-dialog>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
deleted file mode 100644
index 9d7bba4..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.html
+++ /dev/null
@@ -1,576 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-repo-detail-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-repo-detail-list></gr-repo-detail-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-detail-list.js';
-import page from 'page/page.mjs';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-let counter;
-const branchGenerator = () => {
-  return {
-    ref: `refs/heads/test${++counter}`,
-    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
-    web_links: [
-      {
-        name: 'diffusion',
-        url: `https://git.example.org/branch/test;refs/heads/test${counter}`,
-      },
-    ],
-  };
-};
-const tagGenerator = () => {
-  return {
-    ref: `refs/tags/test${++counter}`,
-    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
-    web_links: [
-      {
-        name: 'diffusion',
-        url: `https://git.example.org/tag/test;refs/tags/test${counter}`,
-      },
-    ],
-    message: 'Annotated tag',
-    tagger: {
-      name: 'Test User',
-      email: 'test.user@gmail.com',
-      date: '2017-09-19 14:54:00.000000000',
-      tz: 540,
-    },
-  };
-};
-
-suite('gr-repo-detail-list', () => {
-  suite('Branches', () => {
-    let element;
-    let branches;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.detailType = 'branches';
-      counter = 0;
-      sandbox.stub(page, 'show');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    suite('list of repo branches', () => {
-      setup(done => {
-        branches = [{
-          ref: 'HEAD',
-          revision: 'master',
-        }].concat(_.times(25, branchGenerator));
-
-        stub('gr-rest-api-interface', {
-          getRepoBranches(num, project, offset) {
-            return Promise.resolve(branches);
-          },
-        });
-
-        const params = {
-          repo: 'test',
-          detail: 'branches',
-        };
-
-        element._paramsChanged(params).then(() => { flush(done); });
-      });
-
-      test('test for branch in the list', done => {
-        flush(() => {
-          assert.equal(element._items[2].ref, 'refs/heads/test2');
-          done();
-        });
-      });
-
-      test('test for web links in the branches list', done => {
-        flush(() => {
-          assert.equal(element._items[2].web_links[0].url,
-              'https://git.example.org/branch/test;refs/heads/test2');
-          done();
-        });
-      });
-
-      test('test for refs/heads/ being striped from ref', done => {
-        flush(() => {
-          assert.equal(element._stripRefs(element._items[2].ref,
-              element.detailType), 'test2');
-          done();
-        });
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-
-      test('Edit HEAD button not admin', done => {
-        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sandbox.stub(element.$.restAPI, 'getRepoAccess').returns(
-            Promise.resolve({
-              test: {is_owner: false},
-            }));
-        element._determineIfOwner('test').then(() => {
-          assert.equal(element._isOwner, false);
-          assert.equal(getComputedStyle(dom(element.root)
-              .querySelector('.revisionNoEditing')).display, 'inline');
-          assert.equal(getComputedStyle(dom(element.root)
-              .querySelector('.revisionEdit')).display, 'none');
-          done();
-        });
-      });
-
-      test('Edit HEAD button admin', done => {
-        const saveBtn = dom(element.root).querySelector('.saveBtn');
-        const cancelBtn = dom(element.root).querySelector('.cancelBtn');
-        const editBtn = dom(element.root).querySelector('.editBtn');
-        const revisionNoEditing = dom(element.root)
-            .querySelector('.revisionNoEditing');
-        const revisionWithEditing = dom(element.root)
-            .querySelector('.revisionWithEditing');
-
-        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sandbox.stub(element.$.restAPI, 'getRepoAccess').returns(
-            Promise.resolve({
-              test: {is_owner: true},
-            }));
-        sandbox.stub(element, '_handleSaveRevision');
-        element._determineIfOwner('test').then(() => {
-          assert.equal(element._isOwner, true);
-          // The revision container for non-editing enabled row is not visible.
-          assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
-
-          // The revision container for editing enabled row is visible.
-          assert.notEqual(getComputedStyle(dom(element.root)
-              .querySelector('.revisionEdit')).display, 'none');
-
-          // The revision and edit button are visible.
-          assert.notEqual(getComputedStyle(revisionWithEditing).display,
-              'none');
-          assert.notEqual(getComputedStyle(editBtn).display, 'none');
-
-          // The input, cancel, and save buttons are not visible.
-          const hiddenElements = dom(element.root)
-              .querySelectorAll('.canEdit .editItem');
-
-          for (const item of hiddenElements) {
-            assert.equal(getComputedStyle(item).display, 'none');
-          }
-
-          MockInteractions.tap(editBtn);
-          flushAsynchronousOperations();
-          // The revision and edit button are not visible.
-          assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
-          assert.equal(getComputedStyle(editBtn).display, 'none');
-
-          // The input, cancel, and save buttons are not visible.
-          for (const item of hiddenElements) {
-            assert.notEqual(getComputedStyle(item).display, 'none');
-          }
-
-          // The revised ref was set correctly
-          assert.equal(element._revisedRef, 'master');
-
-          assert.isFalse(saveBtn.disabled);
-
-          // Delete the ref.
-          element._revisedRef = '';
-          assert.isTrue(saveBtn.disabled);
-
-          // Change the ref to something else
-          element._revisedRef = 'newRef';
-          element._repo = 'test';
-          assert.isFalse(saveBtn.disabled);
-
-          // Save button calls handleSave. since this is stubbed, the edit
-          // section remains open.
-          MockInteractions.tap(saveBtn);
-          assert.isTrue(element._handleSaveRevision.called);
-
-          // When cancel is tapped, the edit secion closes.
-          MockInteractions.tap(cancelBtn);
-          flushAsynchronousOperations();
-
-          // The revision and edit button are visible.
-          assert.notEqual(getComputedStyle(revisionWithEditing).display,
-              'none');
-          assert.notEqual(getComputedStyle(editBtn).display, 'none');
-
-          // The input, cancel, and save buttons are not visible.
-          for (const item of hiddenElements) {
-            assert.equal(getComputedStyle(item).display, 'none');
-          }
-          done();
-        });
-      });
-
-      test('_handleSaveRevision with invalid rev', done => {
-        const event = {model: {set: sandbox.stub()}};
-        element._isEditing = true;
-        sandbox.stub(element.$.restAPI, 'setRepoHead').returns(
-            Promise.resolve({
-              status: 400,
-            })
-        );
-
-        element._setRepoHead('test', 'newRef', event).then(() => {
-          assert.isTrue(element._isEditing);
-          assert.isFalse(event.model.set.called);
-          done();
-        });
-      });
-
-      test('_handleSaveRevision with valid rev', done => {
-        const event = {model: {set: sandbox.stub()}};
-        element._isEditing = true;
-        sandbox.stub(element.$.restAPI, 'setRepoHead').returns(
-            Promise.resolve({
-              status: 200,
-            })
-        );
-
-        element._setRepoHead('test', 'newRef', event).then(() => {
-          assert.isFalse(element._isEditing);
-          assert.isTrue(event.model.set.called);
-          done();
-        });
-      });
-
-      test('test _computeItemName', () => {
-        assert.deepEqual(element._computeItemName('branches'), 'Branch');
-        assert.deepEqual(element._computeItemName('tags'), 'Tag');
-      });
-    });
-
-    suite('list with less then 25 branches', () => {
-      setup(done => {
-        branches = _.times(25, branchGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoBranches(num, repo, offset) {
-            return Promise.resolve(branches);
-          },
-        });
-
-        const params = {
-          repo: 'test',
-          detail: 'branches',
-        };
-
-        element._paramsChanged(params).then(() => { flush(done); });
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      test('_paramsChanged', done => {
-        sandbox.stub(
-            element.$.restAPI,
-            'getRepoBranches',
-            () => Promise.resolve(branches));
-        const params = {
-          detail: 'branches',
-          repo: 'test',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params).then(() => {
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[0],
-              'test');
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[1],
-              'test');
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[2],
-              25);
-          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[3],
-              25);
-          done();
-        });
-      });
-    });
-
-    suite('404', () => {
-      test('fires page-error', done => {
-        const response = {status: 404};
-        sandbox.stub(element.$.restAPI, 'getRepoBranches',
-            (filter, repo, reposBranchesPerPage, opt_offset, errFn) => {
-              errFn(response);
-            });
-
-        element.addEventListener('page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          done();
-        });
-
-        const params = {
-          detail: 'branches',
-          repo: 'test',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params);
-      });
-    });
-  });
-
-  suite('Tags', () => {
-    let element;
-    let tags;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-      element.detailType = 'tags';
-      counter = 0;
-      sandbox.stub(page, 'show');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_computeMessage', () => {
-      let message = 'v2.15-rc1↵-----BEGIN PGP SIGNATURE-----↵Version: GnuPG v' +
-      '1↵↵iQIcBAABAgAGBQJZ27O7AAoJEF/XxZqaEoiMy6kQAMoQCpGr3J6JITI4BVWsr7QM↵xy' +
-      'EcWH5YPUko5EPTbkABHmaVyFmKGkuIQdn6c+NIbqJOk+5XT4oUyRSo1T569HPJ↵3kyxEJi' +
-      'T1ryvp5BIHwdvHx58fjw1+YkiWLZuZq1FFkUYqnWTYCrkv7Fok98pdOmV↵CL1Hgugi5uK8' +
-      '/kxf1M7+Nv6piaZ140pwSb1h6QdAjaZVfaBCnoxlG4LRUqHvEYay↵f4QYgFT67auHIGkZ4' +
-      'moUcsp2Du/1jSsCWL/CPwjPFGbbckVAjLCMT9yD3NKwpEZF↵pfsiZyHI9dL0M+QjVrM+RD' +
-      'HwIIJwra8R0IMkDlQ6MDrFlKNqNBbo588S6UPrm71L↵YuiwWlcrK9ZIybxT6LzbR65Rvez' +
-      'DSitQ+xeIfpZE19/X6BCnvlARLE8k/tC2JksI↵lEZi7Lf3FQdIcwwyt98tJkS9HX9v9jbC' +
-      '5QXifnoj3Li8tHSLuQ1dJCxHQiis6ojI↵OWUFkm0IHBXVNHA2dqYBdM+pL12mlI3wp6Ica' +
-      '4cdEVDwzu+j1xnVSFUa+d+Y2xJF↵7mytuyhHiKG4hm+zbhMv6WD8Q3FoDsJZeLY99l0hYQ' +
-      'SnnkMduFVroIs45pAs8gUA↵RvYla8mm9w/543IJAPzzFarPVLSsSyQ7tJl3UBzjKRNH/rX' +
-      'W+F22qyWD1zyHPUIR↵C00ItmwlAvveImYKpQAH↵=L+K9↵-----END PGP SIGNATURE---' +
-      '--';
-      assert.equal(element._computeMessage(message), 'v2.15-rc1↵');
-      message = 'v2.15-rc1';
-      assert.equal(element._computeMessage(message), 'v2.15-rc1');
-    });
-
-    suite('list of repo tags', () => {
-      setup(done => {
-        tags = _.times(26, tagGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoTags(num, repo, offset) {
-            return Promise.resolve(tags);
-          },
-        });
-
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-        };
-
-        element._paramsChanged(params).then(() => { flush(done); });
-      });
-
-      test('test for tag in the list', done => {
-        flush(() => {
-          assert.equal(element._items[1].ref, 'refs/tags/test2');
-          done();
-        });
-      });
-
-      test('test for tag message in the list', done => {
-        flush(() => {
-          assert.equal(element._items[1].message, 'Annotated tag');
-          done();
-        });
-      });
-
-      test('test for tagger in the tag list', done => {
-        const tagger = {
-          name: 'Test User',
-          email: 'test.user@gmail.com',
-          date: '2017-09-19 14:54:00.000000000',
-          tz: 540,
-        };
-        flush(() => {
-          assert.deepEqual(element._items[1].tagger, tagger);
-          done();
-        });
-      });
-
-      test('test for web links in the tags list', done => {
-        flush(() => {
-          assert.equal(element._items[1].web_links[0].url,
-              'https://git.example.org/tag/test;refs/tags/test2');
-          done();
-        });
-      });
-
-      test('test for refs/tags/ being striped from ref', done => {
-        flush(() => {
-          assert.equal(element._stripRefs(element._items[1].ref,
-              element.detailType), 'test2');
-          done();
-        });
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-
-      test('_computeHideTagger', () => {
-        const testObject1 = {
-          tagger: 'test',
-        };
-        assert.equal(element._computeHideTagger(testObject1), '');
-
-        assert.equal(element._computeHideTagger(undefined), 'hide');
-      });
-    });
-
-    suite('list with less then 25 tags', () => {
-      setup(done => {
-        tags = _.times(25, tagGenerator);
-
-        stub('gr-rest-api-interface', {
-          getRepoTags(num, project, offset) {
-            return Promise.resolve(tags);
-          },
-        });
-
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-        };
-
-        element._paramsChanged(params).then(() => { flush(done); });
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      test('_paramsChanged', done => {
-        sandbox.stub(
-            element.$.restAPI,
-            'getRepoTags',
-            () => Promise.resolve(tags));
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params).then(() => {
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[0],
-              'test');
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[1],
-              'test');
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[2],
-              25);
-          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[3],
-              25);
-          done();
-        });
-      });
-    });
-
-    suite('create new', () => {
-      test('_handleCreateClicked called when create-click fired', () => {
-        sandbox.stub(element, '_handleCreateClicked');
-        element.shadowRoot
-            .querySelector('gr-list-view').dispatchEvent(
-                new CustomEvent('create-clicked', {
-                  composed: true, bubbles: true,
-                }));
-        assert.isTrue(element._handleCreateClicked.called);
-      });
-
-      test('_handleCreateClicked opens modal', () => {
-        const openStub = sandbox.stub(element.$.createOverlay, 'open');
-        element._handleCreateClicked();
-        assert.isTrue(openStub.called);
-      });
-
-      test('_handleCreateItem called when confirm fired', () => {
-        sandbox.stub(element, '_handleCreateItem');
-        element.$.createDialog.dispatchEvent(
-            new CustomEvent('confirm', {
-              composed: true, bubbles: true,
-            }));
-        assert.isTrue(element._handleCreateItem.called);
-      });
-
-      test('_handleCloseCreate called when cancel fired', () => {
-        sandbox.stub(element, '_handleCloseCreate');
-        element.$.createDialog.dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-        assert.isTrue(element._handleCloseCreate.called);
-      });
-    });
-
-    suite('404', () => {
-      test('fires page-error', done => {
-        const response = {status: 404};
-        sandbox.stub(element.$.restAPI, 'getRepoTags',
-            (filter, repo, reposTagsPerPage, opt_offset, errFn) => {
-              errFn(response);
-            });
-
-        element.addEventListener('page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          done();
-        });
-
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params);
-      });
-    });
-
-    test('test _computeHideDeleteClass', () => {
-      assert.deepEqual(element._computeHideDeleteClass(true, false), 'show');
-      assert.deepEqual(element._computeHideDeleteClass(false, true), 'show');
-      assert.deepEqual(element._computeHideDeleteClass(false, false), '');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
new file mode 100644
index 0000000..7190218
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
@@ -0,0 +1,549 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-repo-detail-list.js';
+import page from 'page/page.mjs';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-repo-detail-list');
+
+let counter;
+const branchGenerator = () => {
+  return {
+    ref: `refs/heads/test${++counter}`,
+    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://git.example.org/branch/test;refs/heads/test${counter}`,
+      },
+    ],
+  };
+};
+const tagGenerator = () => {
+  return {
+    ref: `refs/tags/test${++counter}`,
+    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://git.example.org/tag/test;refs/tags/test${counter}`,
+      },
+    ],
+    message: 'Annotated tag',
+    tagger: {
+      name: 'Test User',
+      email: 'test.user@gmail.com',
+      date: '2017-09-19 14:54:00.000000000',
+      tz: 540,
+    },
+  };
+};
+
+suite('gr-repo-detail-list', () => {
+  suite('Branches', () => {
+    let element;
+    let branches;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.detailType = 'branches';
+      counter = 0;
+      sinon.stub(page, 'show');
+    });
+
+    suite('list of repo branches', () => {
+      setup(done => {
+        branches = [{
+          ref: 'HEAD',
+          revision: 'master',
+        }].concat(_.times(25, branchGenerator));
+
+        stub('gr-rest-api-interface', {
+          getRepoBranches(num, project, offset) {
+            return Promise.resolve(branches);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'branches',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('test for branch in the list', done => {
+        flush(() => {
+          assert.equal(element._items[2].ref, 'refs/heads/test2');
+          done();
+        });
+      });
+
+      test('test for web links in the branches list', done => {
+        flush(() => {
+          assert.equal(element._items[2].web_links[0].url,
+              'https://git.example.org/branch/test;refs/heads/test2');
+          done();
+        });
+      });
+
+      test('test for refs/heads/ being striped from ref', done => {
+        flush(() => {
+          assert.equal(element._stripRefs(element._items[2].ref,
+              element.detailType), 'test2');
+          done();
+        });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+
+      test('Edit HEAD button not admin', done => {
+        sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+        sinon.stub(element.$.restAPI, 'getRepoAccess').returns(
+            Promise.resolve({
+              test: {is_owner: false},
+            }));
+        element._determineIfOwner('test').then(() => {
+          assert.equal(element._isOwner, false);
+          assert.equal(getComputedStyle(dom(element.root)
+              .querySelector('.revisionNoEditing')).display, 'inline');
+          assert.equal(getComputedStyle(dom(element.root)
+              .querySelector('.revisionEdit')).display, 'none');
+          done();
+        });
+      });
+
+      test('Edit HEAD button admin', done => {
+        const saveBtn = dom(element.root).querySelector('.saveBtn');
+        const cancelBtn = dom(element.root).querySelector('.cancelBtn');
+        const editBtn = dom(element.root).querySelector('.editBtn');
+        const revisionNoEditing = dom(element.root)
+            .querySelector('.revisionNoEditing');
+        const revisionWithEditing = dom(element.root)
+            .querySelector('.revisionWithEditing');
+
+        sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+        sinon.stub(element.$.restAPI, 'getRepoAccess').returns(
+            Promise.resolve({
+              test: {is_owner: true},
+            }));
+        sinon.stub(element, '_handleSaveRevision');
+        element._determineIfOwner('test').then(() => {
+          assert.equal(element._isOwner, true);
+          // The revision container for non-editing enabled row is not visible.
+          assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
+
+          // The revision container for editing enabled row is visible.
+          assert.notEqual(getComputedStyle(dom(element.root)
+              .querySelector('.revisionEdit')).display, 'none');
+
+          // The revision and edit button are visible.
+          assert.notEqual(getComputedStyle(revisionWithEditing).display,
+              'none');
+          assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          const hiddenElements = dom(element.root)
+              .querySelectorAll('.canEdit .editItem');
+
+          for (const item of hiddenElements) {
+            assert.equal(getComputedStyle(item).display, 'none');
+          }
+
+          MockInteractions.tap(editBtn);
+          flushAsynchronousOperations();
+          // The revision and edit button are not visible.
+          assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
+          assert.equal(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          for (const item of hiddenElements) {
+            assert.notEqual(getComputedStyle(item).display, 'none');
+          }
+
+          // The revised ref was set correctly
+          assert.equal(element._revisedRef, 'master');
+
+          assert.isFalse(saveBtn.disabled);
+
+          // Delete the ref.
+          element._revisedRef = '';
+          assert.isTrue(saveBtn.disabled);
+
+          // Change the ref to something else
+          element._revisedRef = 'newRef';
+          element._repo = 'test';
+          assert.isFalse(saveBtn.disabled);
+
+          // Save button calls handleSave. since this is stubbed, the edit
+          // section remains open.
+          MockInteractions.tap(saveBtn);
+          assert.isTrue(element._handleSaveRevision.called);
+
+          // When cancel is tapped, the edit secion closes.
+          MockInteractions.tap(cancelBtn);
+          flushAsynchronousOperations();
+
+          // The revision and edit button are visible.
+          assert.notEqual(getComputedStyle(revisionWithEditing).display,
+              'none');
+          assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          for (const item of hiddenElements) {
+            assert.equal(getComputedStyle(item).display, 'none');
+          }
+          done();
+        });
+      });
+
+      test('_handleSaveRevision with invalid rev', done => {
+        const event = {model: {set: sinon.stub()}};
+        element._isEditing = true;
+        sinon.stub(element.$.restAPI, 'setRepoHead').returns(
+            Promise.resolve({
+              status: 400,
+            })
+        );
+
+        element._setRepoHead('test', 'newRef', event).then(() => {
+          assert.isTrue(element._isEditing);
+          assert.isFalse(event.model.set.called);
+          done();
+        });
+      });
+
+      test('_handleSaveRevision with valid rev', done => {
+        const event = {model: {set: sinon.stub()}};
+        element._isEditing = true;
+        sinon.stub(element.$.restAPI, 'setRepoHead').returns(
+            Promise.resolve({
+              status: 200,
+            })
+        );
+
+        element._setRepoHead('test', 'newRef', event).then(() => {
+          assert.isFalse(element._isEditing);
+          assert.isTrue(event.model.set.called);
+          done();
+        });
+      });
+
+      test('test _computeItemName', () => {
+        assert.deepEqual(element._computeItemName('branches'), 'Branch');
+        assert.deepEqual(element._computeItemName('tags'), 'Tag');
+      });
+    });
+
+    suite('list with less then 25 branches', () => {
+      setup(done => {
+        branches = _.times(25, branchGenerator);
+
+        stub('gr-rest-api-interface', {
+          getRepoBranches(num, repo, offset) {
+            return Promise.resolve(branches);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'branches',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sinon.stub(
+            element.$.restAPI,
+            'getRepoBranches')
+            .callsFake(() => Promise.resolve(branches));
+        const params = {
+          detail: 'branches',
+          repo: 'test',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params).then(() => {
+          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[0],
+              'test');
+          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[1],
+              'test');
+          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[2],
+              25);
+          assert.equal(element.$.restAPI.getRepoBranches.lastCall.args[3],
+              25);
+          done();
+        });
+      });
+    });
+
+    suite('404', () => {
+      test('fires page-error', done => {
+        const response = {status: 404};
+        sinon.stub(element.$.restAPI, 'getRepoBranches').callsFake(
+            (filter, repo, reposBranchesPerPage, opt_offset, errFn) => {
+              errFn(response);
+            });
+
+        element.addEventListener('page-error', e => {
+          assert.deepEqual(e.detail.response, response);
+          done();
+        });
+
+        const params = {
+          detail: 'branches',
+          repo: 'test',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params);
+      });
+    });
+  });
+
+  suite('Tags', () => {
+    let element;
+    let tags;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.detailType = 'tags';
+      counter = 0;
+      sinon.stub(page, 'show');
+    });
+
+    test('_computeMessage', () => {
+      let message = 'v2.15-rc1↵-----BEGIN PGP SIGNATURE-----↵Version: GnuPG v' +
+      '1↵↵iQIcBAABAgAGBQJZ27O7AAoJEF/XxZqaEoiMy6kQAMoQCpGr3J6JITI4BVWsr7QM↵xy' +
+      'EcWH5YPUko5EPTbkABHmaVyFmKGkuIQdn6c+NIbqJOk+5XT4oUyRSo1T569HPJ↵3kyxEJi' +
+      'T1ryvp5BIHwdvHx58fjw1+YkiWLZuZq1FFkUYqnWTYCrkv7Fok98pdOmV↵CL1Hgugi5uK8' +
+      '/kxf1M7+Nv6piaZ140pwSb1h6QdAjaZVfaBCnoxlG4LRUqHvEYay↵f4QYgFT67auHIGkZ4' +
+      'moUcsp2Du/1jSsCWL/CPwjPFGbbckVAjLCMT9yD3NKwpEZF↵pfsiZyHI9dL0M+QjVrM+RD' +
+      'HwIIJwra8R0IMkDlQ6MDrFlKNqNBbo588S6UPrm71L↵YuiwWlcrK9ZIybxT6LzbR65Rvez' +
+      'DSitQ+xeIfpZE19/X6BCnvlARLE8k/tC2JksI↵lEZi7Lf3FQdIcwwyt98tJkS9HX9v9jbC' +
+      '5QXifnoj3Li8tHSLuQ1dJCxHQiis6ojI↵OWUFkm0IHBXVNHA2dqYBdM+pL12mlI3wp6Ica' +
+      '4cdEVDwzu+j1xnVSFUa+d+Y2xJF↵7mytuyhHiKG4hm+zbhMv6WD8Q3FoDsJZeLY99l0hYQ' +
+      'SnnkMduFVroIs45pAs8gUA↵RvYla8mm9w/543IJAPzzFarPVLSsSyQ7tJl3UBzjKRNH/rX' +
+      'W+F22qyWD1zyHPUIR↵C00ItmwlAvveImYKpQAH↵=L+K9↵-----END PGP SIGNATURE---' +
+      '--';
+      assert.equal(element._computeMessage(message), 'v2.15-rc1↵');
+      message = 'v2.15-rc1';
+      assert.equal(element._computeMessage(message), 'v2.15-rc1');
+    });
+
+    suite('list of repo tags', () => {
+      setup(done => {
+        tags = _.times(26, tagGenerator);
+
+        stub('gr-rest-api-interface', {
+          getRepoTags(num, repo, offset) {
+            return Promise.resolve(tags);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('test for tag in the list', done => {
+        flush(() => {
+          assert.equal(element._items[1].ref, 'refs/tags/test2');
+          done();
+        });
+      });
+
+      test('test for tag message in the list', done => {
+        flush(() => {
+          assert.equal(element._items[1].message, 'Annotated tag');
+          done();
+        });
+      });
+
+      test('test for tagger in the tag list', done => {
+        const tagger = {
+          name: 'Test User',
+          email: 'test.user@gmail.com',
+          date: '2017-09-19 14:54:00.000000000',
+          tz: 540,
+        };
+        flush(() => {
+          assert.deepEqual(element._items[1].tagger, tagger);
+          done();
+        });
+      });
+
+      test('test for web links in the tags list', done => {
+        flush(() => {
+          assert.equal(element._items[1].web_links[0].url,
+              'https://git.example.org/tag/test;refs/tags/test2');
+          done();
+        });
+      });
+
+      test('test for refs/tags/ being striped from ref', done => {
+        flush(() => {
+          assert.equal(element._stripRefs(element._items[1].ref,
+              element.detailType), 'test2');
+          done();
+        });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+
+      test('_computeHideTagger', () => {
+        const testObject1 = {
+          tagger: 'test',
+        };
+        assert.equal(element._computeHideTagger(testObject1), '');
+
+        assert.equal(element._computeHideTagger(undefined), 'hide');
+      });
+    });
+
+    suite('list with less then 25 tags', () => {
+      setup(done => {
+        tags = _.times(25, tagGenerator);
+
+        stub('gr-rest-api-interface', {
+          getRepoTags(num, project, offset) {
+            return Promise.resolve(tags);
+          },
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sinon.stub(
+            element.$.restAPI,
+            'getRepoTags')
+            .callsFake(() => Promise.resolve(tags));
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params).then(() => {
+          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[0],
+              'test');
+          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[1],
+              'test');
+          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[2],
+              25);
+          assert.equal(element.$.restAPI.getRepoTags.lastCall.args[3],
+              25);
+          done();
+        });
+      });
+    });
+
+    suite('create new', () => {
+      test('_handleCreateClicked called when create-click fired', () => {
+        sinon.stub(element, '_handleCreateClicked');
+        element.shadowRoot
+            .querySelector('gr-list-view').dispatchEvent(
+                new CustomEvent('create-clicked', {
+                  composed: true, bubbles: true,
+                }));
+        assert.isTrue(element._handleCreateClicked.called);
+      });
+
+      test('_handleCreateClicked opens modal', () => {
+        const openStub = sinon.stub(element.$.createOverlay, 'open');
+        element._handleCreateClicked();
+        assert.isTrue(openStub.called);
+      });
+
+      test('_handleCreateItem called when confirm fired', () => {
+        sinon.stub(element, '_handleCreateItem');
+        element.$.createDialog.dispatchEvent(
+            new CustomEvent('confirm', {
+              composed: true, bubbles: true,
+            }));
+        assert.isTrue(element._handleCreateItem.called);
+      });
+
+      test('_handleCloseCreate called when cancel fired', () => {
+        sinon.stub(element, '_handleCloseCreate');
+        element.$.createDialog.dispatchEvent(
+            new CustomEvent('cancel', {
+              composed: true, bubbles: true,
+            }));
+        assert.isTrue(element._handleCloseCreate.called);
+      });
+    });
+
+    suite('404', () => {
+      test('fires page-error', done => {
+        const response = {status: 404};
+        sinon.stub(element.$.restAPI, 'getRepoTags').callsFake(
+            (filter, repo, reposTagsPerPage, opt_offset, errFn) => {
+              errFn(response);
+            });
+
+        element.addEventListener('page-error', e => {
+          assert.deepEqual(e.detail.response, response);
+          done();
+        });
+
+        const params = {
+          repo: 'test',
+          detail: 'tags',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params);
+      });
+    });
+
+    test('test _computeHideDeleteClass', () => {
+      assert.deepEqual(element._computeHideDeleteClass(true, false), 'show');
+      assert.deepEqual(element._computeHideDeleteClass(false, true), 'show');
+      assert.deepEqual(element._computeHideDeleteClass(false, false), '');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
index 59abb72..249a75d 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/gr-table-styles.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-dialog/gr-dialog.js';
@@ -23,21 +21,18 @@
 import '../../shared/gr-overlay/gr-overlay.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-create-repo-dialog/gr-create-repo-dialog.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-repo-list_html.js';
-import {ListViewBehavior} from '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
 /**
  * @appliesMixin ListViewMixin
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrRepoList extends mixinBehaviors( [
-  ListViewBehavior,
-], GestureEventListeners(
+class GrRepoList extends ListViewMixin(GestureEventListeners(
     LegacyElementMixin(
         PolymerElement))) {
   static get template() { return htmlTemplate; }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.js
deleted file mode 100644
index 3681399..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.js
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style>
-    .genericList tr td:last-of-type {
-      text-align: left;
-    }
-    .genericList tr th:last-of-type {
-      text-align: left;
-    }
-    .readOnly {
-      text-align: center;
-    }
-    .changesLink,
-    .name,
-    .repositoryBrowser,
-    .readOnly {
-      white-space: nowrap;
-    }
-  </style>
-  <gr-list-view
-    create-new="[[_createNewCapability]]"
-    filter="[[_filter]]"
-    items-per-page="[[_reposPerPage]]"
-    items="[[_repos]]"
-    loading="[[_loading]]"
-    offset="[[_offset]]"
-    on-create-clicked="_handleCreateClicked"
-    path="[[_path]]"
-  >
-    <table id="list" class="genericList">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Repository Name</th>
-          <th class="repositoryBrowser topHeader">Repository Browser</th>
-          <th class="changesLink topHeader">Changes</th>
-          <th class="topHeader readOnly">Read only</th>
-          <th class="description topHeader">Repository Description</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_shownRepos]]">
-          <tr class="table">
-            <td class="name">
-              <a href$="[[_computeRepoUrl(item.name)]]">[[item.name]]</a>
-            </td>
-            <td class="repositoryBrowser">
-              <template
-                is="dom-repeat"
-                items="[[_computeWeblink(item)]]"
-                as="link"
-              >
-                <a
-                  href$="[[link.url]]"
-                  class="webLink"
-                  rel="noopener"
-                  target="_blank"
-                >
-                  [[link.name]]
-                </a>
-              </template>
-            </td>
-            <td class="changesLink">
-              <a href$="[[_computeChangesLink(item.name)]]">view all</a>
-            </td>
-            <td class="readOnly">[[_readOnly(item)]]</td>
-            <td class="description">[[item.description]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </gr-list-view>
-  <gr-overlay id="createOverlay" with-backdrop="">
-    <gr-dialog
-      id="createDialog"
-      class="confirmDialog"
-      disabled="[[!_hasNewRepoName]]"
-      confirm-label="Create"
-      on-confirm="_handleCreateRepo"
-      on-cancel="_handleCloseCreate"
-    >
-      <div class="header" slot="header">
-        Create Repository
-      </div>
-      <div class="main" slot="main">
-        <gr-create-repo-dialog
-          has-new-repo-name="{{_hasNewRepoName}}"
-          params="[[params]]"
-          id="createNewModal"
-        ></gr-create-repo-dialog>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
new file mode 100644
index 0000000..f61adce
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
@@ -0,0 +1,120 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-table-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style>
+    .genericList tr td:last-of-type {
+      text-align: left;
+    }
+    .genericList tr th:last-of-type {
+      text-align: left;
+    }
+    .readOnly {
+      text-align: center;
+    }
+    .changesLink,
+    .name,
+    .repositoryBrowser,
+    .readOnly {
+      white-space: nowrap;
+    }
+  </style>
+  <gr-list-view
+    create-new="[[_createNewCapability]]"
+    filter="[[_filter]]"
+    items-per-page="[[_reposPerPage]]"
+    items="[[_repos]]"
+    loading="[[_loading]]"
+    offset="[[_offset]]"
+    on-create-clicked="_handleCreateClicked"
+    path="[[_path]]"
+  >
+    <table id="list" class="genericList">
+      <tbody>
+        <tr class="headerRow">
+          <th class="name topHeader">Repository Name</th>
+          <th class="repositoryBrowser topHeader">Repository Browser</th>
+          <th class="changesLink topHeader">Changes</th>
+          <th class="topHeader readOnly">Read only</th>
+          <th class="description topHeader">Repository Description</th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+      </tbody>
+      <tbody class$="[[computeLoadingClass(_loading)]]">
+        <template is="dom-repeat" items="[[_shownRepos]]">
+          <tr class="table">
+            <td class="name">
+              <a href$="[[_computeRepoUrl(item.name)]]">[[item.name]]</a>
+            </td>
+            <td class="repositoryBrowser">
+              <template
+                is="dom-repeat"
+                items="[[_computeWeblink(item)]]"
+                as="link"
+              >
+                <a
+                  href$="[[link.url]]"
+                  class="webLink"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  [[link.name]]
+                </a>
+              </template>
+            </td>
+            <td class="changesLink">
+              <a href$="[[_computeChangesLink(item.name)]]">view all</a>
+            </td>
+            <td class="readOnly">[[_readOnly(item)]]</td>
+            <td class="description">[[item.description]]</td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </gr-list-view>
+  <gr-overlay id="createOverlay" with-backdrop="">
+    <gr-dialog
+      id="createDialog"
+      class="confirmDialog"
+      disabled="[[!_hasNewRepoName]]"
+      confirm-label="Create"
+      on-confirm="_handleCreateRepo"
+      on-cancel="_handleCloseCreate"
+    >
+      <div class="header" slot="header">
+        Create Repository
+      </div>
+      <div class="main" slot="main">
+        <gr-create-repo-dialog
+          has-new-repo-name="{{_hasNewRepoName}}"
+          params="[[params]]"
+          id="createNewModal"
+        ></gr-create-repo-dialog>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
deleted file mode 100644
index 96cb9ff..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.html
+++ /dev/null
@@ -1,209 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-repo-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-repo-list></gr-repo-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-list.js';
-import page from 'page/page.mjs';
-
-let counter;
-const repoGenerator = () => {
-  return {
-    id: `test${++counter}`,
-    state: 'ACTIVE',
-    web_links: [
-      {
-        name: 'diffusion',
-        url: `https://phabricator.example.org/r/project/test${counter}`,
-      },
-    ],
-  };
-};
-
-suite('gr-repo-list tests', () => {
-  let element;
-  let repos;
-  let sandbox;
-  let value;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    sandbox.stub(page, 'show');
-    element = fixture('basic');
-    counter = 0;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('list with repos', () => {
-    setup(done => {
-      repos = _.times(26, repoGenerator);
-      stub('gr-rest-api-interface', {
-        getRepos(num, offset) {
-          return Promise.resolve(repos);
-        },
-      });
-      element._paramsChanged(value).then(() => { flush(done); });
-    });
-
-    test('test for test repo in the list', done => {
-      flush(() => {
-        assert.equal(element._repos[1].id, 'test2');
-        done();
-      });
-    });
-
-    test('_shownRepos', () => {
-      assert.equal(element._shownRepos.length, 25);
-    });
-
-    test('_maybeOpenCreateOverlay', () => {
-      const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
-      element._maybeOpenCreateOverlay();
-      assert.isFalse(overlayOpen.called);
-      const params = {};
-      element._maybeOpenCreateOverlay(params);
-      assert.isFalse(overlayOpen.called);
-      params.openCreateModal = true;
-      element._maybeOpenCreateOverlay(params);
-      assert.isTrue(overlayOpen.called);
-    });
-  });
-
-  suite('list with less then 25 repos', () => {
-    setup(done => {
-      repos = _.times(25, repoGenerator);
-
-      stub('gr-rest-api-interface', {
-        getRepos(num, offset) {
-          return Promise.resolve(repos);
-        },
-      });
-
-      element._paramsChanged(value).then(() => { flush(done); });
-    });
-
-    test('_shownRepos', () => {
-      assert.equal(element._shownRepos.length, 25);
-    });
-  });
-
-  suite('filter', () => {
-    let reposFiltered;
-    setup(() => {
-      repos = _.times(25, repoGenerator);
-      reposFiltered = _.times(1, repoGenerator);
-    });
-
-    test('_paramsChanged', done => {
-      sandbox.stub(element.$.restAPI, 'getRepos', () => Promise.resolve(repos));
-      const value = {
-        filter: 'test',
-        offset: 25,
-      };
-      element._paramsChanged(value).then(() => {
-        assert.isTrue(element.$.restAPI.getRepos.lastCall
-            .calledWithExactly('test', 25, 25));
-        done();
-      });
-    });
-
-    test('latest repos requested are always set', done => {
-      const repoStub = sandbox.stub(element.$.restAPI, 'getRepos');
-      repoStub.withArgs('test').returns(Promise.resolve(repos));
-      repoStub.withArgs('filter').returns(Promise.resolve(reposFiltered));
-      element._filter = 'test';
-
-      // Repos are not set because the element._filter differs.
-      element._getRepos('filter', 25, 0).then(() => {
-        assert.deepEqual(element._repos, []);
-        done();
-      });
-    });
-  });
-
-  suite('loading', () => {
-    test('correct contents are displayed', () => {
-      assert.isTrue(element._loading);
-      assert.equal(element.computeLoadingClass(element._loading), 'loading');
-      assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-      element._loading = false;
-      element._repos = _.times(25, repoGenerator);
-
-      flushAsynchronousOperations();
-      assert.equal(element.computeLoadingClass(element._loading), '');
-      assert.equal(getComputedStyle(element.$.loading).display, 'none');
-    });
-  });
-
-  suite('create new', () => {
-    test('_handleCreateClicked called when create-click fired', () => {
-      sandbox.stub(element, '_handleCreateClicked');
-      element.shadowRoot
-          .querySelector('gr-list-view').dispatchEvent(
-              new CustomEvent('create-clicked', {
-                composed: true, bubbles: true,
-              }));
-      assert.isTrue(element._handleCreateClicked.called);
-    });
-
-    test('_handleCreateClicked opens modal', () => {
-      const openStub = sandbox.stub(element.$.createOverlay, 'open');
-      element._handleCreateClicked();
-      assert.isTrue(openStub.called);
-    });
-
-    test('_handleCreateRepo called when confirm fired', () => {
-      sandbox.stub(element, '_handleCreateRepo');
-      element.$.createDialog.dispatchEvent(
-          new CustomEvent('confirm', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCreateRepo.called);
-    });
-
-    test('_handleCloseCreate called when cancel fired', () => {
-      sandbox.stub(element, '_handleCloseCreate');
-      element.$.createDialog.dispatchEvent(
-          new CustomEvent('cancel', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCloseCreate.called);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
new file mode 100644
index 0000000..b629cf4
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
@@ -0,0 +1,189 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-repo-list.js';
+import page from 'page/page.mjs';
+
+const basicFixture = fixtureFromElement('gr-repo-list');
+
+let counter;
+const repoGenerator = () => {
+  return {
+    id: `test${++counter}`,
+    state: 'ACTIVE',
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://phabricator.example.org/r/project/test${counter}`,
+      },
+    ],
+  };
+};
+
+suite('gr-repo-list tests', () => {
+  let element;
+  let repos;
+
+  let value;
+
+  setup(() => {
+    sinon.stub(page, 'show');
+    element = basicFixture.instantiate();
+    counter = 0;
+  });
+
+  suite('list with repos', () => {
+    setup(done => {
+      repos = _.times(26, repoGenerator);
+      stub('gr-rest-api-interface', {
+        getRepos(num, offset) {
+          return Promise.resolve(repos);
+        },
+      });
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
+
+    test('test for test repo in the list', done => {
+      flush(() => {
+        assert.equal(element._repos[1].id, 'test2');
+        done();
+      });
+    });
+
+    test('_shownRepos', () => {
+      assert.equal(element._shownRepos.length, 25);
+    });
+
+    test('_maybeOpenCreateOverlay', () => {
+      const overlayOpen = sinon.stub(element.$.createOverlay, 'open');
+      element._maybeOpenCreateOverlay();
+      assert.isFalse(overlayOpen.called);
+      const params = {};
+      element._maybeOpenCreateOverlay(params);
+      assert.isFalse(overlayOpen.called);
+      params.openCreateModal = true;
+      element._maybeOpenCreateOverlay(params);
+      assert.isTrue(overlayOpen.called);
+    });
+  });
+
+  suite('list with less then 25 repos', () => {
+    setup(done => {
+      repos = _.times(25, repoGenerator);
+
+      stub('gr-rest-api-interface', {
+        getRepos(num, offset) {
+          return Promise.resolve(repos);
+        },
+      });
+
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
+
+    test('_shownRepos', () => {
+      assert.equal(element._shownRepos.length, 25);
+    });
+  });
+
+  suite('filter', () => {
+    let reposFiltered;
+    setup(() => {
+      repos = _.times(25, repoGenerator);
+      reposFiltered = _.times(1, repoGenerator);
+    });
+
+    test('_paramsChanged', done => {
+      sinon.stub(element.$.restAPI, 'getRepos')
+          .callsFake( () => Promise.resolve(repos));
+      const value = {
+        filter: 'test',
+        offset: 25,
+      };
+      element._paramsChanged(value).then(() => {
+        assert.isTrue(element.$.restAPI.getRepos.lastCall
+            .calledWithExactly('test', 25, 25));
+        done();
+      });
+    });
+
+    test('latest repos requested are always set', done => {
+      const repoStub = sinon.stub(element.$.restAPI, 'getRepos');
+      repoStub.withArgs('test').returns(Promise.resolve(repos));
+      repoStub.withArgs('filter').returns(Promise.resolve(reposFiltered));
+      element._filter = 'test';
+
+      // Repos are not set because the element._filter differs.
+      element._getRepos('filter', 25, 0).then(() => {
+        assert.deepEqual(element._repos, []);
+        done();
+      });
+    });
+  });
+
+  suite('loading', () => {
+    test('correct contents are displayed', () => {
+      assert.isTrue(element._loading);
+      assert.equal(element.computeLoadingClass(element._loading), 'loading');
+      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+      element._loading = false;
+      element._repos = _.times(25, repoGenerator);
+
+      flushAsynchronousOperations();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+
+  suite('create new', () => {
+    test('_handleCreateClicked called when create-click fired', () => {
+      sinon.stub(element, '_handleCreateClicked');
+      element.shadowRoot
+          .querySelector('gr-list-view').dispatchEvent(
+              new CustomEvent('create-clicked', {
+                composed: true, bubbles: true,
+              }));
+      assert.isTrue(element._handleCreateClicked.called);
+    });
+
+    test('_handleCreateClicked opens modal', () => {
+      const openStub = sinon.stub(element.$.createOverlay, 'open');
+      element._handleCreateClicked();
+      assert.isTrue(openStub.called);
+    });
+
+    test('_handleCreateRepo called when confirm fired', () => {
+      sinon.stub(element, '_handleCreateRepo');
+      element.$.createDialog.dispatchEvent(
+          new CustomEvent('confirm', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCreateRepo.called);
+    });
+
+    test('_handleCloseCreate called when cancel fired', () => {
+      sinon.stub(element, '_handleCloseCreate');
+      element.$.createDialog.dispatchEvent(
+          new CustomEvent('cancel', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleCloseCreate.called);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
index 8a01f93..4933f41 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '@polymer/paper-toggle-button/paper-toggle-button.js';
 import '../../../styles/gr-form-styles.js';
@@ -26,21 +24,30 @@
 import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
 import '../gr-plugin-config-array-editor/gr-plugin-config-array-editor.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-repo-plugin-config_html.js';
-import {RepoPluginConfig} from '../../../behaviors/gr-repo-plugin-config-behavior/gr-repo-plugin-config-behavior.js';
+
+// Should be kept in sync with
+// gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
+const CONFIG_ENTRY_TYPE = {
+  ARRAY: 'ARRAY',
+  BOOLEAN: 'BOOLEAN',
+  INT: 'INT',
+  LIST: 'LIST',
+  LONG: 'LONG',
+  STRING: 'STRING',
+};
+
+const PLUGIN_CONFIG_CHANGED_EVENT_NAME = 'plugin-config-changed';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrRepoPluginConfig extends mixinBehaviors( [
-  RepoPluginConfig,
-], GestureEventListeners(
+class GrRepoPluginConfig extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-repo-plugin-config'; }
@@ -72,22 +79,22 @@
   }
 
   _isArray(type) {
-    return type === this.ENTRY_TYPES.ARRAY;
+    return type === CONFIG_ENTRY_TYPE.ARRAY;
   }
 
   _isBoolean(type) {
-    return type === this.ENTRY_TYPES.BOOLEAN;
+    return type === CONFIG_ENTRY_TYPE.BOOLEAN;
   }
 
   _isList(type) {
-    return type === this.ENTRY_TYPES.LIST;
+    return type === CONFIG_ENTRY_TYPE.LIST;
   }
 
   _isString(type) {
     // Treat numbers like strings for simplicity.
-    return type === this.ENTRY_TYPES.STRING ||
-        type === this.ENTRY_TYPES.INT ||
-        type === this.ENTRY_TYPES.LONG;
+    return type === CONFIG_ENTRY_TYPE.STRING ||
+        type === CONFIG_ENTRY_TYPE.INT ||
+        type === CONFIG_ENTRY_TYPE.LONG;
   }
 
   _computeDisabled(editable) {
@@ -149,8 +156,8 @@
       notifyPath: `${name}.${notifyPath}`,
     };
 
-    this.dispatchEvent(new CustomEvent(
-        this.PLUGIN_CONFIG_CHANGED, {detail, bubbles: true, composed: true}));
+    this.dispatchEvent(new CustomEvent(PLUGIN_CONFIG_CHANGED_EVENT_NAME,
+        {detail, bubbles: true, composed: true}));
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js
deleted file mode 100644
index ee633463..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.js
+++ /dev/null
@@ -1,114 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    .inherited {
-      color: var(--deemphasized-text-color);
-      margin-left: var(--spacing-m);
-    }
-    section.section:not(.ARRAY) .title {
-      align-items: center;
-      display: flex;
-    }
-    section.section.ARRAY .title {
-      padding-top: var(--spacing-m);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset>
-      <h4>[[pluginData.name]]</h4>
-      <template is="dom-repeat" items="[[_pluginConfigOptions]]" as="option">
-        <section class$="section [[option.info.type]]">
-          <span class="title">
-            <gr-tooltip-content
-              has-tooltip="[[option.info.description]]"
-              show-icon="[[option.info.description]]"
-              title="[[option.info.description]]"
-            >
-              <span>[[option.info.display_name]]</span>
-            </gr-tooltip-content>
-          </span>
-          <span class="value">
-            <template is="dom-if" if="[[_isArray(option.info.type)]]">
-              <gr-plugin-config-array-editor
-                on-plugin-config-option-changed="_handleArrayChange"
-                plugin-option="[[option]]"
-              ></gr-plugin-config-array-editor>
-            </template>
-            <template is="dom-if" if="[[_isBoolean(option.info.type)]]">
-              <paper-toggle-button
-                checked="[[_computeChecked(option.info.value)]]"
-                on-change="_handleBooleanChange"
-                data-option-key$="[[option._key]]"
-                disabled$="[[_computeDisabled(option.info.editable)]]"
-                on-tap="_onTapPluginBoolean"
-              ></paper-toggle-button>
-            </template>
-            <template is="dom-if" if="[[_isList(option.info.type)]]">
-              <gr-select
-                bind-value$="[[option.info.value]]"
-                on-change="_handleListChange"
-              >
-                <select
-                  data-option-key$="[[option._key]]"
-                  disabled$="[[_computeDisabled(option.info.editable)]]"
-                >
-                  <template
-                    is="dom-repeat"
-                    items="[[option.info.permitted_values]]"
-                    as="value"
-                  >
-                    <option value$="[[value]]">[[value]]</option>
-                  </template>
-                </select>
-              </gr-select>
-            </template>
-            <template is="dom-if" if="[[_isString(option.info.type)]]">
-              <iron-input
-                bind-value="[[option.info.value]]"
-                on-input="_handleStringChange"
-                data-option-key$="[[option._key]]"
-                disabled$="[[_computeDisabled(option.info.editable)]]"
-              >
-                <input
-                  is="iron-input"
-                  value="[[option.info.value]]"
-                  on-input="_handleStringChange"
-                  data-option-key$="[[option._key]]"
-                  disabled$="[[_computeDisabled(option.info.editable)]]"
-                />
-              </iron-input>
-            </template>
-            <template is="dom-if" if="[[option.info.inherited_value]]">
-              <span class="inherited">
-                (Inherited: [[option.info.inherited_value]])
-              </span>
-            </template>
-          </span>
-        </section>
-      </template>
-    </fieldset>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts
new file mode 100644
index 0000000..3045108
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts
@@ -0,0 +1,114 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-subpage-styles">
+    .inherited {
+      color: var(--deemphasized-text-color);
+      margin-left: var(--spacing-m);
+    }
+    section.section:not(.ARRAY) .title {
+      align-items: center;
+      display: flex;
+    }
+    section.section.ARRAY .title {
+      padding-top: var(--spacing-m);
+    }
+  </style>
+  <div class="gr-form-styles">
+    <fieldset>
+      <h4>[[pluginData.name]]</h4>
+      <template is="dom-repeat" items="[[_pluginConfigOptions]]" as="option">
+        <section class$="section [[option.info.type]]">
+          <span class="title">
+            <gr-tooltip-content
+              has-tooltip="[[option.info.description]]"
+              show-icon="[[option.info.description]]"
+              title="[[option.info.description]]"
+            >
+              <span>[[option.info.display_name]]</span>
+            </gr-tooltip-content>
+          </span>
+          <span class="value">
+            <template is="dom-if" if="[[_isArray(option.info.type)]]">
+              <gr-plugin-config-array-editor
+                on-plugin-config-option-changed="_handleArrayChange"
+                plugin-option="[[option]]"
+              ></gr-plugin-config-array-editor>
+            </template>
+            <template is="dom-if" if="[[_isBoolean(option.info.type)]]">
+              <paper-toggle-button
+                checked="[[_computeChecked(option.info.value)]]"
+                on-change="_handleBooleanChange"
+                data-option-key$="[[option._key]]"
+                disabled$="[[_computeDisabled(option.info.editable)]]"
+                on-tap="_onTapPluginBoolean"
+              ></paper-toggle-button>
+            </template>
+            <template is="dom-if" if="[[_isList(option.info.type)]]">
+              <gr-select
+                bind-value$="[[option.info.value]]"
+                on-change="_handleListChange"
+              >
+                <select
+                  data-option-key$="[[option._key]]"
+                  disabled$="[[_computeDisabled(option.info.editable)]]"
+                >
+                  <template
+                    is="dom-repeat"
+                    items="[[option.info.permitted_values]]"
+                    as="value"
+                  >
+                    <option value$="[[value]]">[[value]]</option>
+                  </template>
+                </select>
+              </gr-select>
+            </template>
+            <template is="dom-if" if="[[_isString(option.info.type)]]">
+              <iron-input
+                bind-value="[[option.info.value]]"
+                on-input="_handleStringChange"
+                data-option-key$="[[option._key]]"
+                disabled$="[[_computeDisabled(option.info.editable)]]"
+              >
+                <input
+                  is="iron-input"
+                  value="[[option.info.value]]"
+                  on-input="_handleStringChange"
+                  data-option-key$="[[option._key]]"
+                  disabled$="[[_computeDisabled(option.info.editable)]]"
+                />
+              </iron-input>
+            </template>
+            <template is="dom-if" if="[[option.info.inherited_value]]">
+              <span class="inherited">
+                (Inherited: [[option.info.inherited_value]])
+              </span>
+            </template>
+          </span>
+        </section>
+      </template>
+    </fieldset>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
deleted file mode 100644
index a2370d9..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html
+++ /dev/null
@@ -1,181 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-repo-plugin-config</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-repo-plugin-config></gr-repo-plugin-config>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-plugin-config.js';
-suite('gr-repo-plugin-config tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => sandbox.restore());
-
-  test('_computePluginConfigOptions', () => {
-    assert.deepEqual(element._computePluginConfigOptions(), []);
-    assert.deepEqual(element._computePluginConfigOptions({}), []);
-    assert.deepEqual(element._computePluginConfigOptions({base: {}}), []);
-    assert.deepEqual(element._computePluginConfigOptions(
-        {base: {config: {}}}), []);
-    assert.deepEqual(element._computePluginConfigOptions(
-        {base: {config: {testKey: 'testInfo'}}}),
-    [{_key: 'testKey', info: 'testInfo'}]);
-  });
-
-  test('_computeDisabled', () => {
-    assert.isFalse(element._computeDisabled('true'));
-    assert.isTrue(element._computeDisabled('false'));
-  });
-
-  test('_handleChange', () => {
-    const eventStub = sandbox.stub(element, 'dispatchEvent');
-    element.pluginData = {
-      name: 'testName',
-      config: {plugin: {value: 'test'}},
-    };
-    element._handleChange({
-      _key: 'plugin',
-      info: {value: 'newTest'},
-      notifyPath: 'plugin.value',
-    });
-
-    assert.isTrue(eventStub.called);
-
-    const {detail} = eventStub.lastCall.args[0];
-    assert.equal(detail.name, 'testName');
-    assert.deepEqual(detail.config, {plugin: {value: 'newTest'}});
-    assert.equal(detail.notifyPath, 'testName.plugin.value');
-  });
-
-  suite('option types', () => {
-    let changeStub;
-    let buildStub;
-
-    setup(() => {
-      changeStub = sandbox.stub(element, '_handleChange');
-      buildStub = sandbox.stub(element, '_buildConfigChangeInfo');
-    });
-
-    test('ARRAY type option', () => {
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin: {value: 'test', type: 'ARRAY'}},
-      };
-      flushAsynchronousOperations();
-
-      const editor = element.shadowRoot
-          .querySelector('gr-plugin-config-array-editor');
-      assert.ok(editor);
-      element._handleArrayChange({detail: 'test'});
-      assert.isTrue(changeStub.called);
-      assert.equal(changeStub.lastCall.args[0], 'test');
-    });
-
-    test('BOOLEAN type option', () => {
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin: {value: 'true', type: 'BOOLEAN'}},
-      };
-      flushAsynchronousOperations();
-
-      const toggle = element.shadowRoot
-          .querySelector('paper-toggle-button');
-      assert.ok(toggle);
-      toggle.click();
-      flushAsynchronousOperations();
-
-      assert.isTrue(buildStub.called);
-      assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
-
-      assert.isTrue(changeStub.called);
-    });
-
-    test('INT/LONG/STRING type option', () => {
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin: {value: 'test', type: 'STRING'}},
-      };
-      flushAsynchronousOperations();
-
-      const input = element.shadowRoot
-          .querySelector('input');
-      assert.ok(input);
-      input.value = 'newTest';
-      input.dispatchEvent(new Event('input'));
-      flushAsynchronousOperations();
-
-      assert.isTrue(buildStub.called);
-      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
-
-      assert.isTrue(changeStub.called);
-    });
-
-    test('LIST type option', () => {
-      const permitted_values = ['test', 'newTest'];
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin: {value: 'test', type: 'LIST', permitted_values}},
-      };
-      flushAsynchronousOperations();
-
-      const select = element.shadowRoot
-          .querySelector('select');
-      assert.ok(select);
-      select.value = 'newTest';
-      select.dispatchEvent(new Event(
-          'change', {bubbles: true, composed: true}));
-      flushAsynchronousOperations();
-
-      assert.isTrue(buildStub.called);
-      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
-
-      assert.isTrue(changeStub.called);
-    });
-  });
-
-  test('_buildConfigChangeInfo', () => {
-    element.pluginData = {
-      name: 'testName',
-      config: {plugin: {value: 'test'}},
-    };
-    const detail = element._buildConfigChangeInfo('newTest', 'plugin');
-    assert.equal(detail._key, 'plugin');
-    assert.deepEqual(detail.info, {value: 'newTest'});
-    assert.equal(detail.notifyPath, 'plugin.value');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
new file mode 100644
index 0000000..1730839
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
@@ -0,0 +1,163 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-repo-plugin-config.js';
+
+const basicFixture = fixtureFromElement('gr-repo-plugin-config');
+
+suite('gr-repo-plugin-config tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_computePluginConfigOptions', () => {
+    assert.deepEqual(element._computePluginConfigOptions(), []);
+    assert.deepEqual(element._computePluginConfigOptions({}), []);
+    assert.deepEqual(element._computePluginConfigOptions({base: {}}), []);
+    assert.deepEqual(element._computePluginConfigOptions(
+        {base: {config: {}}}), []);
+    assert.deepEqual(element._computePluginConfigOptions(
+        {base: {config: {testKey: 'testInfo'}}}),
+    [{_key: 'testKey', info: 'testInfo'}]);
+  });
+
+  test('_computeDisabled', () => {
+    assert.isFalse(element._computeDisabled('true'));
+    assert.isTrue(element._computeDisabled('false'));
+  });
+
+  test('_handleChange', () => {
+    const eventStub = sinon.stub(element, 'dispatchEvent');
+    element.pluginData = {
+      name: 'testName',
+      config: {plugin: {value: 'test'}},
+    };
+    element._handleChange({
+      _key: 'plugin',
+      info: {value: 'newTest'},
+      notifyPath: 'plugin.value',
+    });
+
+    assert.isTrue(eventStub.called);
+
+    const {detail} = eventStub.lastCall.args[0];
+    assert.equal(detail.name, 'testName');
+    assert.deepEqual(detail.config, {plugin: {value: 'newTest'}});
+    assert.equal(detail.notifyPath, 'testName.plugin.value');
+  });
+
+  suite('option types', () => {
+    let changeStub;
+    let buildStub;
+
+    setup(() => {
+      changeStub = sinon.stub(element, '_handleChange');
+      buildStub = sinon.stub(element, '_buildConfigChangeInfo');
+    });
+
+    test('ARRAY type option', () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'test', type: 'ARRAY'}},
+      };
+      flushAsynchronousOperations();
+
+      const editor = element.shadowRoot
+          .querySelector('gr-plugin-config-array-editor');
+      assert.ok(editor);
+      element._handleArrayChange({detail: 'test'});
+      assert.isTrue(changeStub.called);
+      assert.equal(changeStub.lastCall.args[0], 'test');
+    });
+
+    test('BOOLEAN type option', () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'true', type: 'BOOLEAN'}},
+      };
+      flushAsynchronousOperations();
+
+      const toggle = element.shadowRoot
+          .querySelector('paper-toggle-button');
+      assert.ok(toggle);
+      toggle.click();
+      flushAsynchronousOperations();
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
+
+      assert.isTrue(changeStub.called);
+    });
+
+    test('INT/LONG/STRING type option', () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'test', type: 'STRING'}},
+      };
+      flushAsynchronousOperations();
+
+      const input = element.shadowRoot
+          .querySelector('input');
+      assert.ok(input);
+      input.value = 'newTest';
+      input.dispatchEvent(new Event('input'));
+      flushAsynchronousOperations();
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+      assert.isTrue(changeStub.called);
+    });
+
+    test('LIST type option', () => {
+      const permitted_values = ['test', 'newTest'];
+      element.pluginData = {
+        name: 'testName',
+        config: {plugin: {value: 'test', type: 'LIST', permitted_values}},
+      };
+      flushAsynchronousOperations();
+
+      const select = element.shadowRoot
+          .querySelector('select');
+      assert.ok(select);
+      select.value = 'newTest';
+      select.dispatchEvent(new Event(
+          'change', {bubbles: true, composed: true}));
+      flushAsynchronousOperations();
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+      assert.isTrue(changeStub.called);
+    });
+  });
+
+  test('_buildConfigChangeInfo', () => {
+    element.pluginData = {
+      name: 'testName',
+      config: {plugin: {value: 'test'}},
+    };
+    const detail = element._buildConfigChangeInfo('newTest', 'plugin');
+    assert.equal(detail._key, 'plugin');
+    assert.deepEqual(detail.info, {value: 'newTest'});
+    assert.equal(detail.notifyPath, 'plugin.value');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
index 05ae73d..f272708 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 import '@polymer/iron-input/iron-input.js';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
@@ -68,7 +66,7 @@
 };
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrRepo extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -355,8 +353,8 @@
       commands.push({
         title,
         command: commandObj[title]
-            .replace(/\$\{project\}/gi, encodeURI(repo))
-            .replace(/\$\{project-base-name\}/gi,
+            .replace(/\${project}/gi, encodeURI(repo))
+            .replace(/\${project-base-name}/gi,
                 encodeURI(repo.substring(repo.lastIndexOf('/') + 1))),
       });
     }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js
deleted file mode 100644
index de36e73..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.js
+++ /dev/null
@@ -1,437 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    h2.edited:after {
-      color: var(--deemphasized-text-color);
-      content: ' *';
-    }
-    .loading,
-    .hide {
-      display: none;
-    }
-    #loading.loading {
-      display: block;
-    }
-    #loading:not(.loading) {
-      display: none;
-    }
-    #options .repositorySettings {
-      display: none;
-    }
-    #options .repositorySettings.showConfig {
-      display: block;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <main class="gr-form-styles read-only">
-    <style include="shared-styles">
-      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-    </style>
-    <div class="info">
-      <h1 id="Title" class$="name">
-        [[repo]]
-        <hr />
-      </h1>
-      <div>
-        <a href$="[[_computeChangesUrl(repo)]]">(view changes)</a>
-      </div>
-    </div>
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <div id="downloadContent" class$="[[_computeHideClass(_schemes)]]">
-        <h2 id="download">Download</h2>
-        <fieldset>
-          <gr-download-commands
-            id="downloadCommands"
-            commands="[[_computeCommands(repo, _schemesObj, _selectedScheme)]]"
-            schemes="[[_schemes]]"
-            selected-scheme="{{_selectedScheme}}"
-          ></gr-download-commands>
-        </fieldset>
-      </div>
-      <h2 id="configurations" class$="[[_computeHeaderClass(_configChanged)]]">
-        Configurations
-      </h2>
-      <div id="form">
-        <fieldset>
-          <h3 id="Description">Description</h3>
-          <fieldset>
-            <iron-autogrow-textarea
-              id="descriptionInput"
-              class="description"
-              autocomplete="on"
-              placeholder="<Insert repo description here>"
-              bind-value="{{_repoConfig.description}}"
-              disabled$="[[_readOnly]]"
-            ></iron-autogrow-textarea>
-          </fieldset>
-          <h3 id="Options">Repository Options</h3>
-          <fieldset id="options">
-            <section>
-              <span class="title">State</span>
-              <span class="value">
-                <gr-select id="stateSelect" bind-value="{{_repoConfig.state}}">
-                  <select disabled$="[[_readOnly]]">
-                    <template is="dom-repeat" items="[[_states]]">
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Submit type</span>
-              <span class="value">
-                <gr-select
-                  id="submitTypeSelect"
-                  bind-value="{{_repoConfig.submit_type}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatSubmitTypeSelect(_repoConfig)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Allow content merges</span>
-              <span class="value">
-                <gr-select
-                  id="contentMergeSelect"
-                  bind-value="{{_repoConfig.use_content_merge.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_content_merge)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Create a new change for every commit not in the target branch
-              </span>
-              <span class="value">
-                <gr-select
-                  id="newChangeSelect"
-                  bind-value="{{_repoConfig.create_new_change_for_all_not_in_target.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.create_new_change_for_all_not_in_target)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Require Change-Id in commit message</span>
-              <span class="value">
-                <gr-select
-                  id="requireChangeIdSelect"
-                  bind-value="{{_repoConfig.require_change_id.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.require_change_id)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section
-              id="enableSignedPushSettings"
-              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.enable_signed_push)]]"
-            >
-              <span class="title">Enable signed push</span>
-              <span class="value">
-                <gr-select
-                  id="enableSignedPush"
-                  bind-value="{{_repoConfig.enable_signed_push.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.enable_signed_push)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section
-              id="requireSignedPushSettings"
-              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.require_signed_push)]]"
-            >
-              <span class="title">Require signed push</span>
-              <span class="value">
-                <gr-select
-                  id="requireSignedPush"
-                  bind-value="{{_repoConfig.require_signed_push.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.require_signed_push)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Reject implicit merges when changes are pushed for review</span
-              >
-              <span class="value">
-                <gr-select
-                  id="rejectImplicitMergesSelect"
-                  bind-value="{{_repoConfig.reject_implicit_merges.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.reject_implicit_merges)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Enable adding unregistered users as reviewers and CCs on
-                changes</span
-              >
-              <span class="value">
-                <gr-select
-                  id="unRegisteredCcSelect"
-                  bind-value="{{_repoConfig.enable_reviewer_by_email.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.enable_reviewer_by_email)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title"> Set all new changes private by default</span>
-              <span class="value">
-                <gr-select
-                  id="setAllnewChangesPrivateByDefaultSelect"
-                  bind-value="{{_repoConfig.private_by_default.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.private_by_default)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Set new changes to "work in progress" by default</span
-              >
-              <span class="value">
-                <gr-select
-                  id="setAllNewChangesWorkInProgressByDefaultSelect"
-                  bind-value="{{_repoConfig.work_in_progress_by_default.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.work_in_progress_by_default)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Maximum Git object size limit</span>
-              <span class="value">
-                <iron-input
-                  id="maxGitObjSizeIronInput"
-                  bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                  type="text"
-                  disabled$="[[_readOnly]]"
-                >
-                  <input
-                    id="maxGitObjSizeInput"
-                    bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                    is="iron-input"
-                    type="text"
-                    disabled$="[[_readOnly]]"
-                  />
-                </iron-input>
-                <template
-                  is="dom-if"
-                  if="[[_repoConfig.max_object_size_limit.value]]"
-                >
-                  effective: [[_repoConfig.max_object_size_limit.value]] bytes
-                </template>
-              </span>
-            </section>
-            <section>
-              <span class="title"
-                >Match authored date with committer date upon submit</span
-              >
-              <span class="value">
-                <gr-select
-                  id="matchAuthoredDateWithCommitterDateSelect"
-                  bind-value="{{_repoConfig.match_author_to_committer_date.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.match_author_to_committer_date)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Reject empty commit upon submit</span>
-              <span class="value">
-                <gr-select
-                  id="rejectEmptyCommitSelect"
-                  bind-value="{{_repoConfig.reject_empty_commit.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.reject_empty_commit)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-          </fieldset>
-          <h3 id="Options">Contributor Agreements</h3>
-          <fieldset id="agreements">
-            <section>
-              <span class="title">
-                Require a valid contributor agreement to upload</span
-              >
-              <span class="value">
-                <gr-select
-                  id="contributorAgreementSelect"
-                  bind-value="{{_repoConfig.use_contributor_agreements.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_contributor_agreements)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Require Signed-off-by in commit message</span>
-              <span class="value">
-                <gr-select
-                  id="useSignedOffBySelect"
-                  bind-value="{{_repoConfig.use_signed_off_by.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_signed_off_by)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-          </fieldset>
-          <div
-            class$="pluginConfig [[_computeHideClass(_pluginData)]]"
-            on-plugin-config-changed="_handlePluginConfigChanged"
-          >
-            <h3>Plugins</h3>
-            <template is="dom-repeat" items="[[_pluginData]]" as="data">
-              <gr-repo-plugin-config
-                plugin-data="[[data]]"
-              ></gr-repo-plugin-config>
-            </template>
-          </div>
-          <gr-button
-            on-click="_handleSaveRepoConfig"
-            disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]"
-            >Save changes</gr-button
-          >
-        </fieldset>
-        <gr-endpoint-decorator name="repo-config">
-          <gr-endpoint-param
-            name="repoName"
-            value="[[repo]]"
-          ></gr-endpoint-param>
-          <gr-endpoint-param
-            name="readOnly"
-            value="[[_readOnly]]"
-          ></gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </div>
-    </div>
-  </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
new file mode 100644
index 0000000..26d05c3
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
@@ -0,0 +1,440 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-subpage-styles">
+    h2.edited:after {
+      color: var(--deemphasized-text-color);
+      content: ' *';
+    }
+    .loading,
+    .hide {
+      display: none;
+    }
+    #loading.loading {
+      display: block;
+    }
+    #loading:not(.loading) {
+      display: none;
+    }
+    #options .repositorySettings {
+      display: none;
+    }
+    #options .repositorySettings.showConfig {
+      display: block;
+    }
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <main class="gr-form-styles read-only">
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <div class="info">
+      <h1 id="Title" class="heading-1">
+        [[repo]]
+        <hr />
+      </h1>
+      <div>
+        <a href$="[[_computeChangesUrl(repo)]]">(view changes)</a>
+      </div>
+    </div>
+    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+      Loading...
+    </div>
+    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+      <div id="downloadContent" class$="[[_computeHideClass(_schemes)]]">
+        <h2 id="download" class="heading-2">Download</h2>
+        <fieldset>
+          <gr-download-commands
+            id="downloadCommands"
+            commands="[[_computeCommands(repo, _schemesObj, _selectedScheme)]]"
+            schemes="[[_schemes]]"
+            selected-scheme="{{_selectedScheme}}"
+          ></gr-download-commands>
+        </fieldset>
+      </div>
+      <h2
+        id="configurations"
+        class$="heading-2 [[_computeHeaderClass(_configChanged)]]"
+      >
+        Configurations
+      </h2>
+      <div id="form">
+        <fieldset>
+          <h3 id="Description" class="heading-3">Description</h3>
+          <fieldset>
+            <iron-autogrow-textarea
+              id="descriptionInput"
+              class="description"
+              autocomplete="on"
+              placeholder="<Insert repo description here>"
+              bind-value="{{_repoConfig.description}}"
+              disabled$="[[_readOnly]]"
+            ></iron-autogrow-textarea>
+          </fieldset>
+          <h3 id="Options" class="heading-3">Repository Options</h3>
+          <fieldset id="options">
+            <section>
+              <span class="title">State</span>
+              <span class="value">
+                <gr-select id="stateSelect" bind-value="{{_repoConfig.state}}">
+                  <select disabled$="[[_readOnly]]">
+                    <template is="dom-repeat" items="[[_states]]">
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">Submit type</span>
+              <span class="value">
+                <gr-select
+                  id="submitTypeSelect"
+                  bind-value="{{_repoConfig.submit_type}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatSubmitTypeSelect(_repoConfig)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">Allow content merges</span>
+              <span class="value">
+                <gr-select
+                  id="contentMergeSelect"
+                  bind-value="{{_repoConfig.use_content_merge.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.use_content_merge)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">
+                Create a new change for every commit not in the target branch
+              </span>
+              <span class="value">
+                <gr-select
+                  id="newChangeSelect"
+                  bind-value="{{_repoConfig.create_new_change_for_all_not_in_target.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.create_new_change_for_all_not_in_target)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">Require Change-Id in commit message</span>
+              <span class="value">
+                <gr-select
+                  id="requireChangeIdSelect"
+                  bind-value="{{_repoConfig.require_change_id.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.require_change_id)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section
+              id="enableSignedPushSettings"
+              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.enable_signed_push)]]"
+            >
+              <span class="title">Enable signed push</span>
+              <span class="value">
+                <gr-select
+                  id="enableSignedPush"
+                  bind-value="{{_repoConfig.enable_signed_push.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.enable_signed_push)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section
+              id="requireSignedPushSettings"
+              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.require_signed_push)]]"
+            >
+              <span class="title">Require signed push</span>
+              <span class="value">
+                <gr-select
+                  id="requireSignedPush"
+                  bind-value="{{_repoConfig.require_signed_push.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.require_signed_push)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">
+                Reject implicit merges when changes are pushed for review</span
+              >
+              <span class="value">
+                <gr-select
+                  id="rejectImplicitMergesSelect"
+                  bind-value="{{_repoConfig.reject_implicit_merges.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.reject_implicit_merges)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">
+                Enable adding unregistered users as reviewers and CCs on
+                changes</span
+              >
+              <span class="value">
+                <gr-select
+                  id="unRegisteredCcSelect"
+                  bind-value="{{_repoConfig.enable_reviewer_by_email.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.enable_reviewer_by_email)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title"> Set all new changes private by default</span>
+              <span class="value">
+                <gr-select
+                  id="setAllnewChangesPrivateByDefaultSelect"
+                  bind-value="{{_repoConfig.private_by_default.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.private_by_default)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">
+                Set new changes to "work in progress" by default</span
+              >
+              <span class="value">
+                <gr-select
+                  id="setAllNewChangesWorkInProgressByDefaultSelect"
+                  bind-value="{{_repoConfig.work_in_progress_by_default.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.work_in_progress_by_default)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">Maximum Git object size limit</span>
+              <span class="value">
+                <iron-input
+                  id="maxGitObjSizeIronInput"
+                  bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
+                  type="text"
+                  disabled$="[[_readOnly]]"
+                >
+                  <input
+                    id="maxGitObjSizeInput"
+                    bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
+                    is="iron-input"
+                    type="text"
+                    disabled$="[[_readOnly]]"
+                  />
+                </iron-input>
+                <template
+                  is="dom-if"
+                  if="[[_repoConfig.max_object_size_limit.value]]"
+                >
+                  effective: [[_repoConfig.max_object_size_limit.value]] bytes
+                </template>
+              </span>
+            </section>
+            <section>
+              <span class="title"
+                >Match authored date with committer date upon submit</span
+              >
+              <span class="value">
+                <gr-select
+                  id="matchAuthoredDateWithCommitterDateSelect"
+                  bind-value="{{_repoConfig.match_author_to_committer_date.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.match_author_to_committer_date)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">Reject empty commit upon submit</span>
+              <span class="value">
+                <gr-select
+                  id="rejectEmptyCommitSelect"
+                  bind-value="{{_repoConfig.reject_empty_commit.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.reject_empty_commit)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+          </fieldset>
+          <h3 id="Options" class="heading-3">Contributor Agreements</h3>
+          <fieldset id="agreements">
+            <section>
+              <span class="title">
+                Require a valid contributor agreement to upload</span
+              >
+              <span class="value">
+                <gr-select
+                  id="contributorAgreementSelect"
+                  bind-value="{{_repoConfig.use_contributor_agreements.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.use_contributor_agreements)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">Require Signed-off-by in commit message</span>
+              <span class="value">
+                <gr-select
+                  id="useSignedOffBySelect"
+                  bind-value="{{_repoConfig.use_signed_off_by.configured_value}}"
+                >
+                  <select disabled$="[[_readOnly]]">
+                    <template
+                      is="dom-repeat"
+                      items="[[_formatBooleanSelect(_repoConfig.use_signed_off_by)]]"
+                    >
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+          </fieldset>
+          <div
+            class$="pluginConfig [[_computeHideClass(_pluginData)]]"
+            on-plugin-config-changed="_handlePluginConfigChanged"
+          >
+            <h3 class="heading-3">Plugins</h3>
+            <template is="dom-repeat" items="[[_pluginData]]" as="data">
+              <gr-repo-plugin-config
+                plugin-data="[[data]]"
+              ></gr-repo-plugin-config>
+            </template>
+          </div>
+          <gr-button
+            on-click="_handleSaveRepoConfig"
+            disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]"
+            >Save changes</gr-button
+          >
+        </fieldset>
+        <gr-endpoint-decorator name="repo-config">
+          <gr-endpoint-param
+            name="repoName"
+            value="[[repo]]"
+          ></gr-endpoint-param>
+          <gr-endpoint-param
+            name="readOnly"
+            value="[[_readOnly]]"
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>
+    </div>
+  </main>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
deleted file mode 100644
index 58b488a..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.html
+++ /dev/null
@@ -1,400 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-repo</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-repo></gr-repo>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-suite('gr-repo tests', () => {
-  let element;
-  let sandbox;
-  let repoStub;
-  const repoConf = {
-    description: 'Access inherited by all other projects.',
-    use_contributor_agreements: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    use_content_merge: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    use_signed_off_by: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    create_new_change_for_all_not_in_target: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    require_change_id: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    enable_signed_push: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    require_signed_push: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    reject_implicit_merges: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    private_by_default: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    match_author_to_committer_date: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    reject_empty_commit: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    enable_reviewer_by_email: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    max_object_size_limit: {},
-    submit_type: 'MERGE_IF_NECESSARY',
-    default_submit_type: {
-      value: 'MERGE_IF_NECESSARY',
-      configured_value: 'INHERIT',
-      inherited_value: 'MERGE_IF_NECESSARY',
-    },
-  };
-
-  const REPO = 'test-repo';
-  const SCHEMES = {http: {}, repo: {}, ssh: {}};
-
-  function getFormFields() {
-    const selects = Array.from(
-        dom(element.root).querySelectorAll('select'));
-    const textareas = Array.from(
-        dom(element.root).querySelectorAll('iron-autogrow-textarea'));
-    const inputs = Array.from(
-        dom(element.root).querySelectorAll('input'));
-    return inputs.concat(textareas).concat(selects);
-  }
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      getConfig() {
-        return Promise.resolve({download: {}});
-      },
-    });
-    element = fixture('basic');
-    repoStub = sandbox.stub(
-        element.$.restAPI,
-        'getProjectConfig',
-        () => Promise.resolve(repoConf));
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_computePluginData', () => {
-    assert.deepEqual(element._computePluginData(), []);
-    assert.deepEqual(element._computePluginData({}), []);
-    assert.deepEqual(element._computePluginData({base: {}}), []);
-    assert.deepEqual(element._computePluginData({base: {plugin: 'data'}}),
-        [{name: 'plugin', config: 'data'}]);
-  });
-
-  test('_handlePluginConfigChanged', () => {
-    const notifyStub = sandbox.stub(element, 'notifyPath');
-    element._repoConfig = {plugin_config: {}};
-    element._handlePluginConfigChanged({detail: {
-      name: 'test',
-      config: 'data',
-      notifyPath: 'path',
-    }});
-    flushAsynchronousOperations();
-
-    assert.equal(element._repoConfig.plugin_config.test, 'data');
-    assert.equal(notifyStub.lastCall.args[0],
-        '_repoConfig.plugin_config.path');
-  });
-
-  test('loading displays before repo config is loaded', () => {
-    assert.isTrue(element.$.loading.classList.contains('loading'));
-    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
-    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
-    assert.isTrue(getComputedStyle(element.$.loadedContent)
-        .display === 'none');
-  });
-
-  test('download commands visibility', () => {
-    element._loading = false;
-    flushAsynchronousOperations();
-    assert.isTrue(element.$.downloadContent.classList.contains('hide'));
-    assert.isTrue(getComputedStyle(element.$.downloadContent)
-        .display == 'none');
-    element._schemesObj = SCHEMES;
-    flushAsynchronousOperations();
-    assert.isFalse(element.$.downloadContent.classList.contains('hide'));
-    assert.isFalse(getComputedStyle(element.$.downloadContent)
-        .display == 'none');
-  });
-
-  test('form defaults to read only', () => {
-    assert.isTrue(element._readOnly);
-  });
-
-  test('form defaults to read only when not logged in', done => {
-    element.repo = REPO;
-    element._loadRepo().then(() => {
-      assert.isTrue(element._readOnly);
-      done();
-    });
-  });
-
-  test('form defaults to read only when logged in and not admin', done => {
-    element.repo = REPO;
-    sandbox.stub(element, '_getLoggedIn', () => Promise.resolve(true));
-    sandbox.stub(
-        element.$.restAPI,
-        'getRepoAccess',
-        () => Promise.resolve({'test-repo': {}}));
-    element._loadRepo().then(() => {
-      assert.isTrue(element._readOnly);
-      done();
-    });
-  });
-
-  test('all form elements are disabled when not admin', done => {
-    element.repo = REPO;
-    element._loadRepo().then(() => {
-      flushAsynchronousOperations();
-      const formFields = getFormFields();
-      for (const field of formFields) {
-        assert.isTrue(field.hasAttribute('disabled'));
-      }
-      done();
-    });
-  });
-
-  test('_formatBooleanSelect', () => {
-    let item = {inherited_value: true};
-    assert.deepEqual(element._formatBooleanSelect(item), [
-      {
-        label: 'Inherit (true)',
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ]);
-
-    item = {inherited_value: false};
-    assert.deepEqual(element._formatBooleanSelect(item), [
-      {
-        label: 'Inherit (false)',
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ]);
-
-    // For items without inherited values
-    item = {};
-    assert.deepEqual(element._formatBooleanSelect(item), [
-      {
-        label: 'Inherit',
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ]);
-  });
-
-  test('fires page-error', done => {
-    repoStub.restore();
-
-    element.repo = 'test';
-
-    const response = {status: 404};
-    sandbox.stub(
-        element.$.restAPI, 'getProjectConfig', (repo, errFn) => {
-          errFn(response);
-        });
-    element.addEventListener('page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      done();
-    });
-
-    element._loadRepo();
-  });
-
-  suite('admin', () => {
-    setup(() => {
-      element.repo = REPO;
-      sandbox.stub(element, '_getLoggedIn', () => Promise.resolve(true));
-      sandbox.stub(
-          element.$.restAPI,
-          'getRepoAccess',
-          () => Promise.resolve({'test-repo': {is_owner: true}}));
-    });
-
-    test('all form elements are enabled', done => {
-      element._loadRepo().then(() => {
-        flushAsynchronousOperations();
-        const formFields = getFormFields();
-        for (const field of formFields) {
-          assert.isFalse(field.hasAttribute('disabled'));
-        }
-        assert.isFalse(element._loading);
-        done();
-      });
-    });
-
-    test('state gets set correctly', done => {
-      element._loadRepo().then(() => {
-        assert.equal(element._repoConfig.state, 'ACTIVE');
-        assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
-        done();
-      });
-    });
-
-    test('inherited submit type value is calculated correctly', done => {
-      element
-          ._loadRepo().then(() => {
-            const sel = element.$.submitTypeSelect;
-            assert.equal(sel.bindValue, 'INHERIT');
-            assert.equal(
-                sel.nativeSelect.options[0].text,
-                'Inherit (Merge if necessary)'
-            );
-            done();
-          });
-    });
-
-    test('fields update and save correctly', () => {
-      const configInputObj = {
-        description: 'new description',
-        use_contributor_agreements: 'TRUE',
-        use_content_merge: 'TRUE',
-        use_signed_off_by: 'TRUE',
-        create_new_change_for_all_not_in_target: 'TRUE',
-        require_change_id: 'TRUE',
-        enable_signed_push: 'TRUE',
-        require_signed_push: 'TRUE',
-        reject_implicit_merges: 'TRUE',
-        private_by_default: 'TRUE',
-        match_author_to_committer_date: 'TRUE',
-        reject_empty_commit: 'TRUE',
-        max_object_size_limit: 10,
-        submit_type: 'FAST_FORWARD_ONLY',
-        state: 'READ_ONLY',
-        enable_reviewer_by_email: 'TRUE',
-      };
-
-      const saveStub = sandbox.stub(element.$.restAPI, 'saveRepoConfig'
-          , () => Promise.resolve({}));
-
-      const button = dom(element.root).querySelector('gr-button');
-
-      return element._loadRepo().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-        element.$.descriptionInput.bindValue = configInputObj.description;
-        element.$.stateSelect.bindValue = configInputObj.state;
-        element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
-        element.$.contentMergeSelect.bindValue =
-            configInputObj.use_content_merge;
-        element.$.newChangeSelect.bindValue =
-            configInputObj.create_new_change_for_all_not_in_target;
-        element.$.requireChangeIdSelect.bindValue =
-            configInputObj.require_change_id;
-        element.$.enableSignedPush.bindValue =
-            configInputObj.enable_signed_push;
-        element.$.requireSignedPush.bindValue =
-            configInputObj.require_signed_push;
-        element.$.rejectImplicitMergesSelect.bindValue =
-            configInputObj.reject_implicit_merges;
-        element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
-            configInputObj.private_by_default;
-        element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
-            configInputObj.match_author_to_committer_date;
-        const inputElement = PolymerElement ?
-          element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
-        inputElement.bindValue = configInputObj.max_object_size_limit;
-        element.$.contributorAgreementSelect.bindValue =
-            configInputObj.use_contributor_agreements;
-        element.$.useSignedOffBySelect.bindValue =
-            configInputObj.use_signed_off_by;
-        element.$.rejectEmptyCommitSelect.bindValue =
-            configInputObj.reject_empty_commit;
-        element.$.unRegisteredCcSelect.bindValue =
-            configInputObj.enable_reviewer_by_email;
-
-        assert.isFalse(button.hasAttribute('disabled'));
-        assert.isTrue(element.$.configurations.classList.contains('edited'));
-
-        const formattedObj =
-            element._formatRepoConfigForSave(element._repoConfig);
-        assert.deepEqual(formattedObj, configInputObj);
-
-        return element._handleSaveRepoConfig().then(() => {
-          assert.isTrue(button.hasAttribute('disabled'));
-          assert.isFalse(element.$.Title.classList.contains('edited'));
-          assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
-              configInputObj));
-        });
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
new file mode 100644
index 0000000..3b42e3b
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
@@ -0,0 +1,382 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-repo.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+
+const basicFixture = fixtureFromElement('gr-repo');
+
+suite('gr-repo tests', () => {
+  let element;
+
+  let repoStub;
+  const repoConf = {
+    description: 'Access inherited by all other projects.',
+    use_contributor_agreements: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    use_content_merge: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    use_signed_off_by: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    create_new_change_for_all_not_in_target: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    require_change_id: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    enable_signed_push: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    require_signed_push: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    reject_implicit_merges: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    private_by_default: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    match_author_to_committer_date: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    reject_empty_commit: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    enable_reviewer_by_email: {
+      value: false,
+      configured_value: 'FALSE',
+    },
+    max_object_size_limit: {},
+    submit_type: 'MERGE_IF_NECESSARY',
+    default_submit_type: {
+      value: 'MERGE_IF_NECESSARY',
+      configured_value: 'INHERIT',
+      inherited_value: 'MERGE_IF_NECESSARY',
+    },
+  };
+
+  const REPO = 'test-repo';
+  const SCHEMES = {http: {}, repo: {}, ssh: {}};
+
+  function getFormFields() {
+    const selects = Array.from(
+        dom(element.root).querySelectorAll('select'));
+    const textareas = Array.from(
+        dom(element.root).querySelectorAll('iron-autogrow-textarea'));
+    const inputs = Array.from(
+        dom(element.root).querySelectorAll('input'));
+    return inputs.concat(textareas).concat(selects);
+  }
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getConfig() {
+        return Promise.resolve({download: {}});
+      },
+    });
+    element = basicFixture.instantiate();
+    repoStub = sinon.stub(
+        element.$.restAPI,
+        'getProjectConfig')
+        .callsFake(() => Promise.resolve(repoConf));
+  });
+
+  test('_computePluginData', () => {
+    assert.deepEqual(element._computePluginData(), []);
+    assert.deepEqual(element._computePluginData({}), []);
+    assert.deepEqual(element._computePluginData({base: {}}), []);
+    assert.deepEqual(element._computePluginData({base: {plugin: 'data'}}),
+        [{name: 'plugin', config: 'data'}]);
+  });
+
+  test('_handlePluginConfigChanged', () => {
+    const notifyStub = sinon.stub(element, 'notifyPath');
+    element._repoConfig = {plugin_config: {}};
+    element._handlePluginConfigChanged({detail: {
+      name: 'test',
+      config: 'data',
+      notifyPath: 'path',
+    }});
+    flushAsynchronousOperations();
+
+    assert.equal(element._repoConfig.plugin_config.test, 'data');
+    assert.equal(notifyStub.lastCall.args[0],
+        '_repoConfig.plugin_config.path');
+  });
+
+  test('loading displays before repo config is loaded', () => {
+    assert.isTrue(element.$.loading.classList.contains('loading'));
+    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
+    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
+    assert.isTrue(getComputedStyle(element.$.loadedContent)
+        .display === 'none');
+  });
+
+  test('download commands visibility', () => {
+    element._loading = false;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.downloadContent.classList.contains('hide'));
+    assert.isTrue(getComputedStyle(element.$.downloadContent)
+        .display == 'none');
+    element._schemesObj = SCHEMES;
+    flushAsynchronousOperations();
+    assert.isFalse(element.$.downloadContent.classList.contains('hide'));
+    assert.isFalse(getComputedStyle(element.$.downloadContent)
+        .display == 'none');
+  });
+
+  test('form defaults to read only', () => {
+    assert.isTrue(element._readOnly);
+  });
+
+  test('form defaults to read only when not logged in', done => {
+    element.repo = REPO;
+    element._loadRepo().then(() => {
+      assert.isTrue(element._readOnly);
+      done();
+    });
+  });
+
+  test('form defaults to read only when logged in and not admin', done => {
+    element.repo = REPO;
+    sinon.stub(element, '_getLoggedIn').callsFake(() => Promise.resolve(true));
+    sinon.stub(
+        element.$.restAPI,
+        'getRepoAccess')
+        .callsFake(() => Promise.resolve({'test-repo': {}}));
+    element._loadRepo().then(() => {
+      assert.isTrue(element._readOnly);
+      done();
+    });
+  });
+
+  test('all form elements are disabled when not admin', done => {
+    element.repo = REPO;
+    element._loadRepo().then(() => {
+      flushAsynchronousOperations();
+      const formFields = getFormFields();
+      for (const field of formFields) {
+        assert.isTrue(field.hasAttribute('disabled'));
+      }
+      done();
+    });
+  });
+
+  test('_formatBooleanSelect', () => {
+    let item = {inherited_value: true};
+    assert.deepEqual(element._formatBooleanSelect(item), [
+      {
+        label: 'Inherit (true)',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      }, {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+
+    item = {inherited_value: false};
+    assert.deepEqual(element._formatBooleanSelect(item), [
+      {
+        label: 'Inherit (false)',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      }, {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+
+    // For items without inherited values
+    item = {};
+    assert.deepEqual(element._formatBooleanSelect(item), [
+      {
+        label: 'Inherit',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      }, {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+  });
+
+  test('fires page-error', done => {
+    repoStub.restore();
+
+    element.repo = 'test';
+
+    const response = {status: 404};
+    sinon.stub(
+        element.$.restAPI, 'getProjectConfig').callsFake((repo, errFn) => {
+      errFn(response);
+    });
+    element.addEventListener('page-error', e => {
+      assert.deepEqual(e.detail.response, response);
+      done();
+    });
+
+    element._loadRepo();
+  });
+
+  suite('admin', () => {
+    setup(() => {
+      element.repo = REPO;
+      sinon.stub(element, '_getLoggedIn')
+          .callsFake(() => Promise.resolve(true));
+      sinon.stub(
+          element.$.restAPI,
+          'getRepoAccess')
+          .callsFake(() => Promise.resolve({'test-repo': {is_owner: true}}));
+    });
+
+    test('all form elements are enabled', done => {
+      element._loadRepo().then(() => {
+        flushAsynchronousOperations();
+        const formFields = getFormFields();
+        for (const field of formFields) {
+          assert.isFalse(field.hasAttribute('disabled'));
+        }
+        assert.isFalse(element._loading);
+        done();
+      });
+    });
+
+    test('state gets set correctly', done => {
+      element._loadRepo().then(() => {
+        assert.equal(element._repoConfig.state, 'ACTIVE');
+        assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
+        done();
+      });
+    });
+
+    test('inherited submit type value is calculated correctly', done => {
+      element
+          ._loadRepo().then(() => {
+            const sel = element.$.submitTypeSelect;
+            assert.equal(sel.bindValue, 'INHERIT');
+            assert.equal(
+                sel.nativeSelect.options[0].text,
+                'Inherit (Merge if necessary)'
+            );
+            done();
+          });
+    });
+
+    test('fields update and save correctly', () => {
+      const configInputObj = {
+        description: 'new description',
+        use_contributor_agreements: 'TRUE',
+        use_content_merge: 'TRUE',
+        use_signed_off_by: 'TRUE',
+        create_new_change_for_all_not_in_target: 'TRUE',
+        require_change_id: 'TRUE',
+        enable_signed_push: 'TRUE',
+        require_signed_push: 'TRUE',
+        reject_implicit_merges: 'TRUE',
+        private_by_default: 'TRUE',
+        match_author_to_committer_date: 'TRUE',
+        reject_empty_commit: 'TRUE',
+        max_object_size_limit: 10,
+        submit_type: 'FAST_FORWARD_ONLY',
+        state: 'READ_ONLY',
+        enable_reviewer_by_email: 'TRUE',
+      };
+
+      const saveStub = sinon.stub(element.$.restAPI, 'saveRepoConfig')
+          .callsFake(() => Promise.resolve({}));
+
+      const button = dom(element.root).querySelector('gr-button');
+
+      return element._loadRepo().then(() => {
+        assert.isTrue(button.hasAttribute('disabled'));
+        assert.isFalse(element.$.Title.classList.contains('edited'));
+        element.$.descriptionInput.bindValue = configInputObj.description;
+        element.$.stateSelect.bindValue = configInputObj.state;
+        element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
+        element.$.contentMergeSelect.bindValue =
+            configInputObj.use_content_merge;
+        element.$.newChangeSelect.bindValue =
+            configInputObj.create_new_change_for_all_not_in_target;
+        element.$.requireChangeIdSelect.bindValue =
+            configInputObj.require_change_id;
+        element.$.enableSignedPush.bindValue =
+            configInputObj.enable_signed_push;
+        element.$.requireSignedPush.bindValue =
+            configInputObj.require_signed_push;
+        element.$.rejectImplicitMergesSelect.bindValue =
+            configInputObj.reject_implicit_merges;
+        element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
+            configInputObj.private_by_default;
+        element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
+            configInputObj.match_author_to_committer_date;
+        const inputElement = PolymerElement ?
+          element.$.maxGitObjSizeIronInput : element.$.maxGitObjSizeInput;
+        inputElement.bindValue = configInputObj.max_object_size_limit;
+        element.$.contributorAgreementSelect.bindValue =
+            configInputObj.use_contributor_agreements;
+        element.$.useSignedOffBySelect.bindValue =
+            configInputObj.use_signed_off_by;
+        element.$.rejectEmptyCommitSelect.bindValue =
+            configInputObj.reject_empty_commit;
+        element.$.unRegisteredCcSelect.bindValue =
+            configInputObj.enable_reviewer_by_email;
+
+        assert.isFalse(button.hasAttribute('disabled'));
+        assert.isTrue(element.$.configurations.classList.contains('edited'));
+
+        const formattedObj =
+            element._formatRepoConfigForSave(element._repoConfig);
+        assert.deepEqual(formattedObj, configInputObj);
+
+        return element._handleSaveRepoConfig().then(() => {
+          assert.isTrue(button.hasAttribute('disabled'));
+          assert.isFalse(element.$.Title.classList.contains('edited'));
+          assert.isTrue(saveStub.lastCall.calledWithExactly(REPO,
+              configInputObj));
+        });
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
index 234015a..a23614d 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
@@ -14,22 +14,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-select/gr-select.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-rule-editor_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {AccessBehavior} from '../../../behaviors/gr-access-behavior/gr-access-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util.js';
+import {AccessPermissions} from '../../../utils/access-util.js';
 
 /**
  * Fired when the rule has been modified or removed.
@@ -79,15 +75,11 @@
 ];
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrRuleEditor extends mixinBehaviors( [
-  AccessBehavior,
-  BaseUrlBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
+class GrRuleEditor extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-rule-editor'; }
@@ -161,12 +153,12 @@
   }
 
   _computeForce(permission, action) {
-    if (this.permissionValues.push.id === permission &&
+    if (AccessPermissions.push.id === permission &&
         action !== Action.DENY) {
       return true;
     }
 
-    return this.permissionValues.editTopicName.id === permission;
+    return AccessPermissions.editTopicName.id === permission;
   }
 
   _computeForceClass(permission, action) {
@@ -174,7 +166,7 @@
   }
 
   _computeGroupPath(group) {
-    return `${this.getBaseUrl()}/admin/groups/${this.encodeURL(group, true)}`;
+    return `${getBaseUrl()}/admin/groups/${encodeURL(group, true)}`;
   }
 
   _handleAccessSaved() {
@@ -204,7 +196,7 @@
   }
 
   _computeForceOptions(permission, action) {
-    if (permission === this.permissionValues.push.id) {
+    if (permission === AccessPermissions.push.id) {
       if (action === Action.ALLOW) {
         return ForcePushOptions.ALLOW;
       } else if (action === Action.BLOCK) {
@@ -212,7 +204,7 @@
       } else {
         return [];
       }
-    } else if (permission === this.permissionValues.editTopicName.id) {
+    } else if (permission === AccessPermissions.editTopicName.id) {
       return FORCE_EDIT_OPTIONS;
     }
     return [];
@@ -267,7 +259,7 @@
     // gr-permission will take care of removing rules that were added but
     // unsaved. We need to keep the added bit for the filter.
     if (this.rule.value.added) { return; }
-    this.set('rule.value', Object.assign({}, this._originalRuleValues));
+    this.set('rule.value', {...this._originalRuleValues});
     this._deleted = false;
     delete this.rule.value.deleted;
     delete this.rule.value.modified;
@@ -282,7 +274,7 @@
   }
 
   _setOriginalRuleValues(value) {
-    this._originalRuleValues = Object.assign({}, value);
+    this._originalRuleValues = {...value};
   }
 }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.js
deleted file mode 100644
index 3e4f9d4..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.js
+++ /dev/null
@@ -1,160 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      border-bottom: 1px solid var(--border-color);
-      padding: var(--spacing-m);
-      display: block;
-    }
-    #removeBtn {
-      display: none;
-    }
-    .editing #removeBtn {
-      display: flex;
-    }
-    #options {
-      align-items: baseline;
-      display: flex;
-    }
-    #options > * {
-      margin-right: var(--spacing-m);
-    }
-    #mainContainer {
-      align-items: baseline;
-      display: flex;
-      flex-wrap: nowrap;
-      justify-content: space-between;
-    }
-    #deletedContainer.deleted {
-      align-items: baseline;
-      display: flex;
-      justify-content: space-between;
-    }
-    #undoBtn,
-    #force,
-    #deletedContainer,
-    #mainContainer.deleted {
-      display: none;
-    }
-    #undoBtn.modified,
-    #force.force {
-      display: block;
-    }
-    .groupPath {
-      color: var(--deemphasized-text-color);
-    }
-  </style>
-  <style include="gr-form-styles">
-    iron-autogrow-textarea {
-      width: 14em;
-    }
-  </style>
-  <div
-    id="mainContainer"
-    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
-  >
-    <div id="options">
-      <gr-select
-        id="action"
-        bind-value="{{rule.value.action}}"
-        on-change="_handleValueChange"
-      >
-        <select disabled$="[[!editing]]">
-          <template is="dom-repeat" items="[[_computeOptions(permission)]]">
-            <option value="[[item]]">[[item]]</option>
-          </template>
-        </select>
-      </gr-select>
-      <template is="dom-if" if="[[label]]">
-        <gr-select
-          id="labelMin"
-          bind-value="{{rule.value.min}}"
-          on-change="_handleValueChange"
-        >
-          <select disabled$="[[!editing]]">
-            <template is="dom-repeat" items="[[label.values]]">
-              <option value="[[item.value]]">[[item.value]]</option>
-            </template>
-          </select>
-        </gr-select>
-        <gr-select
-          id="labelMax"
-          bind-value="{{rule.value.max}}"
-          on-change="_handleValueChange"
-        >
-          <select disabled$="[[!editing]]">
-            <template is="dom-repeat" items="[[label.values]]">
-              <option value="[[item.value]]">[[item.value]]</option>
-            </template>
-          </select>
-        </gr-select>
-      </template>
-      <template is="dom-if" if="[[hasRange]]">
-        <iron-autogrow-textarea
-          id="minInput"
-          class="min"
-          autocomplete="on"
-          placeholder="Min value"
-          bind-value="{{rule.value.min}}"
-          disabled$="[[!editing]]"
-        ></iron-autogrow-textarea>
-        <iron-autogrow-textarea
-          id="maxInput"
-          class="max"
-          autocomplete="on"
-          placeholder="Max value"
-          bind-value="{{rule.value.max}}"
-          disabled$="[[!editing]]"
-        ></iron-autogrow-textarea>
-      </template>
-      <a class="groupPath" href$="[[_computeGroupPath(groupId)]]">
-        [[groupName]]
-      </a>
-      <gr-select
-        id="force"
-        class$="[[_computeForceClass(permission, rule.value.action)]]"
-        bind-value="{{rule.value.force}}"
-        on-change="_handleValueChange"
-      >
-        <select disabled$="[[!editing]]">
-          <template
-            is="dom-repeat"
-            items="[[_computeForceOptions(permission, rule.value.action)]]"
-          >
-            <option value="[[item.value]]">[[item.name]]</option>
-          </template>
-        </select>
-      </gr-select>
-    </div>
-    <gr-button link="" id="removeBtn" on-click="_handleRemoveRule"
-      >Remove</gr-button
-    >
-  </div>
-  <div
-    id="deletedContainer"
-    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
-  >
-    [[groupName]] was deleted
-    <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
-      >Undo</gr-button
-    >
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
new file mode 100644
index 0000000..98403e0
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
@@ -0,0 +1,160 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      border-bottom: 1px solid var(--border-color);
+      padding: var(--spacing-m);
+      display: block;
+    }
+    #removeBtn {
+      display: none;
+    }
+    .editing #removeBtn {
+      display: flex;
+    }
+    #options {
+      align-items: baseline;
+      display: flex;
+    }
+    #options > * {
+      margin-right: var(--spacing-m);
+    }
+    #mainContainer {
+      align-items: baseline;
+      display: flex;
+      flex-wrap: nowrap;
+      justify-content: space-between;
+    }
+    #deletedContainer.deleted {
+      align-items: baseline;
+      display: flex;
+      justify-content: space-between;
+    }
+    #undoBtn,
+    #force,
+    #deletedContainer,
+    #mainContainer.deleted {
+      display: none;
+    }
+    #undoBtn.modified,
+    #force.force {
+      display: block;
+    }
+    .groupPath {
+      color: var(--deemphasized-text-color);
+    }
+  </style>
+  <style include="gr-form-styles">
+    iron-autogrow-textarea {
+      width: 14em;
+    }
+  </style>
+  <div
+    id="mainContainer"
+    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
+  >
+    <div id="options">
+      <gr-select
+        id="action"
+        bind-value="{{rule.value.action}}"
+        on-change="_handleValueChange"
+      >
+        <select disabled$="[[!editing]]">
+          <template is="dom-repeat" items="[[_computeOptions(permission)]]">
+            <option value="[[item]]">[[item]]</option>
+          </template>
+        </select>
+      </gr-select>
+      <template is="dom-if" if="[[label]]">
+        <gr-select
+          id="labelMin"
+          bind-value="{{rule.value.min}}"
+          on-change="_handleValueChange"
+        >
+          <select disabled$="[[!editing]]">
+            <template is="dom-repeat" items="[[label.values]]">
+              <option value="[[item.value]]">[[item.value]]</option>
+            </template>
+          </select>
+        </gr-select>
+        <gr-select
+          id="labelMax"
+          bind-value="{{rule.value.max}}"
+          on-change="_handleValueChange"
+        >
+          <select disabled$="[[!editing]]">
+            <template is="dom-repeat" items="[[label.values]]">
+              <option value="[[item.value]]">[[item.value]]</option>
+            </template>
+          </select>
+        </gr-select>
+      </template>
+      <template is="dom-if" if="[[hasRange]]">
+        <iron-autogrow-textarea
+          id="minInput"
+          class="min"
+          autocomplete="on"
+          placeholder="Min value"
+          bind-value="{{rule.value.min}}"
+          disabled$="[[!editing]]"
+        ></iron-autogrow-textarea>
+        <iron-autogrow-textarea
+          id="maxInput"
+          class="max"
+          autocomplete="on"
+          placeholder="Max value"
+          bind-value="{{rule.value.max}}"
+          disabled$="[[!editing]]"
+        ></iron-autogrow-textarea>
+      </template>
+      <a class="groupPath" href$="[[_computeGroupPath(groupId)]]">
+        [[groupName]]
+      </a>
+      <gr-select
+        id="force"
+        class$="[[_computeForceClass(permission, rule.value.action)]]"
+        bind-value="{{rule.value.force}}"
+        on-change="_handleValueChange"
+      >
+        <select disabled$="[[!editing]]">
+          <template
+            is="dom-repeat"
+            items="[[_computeForceOptions(permission, rule.value.action)]]"
+          >
+            <option value="[[item.value]]">[[item.name]]</option>
+          </template>
+        </select>
+      </gr-select>
+    </div>
+    <gr-button link="" id="removeBtn" on-click="_handleRemoveRule"
+      >Remove</gr-button
+    >
+  </div>
+  <div
+    id="deletedContainer"
+    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
+  >
+    [[groupName]] was deleted
+    <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
+      >Undo</gr-button
+    >
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
deleted file mode 100644
index f096eed..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
+++ /dev/null
@@ -1,626 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-rule-editor</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-rule-editor></gr-rule-editor>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-rule-editor.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-rule-editor tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('unit tests', () => {
-    test('_computeForce, _computeForceClass, and _computeForceOptions',
-        () => {
-          const ForcePushOptions = {
-            ALLOW: [
-              {name: 'Allow pushing (but not force pushing)', value: false},
-              {name: 'Allow pushing with or without force', value: true},
-            ],
-            BLOCK: [
-              {name: 'Block pushing with or without force', value: false},
-              {name: 'Block force pushing', value: true},
-            ],
-          };
-
-          const FORCE_EDIT_OPTIONS = [
-            {
-              name: 'No Force Edit',
-              value: false,
-            },
-            {
-              name: 'Force Edit',
-              value: true,
-            },
-          ];
-          let permission = 'push';
-          let action = 'ALLOW';
-          assert.isTrue(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action),
-              'force');
-          assert.deepEqual(element._computeForceOptions(permission, action),
-              ForcePushOptions.ALLOW);
-
-          action = 'BLOCK';
-          assert.isTrue(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action),
-              'force');
-          assert.deepEqual(element._computeForceOptions(permission, action),
-              ForcePushOptions.BLOCK);
-
-          action = 'DENY';
-          assert.isFalse(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action), '');
-          assert.equal(
-              element._computeForceOptions(permission, action).length, 0);
-
-          permission = 'editTopicName';
-          assert.isTrue(element._computeForce(permission));
-          assert.equal(element._computeForceClass(permission), 'force');
-          assert.deepEqual(element._computeForceOptions(permission),
-              FORCE_EDIT_OPTIONS);
-          permission = 'submit';
-          assert.isFalse(element._computeForce(permission));
-          assert.equal(element._computeForceClass(permission), '');
-          assert.deepEqual(element._computeForceOptions(permission), []);
-        });
-
-    test('_computeSectionClass', () => {
-      let deleted = true;
-      let editing = false;
-      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
-      deleted = false;
-      assert.equal(element._computeSectionClass(editing, deleted), '');
-
-      editing = true;
-      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
-      deleted = true;
-      assert.equal(element._computeSectionClass(editing, deleted),
-          'editing deleted');
-    });
-
-    test('_getDefaultRuleValues', () => {
-      let permission = 'priority';
-      let label;
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'BATCH'});
-      permission = 'label-Code-Review';
-      label = {values: [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW', max: 2, min: -2});
-      permission = 'push';
-      label = undefined;
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW', force: false});
-      permission = 'submit';
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW'});
-    });
-
-    test('_setDefaultRuleValues', () => {
-      element.rule = {id: 123};
-      const defaultValue = {action: 'ALLOW'};
-      sandbox.stub(element, '_getDefaultRuleValues').returns(defaultValue);
-      element._setDefaultRuleValues();
-      assert.isTrue(element._getDefaultRuleValues.called);
-      assert.equal(element.rule.value, defaultValue);
-    });
-
-    test('_computeOptions', () => {
-      const PRIORITY_OPTIONS = [
-        'BATCH',
-        'INTERACTIVE',
-      ];
-      const DROPDOWN_OPTIONS = [
-        'ALLOW',
-        'DENY',
-        'BLOCK',
-      ];
-      let permission = 'priority';
-      assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
-      permission = 'submit';
-      assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
-    });
-
-    test('_handleValueChange', () => {
-      const modifiedHandler = sandbox.stub();
-      element.rule = {value: {}};
-      element.addEventListener('access-modified', modifiedHandler);
-      element._handleValueChange();
-      assert.isNotOk(element.rule.value.modified);
-      element._originalRuleValues = {};
-      element._handleValueChange();
-      assert.isTrue(element.rule.value.modified);
-      assert.isTrue(modifiedHandler.called);
-    });
-
-    test('_handleAccessSaved', () => {
-      const originalValue = {action: 'DENY'};
-      const newValue = {action: 'ALLOW'};
-      element._originalRuleValues = originalValue;
-      element.rule = {value: newValue};
-      element._handleAccessSaved();
-      assert.deepEqual(element._originalRuleValues, newValue);
-    });
-
-    test('_setOriginalRuleValues', () => {
-      const value = {
-        action: 'ALLOW',
-        force: false,
-      };
-      element._setOriginalRuleValues(value);
-      assert.deepEqual(element._originalRuleValues, value);
-    });
-  });
-
-  suite('already existing generic rule', () => {
-    setup(done => {
-      element.group = 'Group Name';
-      element.permission = 'submit';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: false,
-        },
-      };
-      element.section = 'refs/*';
-
-      // Typically called on ready since elements will have properies defined
-      // by the parent element.
-      element._setupValues(element.rule);
-      flushAsynchronousOperations();
-      flush(() => {
-        element.attached();
-        done();
-      });
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.isNotOk(dom(element.root).querySelector('#labelMin'));
-      assert.isNotOk(dom(element.root).querySelector('#labelMax'));
-      assert.isFalse(element.$.force.classList.contains('force'));
-    });
-
-    test('modify and cancel restores original values', () => {
-      element.editing = true;
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = 'DENY';
-      assert.isTrue(element.rule.value.modified);
-      element.editing = false;
-      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-      assert.equal(element.$.action.bindValue, 'ALLOW');
-      assert.isNotOk(element.rule.value.modified);
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = 'DENY';
-      flushAsynchronousOperations();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('all selects are disabled when not in edit mode', () => {
-      const selects = dom(element.root).querySelectorAll('select');
-      for (const select of selects) {
-        assert.isTrue(select.disabled);
-      }
-      element.editing = true;
-      for (const select of selects) {
-        assert.isFalse(select.disabled);
-      }
-    });
-
-    test('remove rule and undo remove', () => {
-      element.editing = true;
-      element.rule = {id: 123, value: {action: 'ALLOW'}};
-      assert.isFalse(
-          element.$.deletedContainer.classList.contains('deleted'));
-      MockInteractions.tap(element.$.removeBtn);
-      assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
-      assert.isTrue(element._deleted);
-      assert.isTrue(element.rule.value.deleted);
-
-      MockInteractions.tap(element.$.undoRemoveBtn);
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.rule.value.deleted);
-    });
-
-    test('remove rule and cancel', () => {
-      element.editing = true;
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-
-      element.rule = {id: 123, value: {action: 'ALLOW'}};
-      MockInteractions.tap(element.$.removeBtn);
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.notEqual(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-      assert.isTrue(element._deleted);
-      assert.isTrue(element.rule.value.deleted);
-
-      element.editing = false;
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.rule.value.deleted);
-      assert.isNotOk(element.rule.value.modified);
-
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-    });
-
-    test('_computeGroupPath', () => {
-      const group = '123';
-      assert.equal(element._computeGroupPath(group),
-          `/admin/groups/123`);
-    });
-  });
-
-  suite('new edit rule', () => {
-    setup(done => {
-      element.group = 'Group Name';
-      element.permission = 'editTopicName';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      flushAsynchronousOperations();
-      element.rule.value.added = true;
-      flush(() => {
-        element.attached();
-        done();
-      });
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      const expectedRuleValue = {
-        action: 'ALLOW',
-        force: false,
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.force.bindValue = true;
-      flushAsynchronousOperations();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('remove value', () => {
-      element.editing = true;
-      const removeStub = sandbox.stub();
-      element.addEventListener('added-rule-removed', removeStub);
-      MockInteractions.tap(element.$.removeBtn);
-      flushAsynchronousOperations();
-      assert.isTrue(removeStub.called);
-    });
-  });
-
-  suite('already existing rule with labels', () => {
-    setup(done => {
-      element.label = {values: [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      element.group = 'Group Name';
-      element.permission = 'label-Code-Review';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: false,
-          max: 2,
-          min: -2,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      flushAsynchronousOperations();
-      flush(() => {
-        element.attached();
-        done();
-      });
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          dom(element.root).querySelector('#labelMin').bindValue,
-          element.rule.value.min);
-      assert.equal(
-          dom(element.root).querySelector('#labelMax').bindValue,
-          element.rule.value.max);
-      assert.isFalse(element.$.force.classList.contains('force'));
-    });
-
-    test('modify value', () => {
-      const removeStub = sandbox.stub();
-      element.addEventListener('added-rule-removed', removeStub);
-      assert.isNotOk(element.rule.value.modified);
-      dom(element.root).querySelector('#labelMin').bindValue = 1;
-      flushAsynchronousOperations();
-      assert.isTrue(element.rule.value.modified);
-      assert.isFalse(removeStub.called);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('new rule with labels', () => {
-    setup(done => {
-      sandbox.spy(element, '_setDefaultRuleValues');
-      element.label = {values: [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      element.group = 'Group Name';
-      element.permission = 'label-Code-Review';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      flushAsynchronousOperations();
-      element.rule.value.added = true;
-      flush(() => {
-        element.attached();
-        done();
-      });
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      assert.isTrue(element._setDefaultRuleValues.called);
-
-      const expectedRuleValue = {
-        max: element.label.values[element.label.values.length - 1].value,
-        min: element.label.values[0].value,
-        action: 'ALLOW',
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(
-            element.$.action.bindValue,
-            expectedRuleValue.action);
-        assert.equal(
-            dom(element.root).querySelector('#labelMin').bindValue,
-            expectedRuleValue.min);
-        assert.equal(
-            dom(element.root).querySelector('#labelMax').bindValue,
-            expectedRuleValue.max);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      dom(element.root).querySelector('#labelMin').bindValue = 1;
-      flushAsynchronousOperations();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('already existing push rule', () => {
-    setup(done => {
-      element.group = 'Group Name';
-      element.permission = 'push';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: true,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      flushAsynchronousOperations();
-      flush(() => {
-        element.attached();
-        done();
-      });
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.isTrue(element.$.force.classList.contains('force'));
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          dom(element.root).querySelector('#force').bindValue,
-          element.rule.value.force);
-      assert.isNotOk(dom(element.root).querySelector('#labelMin'));
-      assert.isNotOk(dom(element.root).querySelector('#labelMax'));
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('new push rule', () => {
-    setup(done => {
-      element.group = 'Group Name';
-      element.permission = 'push';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      flushAsynchronousOperations();
-      element.rule.value.added = true;
-      flush(() => {
-        element.attached();
-        done();
-      });
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      const expectedRuleValue = {
-        action: 'ALLOW',
-        force: false,
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.force.bindValue = true;
-      flushAsynchronousOperations();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('already existing edit rule', () => {
-    setup(done => {
-      element.group = 'Group Name';
-      element.permission = 'editTopicName';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: true,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      flushAsynchronousOperations();
-      flush(() => {
-        element.attached();
-        done();
-      });
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.isTrue(element.$.force.classList.contains('force'));
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          dom(element.root).querySelector('#force').bindValue,
-          element.rule.value.force);
-      assert.isNotOk(dom(element.root).querySelector('#labelMin'));
-      assert.isNotOk(dom(element.root).querySelector('#labelMax'));
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
new file mode 100644
index 0000000..9364a50
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
@@ -0,0 +1,605 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-rule-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-rule-editor');
+
+suite('gr-rule-editor tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('unit tests', () => {
+    test('_computeForce, _computeForceClass, and _computeForceOptions',
+        () => {
+          const ForcePushOptions = {
+            ALLOW: [
+              {name: 'Allow pushing (but not force pushing)', value: false},
+              {name: 'Allow pushing with or without force', value: true},
+            ],
+            BLOCK: [
+              {name: 'Block pushing with or without force', value: false},
+              {name: 'Block force pushing', value: true},
+            ],
+          };
+
+          const FORCE_EDIT_OPTIONS = [
+            {
+              name: 'No Force Edit',
+              value: false,
+            },
+            {
+              name: 'Force Edit',
+              value: true,
+            },
+          ];
+          let permission = 'push';
+          let action = 'ALLOW';
+          assert.isTrue(element._computeForce(permission, action));
+          assert.equal(element._computeForceClass(permission, action),
+              'force');
+          assert.deepEqual(element._computeForceOptions(permission, action),
+              ForcePushOptions.ALLOW);
+
+          action = 'BLOCK';
+          assert.isTrue(element._computeForce(permission, action));
+          assert.equal(element._computeForceClass(permission, action),
+              'force');
+          assert.deepEqual(element._computeForceOptions(permission, action),
+              ForcePushOptions.BLOCK);
+
+          action = 'DENY';
+          assert.isFalse(element._computeForce(permission, action));
+          assert.equal(element._computeForceClass(permission, action), '');
+          assert.equal(
+              element._computeForceOptions(permission, action).length, 0);
+
+          permission = 'editTopicName';
+          assert.isTrue(element._computeForce(permission));
+          assert.equal(element._computeForceClass(permission), 'force');
+          assert.deepEqual(element._computeForceOptions(permission),
+              FORCE_EDIT_OPTIONS);
+          permission = 'submit';
+          assert.isFalse(element._computeForce(permission));
+          assert.equal(element._computeForceClass(permission), '');
+          assert.deepEqual(element._computeForceOptions(permission), []);
+        });
+
+    test('_computeSectionClass', () => {
+      let deleted = true;
+      let editing = false;
+      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
+
+      deleted = false;
+      assert.equal(element._computeSectionClass(editing, deleted), '');
+
+      editing = true;
+      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+
+      deleted = true;
+      assert.equal(element._computeSectionClass(editing, deleted),
+          'editing deleted');
+    });
+
+    test('_getDefaultRuleValues', () => {
+      let permission = 'priority';
+      let label;
+      assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'BATCH'});
+      permission = 'label-Code-Review';
+      label = {values: [
+        {value: -2, text: 'This shall not be merged'},
+        {value: -1, text: 'I would prefer this is not merged as is'},
+        {value: -0, text: 'No score'},
+        {value: 1, text: 'Looks good to me, but someone else must approve'},
+        {value: 2, text: 'Looks good to me, approved'},
+      ]};
+      assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'ALLOW', max: 2, min: -2});
+      permission = 'push';
+      label = undefined;
+      assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'ALLOW', force: false});
+      permission = 'submit';
+      assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'ALLOW'});
+    });
+
+    test('_setDefaultRuleValues', () => {
+      element.rule = {id: 123};
+      const defaultValue = {action: 'ALLOW'};
+      sinon.stub(element, '_getDefaultRuleValues').returns(defaultValue);
+      element._setDefaultRuleValues();
+      assert.isTrue(element._getDefaultRuleValues.called);
+      assert.equal(element.rule.value, defaultValue);
+    });
+
+    test('_computeOptions', () => {
+      const PRIORITY_OPTIONS = [
+        'BATCH',
+        'INTERACTIVE',
+      ];
+      const DROPDOWN_OPTIONS = [
+        'ALLOW',
+        'DENY',
+        'BLOCK',
+      ];
+      let permission = 'priority';
+      assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
+      permission = 'submit';
+      assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
+    });
+
+    test('_handleValueChange', () => {
+      const modifiedHandler = sinon.stub();
+      element.rule = {value: {}};
+      element.addEventListener('access-modified', modifiedHandler);
+      element._handleValueChange();
+      assert.isNotOk(element.rule.value.modified);
+      element._originalRuleValues = {};
+      element._handleValueChange();
+      assert.isTrue(element.rule.value.modified);
+      assert.isTrue(modifiedHandler.called);
+    });
+
+    test('_handleAccessSaved', () => {
+      const originalValue = {action: 'DENY'};
+      const newValue = {action: 'ALLOW'};
+      element._originalRuleValues = originalValue;
+      element.rule = {value: newValue};
+      element._handleAccessSaved();
+      assert.deepEqual(element._originalRuleValues, newValue);
+    });
+
+    test('_setOriginalRuleValues', () => {
+      const value = {
+        action: 'ALLOW',
+        force: false,
+      };
+      element._setOriginalRuleValues(value);
+      assert.deepEqual(element._originalRuleValues, value);
+    });
+  });
+
+  suite('already existing generic rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'submit';
+      element.rule = {
+        id: '123',
+        value: {
+          action: 'ALLOW',
+          force: false,
+        },
+      };
+      element.section = 'refs/*';
+
+      // Typically called on ready since elements will have properies defined
+      // by the parent element.
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(element.$.action.bindValue, element.rule.value.action);
+      assert.isNotOk(dom(element.root).querySelector('#labelMin'));
+      assert.isNotOk(dom(element.root).querySelector('#labelMax'));
+      assert.isFalse(element.$.force.classList.contains('force'));
+    });
+
+    test('modify and cancel restores original values', () => {
+      element.editing = true;
+      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = 'DENY';
+      assert.isTrue(element.rule.value.modified);
+      element.editing = false;
+      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+      assert.equal(element.$.action.bindValue, 'ALLOW');
+      assert.isNotOk(element.rule.value.modified);
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = 'DENY';
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('all selects are disabled when not in edit mode', () => {
+      const selects = dom(element.root).querySelectorAll('select');
+      for (const select of selects) {
+        assert.isTrue(select.disabled);
+      }
+      element.editing = true;
+      for (const select of selects) {
+        assert.isFalse(select.disabled);
+      }
+    });
+
+    test('remove rule and undo remove', () => {
+      element.editing = true;
+      element.rule = {id: 123, value: {action: 'ALLOW'}};
+      assert.isFalse(
+          element.$.deletedContainer.classList.contains('deleted'));
+      MockInteractions.tap(element.$.removeBtn);
+      assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
+      assert.isTrue(element._deleted);
+      assert.isTrue(element.rule.value.deleted);
+
+      MockInteractions.tap(element.$.undoRemoveBtn);
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.rule.value.deleted);
+    });
+
+    test('remove rule and cancel', () => {
+      element.editing = true;
+      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.equal(getComputedStyle(element.$.deletedContainer).display,
+          'none');
+
+      element.rule = {id: 123, value: {action: 'ALLOW'}};
+      MockInteractions.tap(element.$.removeBtn);
+      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.notEqual(getComputedStyle(element.$.deletedContainer).display,
+          'none');
+      assert.isTrue(element._deleted);
+      assert.isTrue(element.rule.value.deleted);
+
+      element.editing = false;
+      assert.isFalse(element._deleted);
+      assert.isNotOk(element.rule.value.deleted);
+      assert.isNotOk(element.rule.value.modified);
+
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
+      assert.equal(getComputedStyle(element.$.deletedContainer).display,
+          'none');
+    });
+
+    test('_computeGroupPath', () => {
+      const group = '123';
+      assert.equal(element._computeGroupPath(group),
+          `/admin/groups/123`);
+    });
+  });
+
+  suite('new edit rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'editTopicName';
+      element.rule = {
+        id: '123',
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      element.rule.value.added = true;
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule.value.modified);
+      const expectedRuleValue = {
+        action: 'ALLOW',
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
+        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.force.bindValue = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('remove value', () => {
+      element.editing = true;
+      const removeStub = sinon.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      MockInteractions.tap(element.$.removeBtn);
+      flushAsynchronousOperations();
+      assert.isTrue(removeStub.called);
+    });
+  });
+
+  suite('already existing rule with labels', () => {
+    setup(done => {
+      element.label = {values: [
+        {value: -2, text: 'This shall not be merged'},
+        {value: -1, text: 'I would prefer this is not merged as is'},
+        {value: -0, text: 'No score'},
+        {value: 1, text: 'Looks good to me, but someone else must approve'},
+        {value: 2, text: 'Looks good to me, approved'},
+      ]};
+      element.group = 'Group Name';
+      element.permission = 'label-Code-Review';
+      element.rule = {
+        id: '123',
+        value: {
+          action: 'ALLOW',
+          force: false,
+          max: 2,
+          min: -2,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(element.$.action.bindValue, element.rule.value.action);
+      assert.equal(
+          dom(element.root).querySelector('#labelMin').bindValue,
+          element.rule.value.min);
+      assert.equal(
+          dom(element.root).querySelector('#labelMax').bindValue,
+          element.rule.value.max);
+      assert.isFalse(element.$.force.classList.contains('force'));
+    });
+
+    test('modify value', () => {
+      const removeStub = sinon.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      assert.isNotOk(element.rule.value.modified);
+      dom(element.root).querySelector('#labelMin').bindValue = 1;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+      assert.isFalse(removeStub.called);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('new rule with labels', () => {
+    setup(done => {
+      sinon.spy(element, '_setDefaultRuleValues');
+      element.label = {values: [
+        {value: -2, text: 'This shall not be merged'},
+        {value: -1, text: 'I would prefer this is not merged as is'},
+        {value: -0, text: 'No score'},
+        {value: 1, text: 'Looks good to me, but someone else must approve'},
+        {value: 2, text: 'Looks good to me, approved'},
+      ]};
+      element.group = 'Group Name';
+      element.permission = 'label-Code-Review';
+      element.rule = {
+        id: '123',
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      element.rule.value.added = true;
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule.value.modified);
+      assert.isTrue(element._setDefaultRuleValues.called);
+
+      const expectedRuleValue = {
+        max: element.label.values[element.label.values.length - 1].value,
+        min: element.label.values[0].value,
+        action: 'ALLOW',
+        added: true,
+      };
+      assert.deepEqual(element.rule.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(
+            element.$.action.bindValue,
+            expectedRuleValue.action);
+        assert.equal(
+            dom(element.root).querySelector('#labelMin').bindValue,
+            expectedRuleValue.min);
+        assert.equal(
+            dom(element.root).querySelector('#labelMax').bindValue,
+            expectedRuleValue.max);
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      dom(element.root).querySelector('#labelMin').bindValue = 1;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('already existing push rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'push';
+      element.rule = {
+        id: '123',
+        value: {
+          action: 'ALLOW',
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(element.$.force.classList.contains('force'));
+      assert.equal(element.$.action.bindValue, element.rule.value.action);
+      assert.equal(
+          dom(element.root).querySelector('#force').bindValue,
+          element.rule.value.force);
+      assert.isNotOk(dom(element.root).querySelector('#labelMin'));
+      assert.isNotOk(dom(element.root).querySelector('#labelMax'));
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('new push rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'push';
+      element.rule = {
+        id: '123',
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      element.rule.value.added = true;
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule.value.modified);
+      const expectedRuleValue = {
+        action: 'ALLOW',
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule.value, expectedRuleValue);
+      test('values are set correctly', () => {
+        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
+        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
+      });
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.force.bindValue = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('already existing edit rule', () => {
+    setup(done => {
+      element.group = 'Group Name';
+      element.permission = 'editTopicName';
+      element.rule = {
+        id: '123',
+        value: {
+          action: 'ALLOW',
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element._setupValues(element.rule);
+      flushAsynchronousOperations();
+      flush(() => {
+        element.attached();
+        done();
+      });
+    });
+
+    test('_ruleValues and _originalRuleValues are set correctly', () => {
+      assert.deepEqual(element._originalRuleValues, element.rule.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(element.$.force.classList.contains('force'));
+      assert.equal(element.$.action.bindValue, element.rule.value.action);
+      assert.equal(
+          dom(element.root).querySelector('#force').bindValue,
+          element.rule.value.force);
+      assert.isNotOk(dom(element.root).querySelector('#labelMin'));
+      assert.isNotOk(dom(element.root).querySelector('#labelMax'));
+    });
+
+    test('modify value', () => {
+      assert.isNotOk(element.rule.value.modified);
+      element.$.action.bindValue = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.rule.value.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
index da13492..c0ce378 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 
-import '../../../scripts/bundled-polymer.js';
 import '../../../styles/gr-change-list-styles.js';
 import '../../shared/gr-account-link/gr-account-link.js';
 import '../../shared/gr-change-star/gr-change-star.js';
@@ -27,19 +26,18 @@
 import '../../../styles/shared-styles.js';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-change-list-item_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {ChangeTableBehavior} from '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
-import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
-import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {getDisplayName} from '../../../utils/display-name-util.js';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {appContext} from '../../../services/app-context.js';
+import {truncatePath} from '../../../utils/path-list-util.js';
+import {changeStatuses} from '../../../utils/change-util.js';
 
 const CHANGE_SIZE = {
   XS: 10,
@@ -48,25 +46,25 @@
   LARGE: 1000,
 };
 
+// How many reviewers should be shown with an account-label?
+const PRIMARY_REVIEWERS_COUNT = 2;
+
 /**
- * @appliesMixin RESTClientMixin
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrChangeListItem extends mixinBehaviors( [
-  BaseUrlBehavior,
-  ChangeTableBehavior,
-  PathListBehavior,
-  RESTClientBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrChangeListItem extends ChangeTableMixin(GestureEventListeners(
+    LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-change-list-item'; }
 
   static get properties() {
     return {
+      /** The logged-in user's account, or null if no user is logged in. */
+      account: {
+        type: Object,
+        value: null,
+      },
       visibleChangeTableColumns: Array,
       labelNames: {
         type: Array,
@@ -74,13 +72,16 @@
 
       /** @type {?} */
       change: Object,
+      config: Object,
+      /** Name of the section in the change-list. Used for reporting. */
+      sectionName: String,
       changeURL: {
         type: String,
         computed: '_computeChangeURL(change)',
       },
       statuses: {
         type: Array,
-        computed: 'changeStatuses(change)',
+        computed: '_changeStatuses(change)',
       },
       showStar: {
         type: Boolean,
@@ -97,15 +98,24 @@
     };
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   /** @override */
   attached() {
     super.attached();
     pluginLoader.awaitPluginsLoaded().then(() => {
-      this._dynamicCellEndpoints = pluginEndpoints.getDynamicEndpoints(
+      this._dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
           'change-list-item-cell');
     });
   }
 
+  _changeStatuses(change) {
+    return changeStatuses(change);
+  }
+
   _computeChangeURL(change) {
     return GerritNav.getUrlForChange(change);
   }
@@ -195,7 +205,7 @@
     if (!change || !change.project) { return ''; }
     let str = '';
     if (change.internalHost) { str += change.internalHost + '/'; }
-    str += truncate ? this.truncatePath(change.project, 2) : change.project;
+    str += truncate ? truncatePath(change.project, 2) : change.project;
     return str;
   }
 
@@ -204,10 +214,55 @@
         isNaN(change.insertions + change.deletions)) {
       return 'Size unknown';
     } else {
-      return `+${change.insertions}, -${change.deletions}`;
+      return `added ${change.insertions}, removed ${change.deletions} lines`;
     }
   }
 
+  _hasAttention(account) {
+    if (!this.change || !this.change.attention_set) return false;
+    return this.change.attention_set.hasOwnProperty(account._account_id);
+  }
+
+  /**
+   * Computes the array of all reviewers with sorting the reviewers in the
+   * attention set before others, and the current user first.
+   */
+  _computeReviewers(change) {
+    if (!change || !change.reviewers || !change.reviewers.REVIEWER) return [];
+    const reviewers = [...change.reviewers.REVIEWER].filter(r =>
+      !change.owner || change.owner._account_id !== r._account_id
+    );
+    reviewers.sort((r1, r2) => {
+      if (this.account) {
+        if (r1._account_id === this.account._account_id) return -1;
+        if (r2._account_id === this.account._account_id) return 1;
+      }
+      if (this._hasAttention(r1) && !this._hasAttention(r2)) return -1;
+      if (this._hasAttention(r2) && !this._hasAttention(r1)) return 1;
+      return (r1.name || '').localeCompare(r2.name || '');
+    });
+    return reviewers;
+  }
+
+  _computePrimaryReviewers(change) {
+    return this._computeReviewers(change).slice(0, PRIMARY_REVIEWERS_COUNT);
+  }
+
+  _computeAdditionalReviewers(change) {
+    return this._computeReviewers(change).slice(PRIMARY_REVIEWERS_COUNT);
+  }
+
+  _computeAdditionalReviewersCount(change) {
+    return this._computeAdditionalReviewers(change).length;
+  }
+
+  _computeAdditionalReviewersTitle(change, config) {
+    if (!change || !config) return '';
+    return this._computeAdditionalReviewers(change)
+        .map(user => getDisplayName(config, user))
+        .join(', ');
+  }
+
   _computeComments(unresolved_comment_count) {
     if (!unresolved_comment_count || unresolved_comment_count < 1) return '';
     return `${unresolved_comment_count} unresolved`;
@@ -244,6 +299,20 @@
       detail: {change: this.change, reviewed: newVal},
     }));
   }
+
+  _handleChangeClick(e) {
+    // Don't prevent the default and neither stop bubbling. We just want to
+    // report the click, but then let the browser handle the click on the link.
+
+    const selfId = (this.account && this.account._account_id) || -1;
+    const ownerId = (this.change && this.change.owner
+        && this.change.owner._account_id) || -1;
+
+    this.reporting.reportInteraction('change-row-clicked', {
+      section: this.sectionName,
+      isOwner: selfId === ownerId,
+    });
+  }
 }
 
 customElements.define(GrChangeListItem.is, GrChangeListItem);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
deleted file mode 100644
index 13b3c24..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.js
+++ /dev/null
@@ -1,278 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: table-row;
-      color: var(--primary-text-color);
-    }
-    :host(:focus) {
-      outline: none;
-    }
-    :host(:hover) {
-      background-color: var(--hover-background-color);
-    }
-    :host([needs-review]) {
-      font-weight: var(--font-weight-bold);
-      color: var(--primary-text-color);
-    }
-    :host([highlight]) {
-      background-color: var(--assignee-highlight-color);
-    }
-    .container {
-      position: relative;
-    }
-    .content {
-      overflow: hidden;
-      position: absolute;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-      width: 100%;
-    }
-    .content a {
-      display: block;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-      width: 100%;
-    }
-    .comments,
-    .reviewers {
-      white-space: nowrap;
-    }
-    .spacer {
-      height: 0;
-      overflow: hidden;
-    }
-    .status {
-      align-items: center;
-      display: inline-flex;
-    }
-    .status .comma {
-      padding-right: var(--spacing-xs);
-    }
-    /* Used to hide the leading separator comma for statuses. */
-    .status .comma:first-of-type {
-      display: none;
-    }
-    .size gr-tooltip-content {
-      margin: -0.4rem -0.6rem;
-      max-width: 2.5rem;
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    a {
-      color: inherit;
-      cursor: pointer;
-      text-decoration: none;
-    }
-    a:hover {
-      text-decoration: underline;
-    }
-    .u-monospace {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-    }
-    .u-green {
-      color: var(--vote-text-color-recommended);
-    }
-    .u-red {
-      color: var(--vote-text-color-disliked);
-    }
-    .u-gray-background {
-      background-color: var(--table-header-background-color);
-    }
-    .comma,
-    .placeholder {
-      color: var(--deemphasized-text-color);
-    }
-    .cell.label {
-      font-weight: var(--font-weight-normal);
-    }
-    .lastChildHidden:last-of-type {
-      display: none;
-    }
-    @media only screen and (max-width: 50em) {
-      :host {
-        display: flex;
-      }
-    }
-  </style>
-  <style include="gr-change-list-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <td class="cell leftPadding"></td>
-  <td class="cell star" hidden$="[[!showStar]]" hidden="">
-    <gr-change-star change="{{change}}"></gr-change-star>
-  </td>
-  <td class="cell number" hidden$="[[!showNumber]]" hidden="">
-    <a href$="[[changeURL]]">[[change._number]]</a>
-  </td>
-  <td
-    class="cell subject"
-    hidden$="[[isColumnHidden('Subject', visibleChangeTableColumns)]]"
-  >
-    <div class="container">
-      <div class="content">
-        <a title$="[[change.subject]]" href$="[[changeURL]]">
-          [[change.subject]]
-        </a>
-      </div>
-      <div class="spacer">
-        [[change.subject]]
-      </div>
-      <span>&nbsp;</span>
-    </div>
-  </td>
-  <td
-    class="cell status"
-    hidden$="[[isColumnHidden('Status', visibleChangeTableColumns)]]"
-  >
-    <template is="dom-repeat" items="[[statuses]]" as="status">
-      <div class="comma">,</div>
-      <gr-change-status flat="" status="[[status]]"></gr-change-status>
-    </template>
-    <template is="dom-if" if="[[!statuses.length]]">
-      <span class="placeholder">--</span>
-    </template>
-  </td>
-  <td
-    class="cell owner"
-    hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]"
-  >
-    <gr-account-link account="[[change.owner]]"></gr-account-link>
-  </td>
-  <td
-    class="cell assignee"
-    hidden$="[[isColumnHidden('Assignee', visibleChangeTableColumns)]]"
-  >
-    <template is="dom-if" if="[[change.assignee]]">
-      <gr-account-link
-        id="assigneeAccountLink"
-        account="[[change.assignee]]"
-      ></gr-account-link>
-    </template>
-    <template is="dom-if" if="[[!change.assignee]]">
-      <span class="placeholder">--</span>
-    </template>
-  </td>
-  <td
-    class="cell reviewers"
-    hidden$="[[isColumnHidden('Reviewers', visibleChangeTableColumns)]]"
-  >
-    <div>
-      <template
-        is="dom-repeat"
-        items="[[change.reviewers.REVIEWER]]"
-        as="reviewer"
-      >
-        <gr-account-link
-          hide-avatar=""
-          hide-status=""
-          account="[[reviewer]]"
-        ></gr-account-link
-        ><!--
-       --><span class="lastChildHidden">, </span>
-      </template>
-    </div>
-  </td>
-  <td
-    class="cell comments"
-    hidden$="[[isColumnHidden('Comments', visibleChangeTableColumns)]]"
-  >
-    <iron-icon
-      hidden$="[[!change.unresolved_comment_count]]"
-      icon="gr-icons:comment"
-    ></iron-icon>
-    <span>[[_computeComments(change.unresolved_comment_count)]]</span>
-  </td>
-  <td
-    class="cell repo"
-    hidden$="[[isColumnHidden('Repo', visibleChangeTableColumns)]]"
-  >
-    <a class="fullRepo" href$="[[_computeRepoUrl(change)]]">
-      [[_computeRepoDisplay(change)]]
-    </a>
-    <a
-      class="truncatedRepo"
-      href$="[[_computeRepoUrl(change)]]"
-      title$="[[_computeRepoDisplay(change)]]"
-    >
-      [[_computeRepoDisplay(change, 'true')]]
-    </a>
-  </td>
-  <td
-    class="cell branch"
-    hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]"
-  >
-    <a href$="[[_computeRepoBranchURL(change)]]">
-      [[change.branch]]
-    </a>
-    <template is="dom-if" if="[[change.topic]]">
-      (<a href$="[[_computeTopicURL(change)]]"
-        ><!--
-       --><gr-limited-text limit="50" text="[[change.topic]]"> </gr-limited-text
-        ><!--
-     --></a
-      >)
-    </template>
-  </td>
-  <td
-    class="cell updated"
-    hidden$="[[isColumnHidden('Updated', visibleChangeTableColumns)]]"
-  >
-    <gr-date-formatter
-      has-tooltip=""
-      date-str="[[change.updated]]"
-    ></gr-date-formatter>
-  </td>
-  <td
-    class="cell size"
-    hidden$="[[isColumnHidden('Size', visibleChangeTableColumns)]]"
-  >
-    <gr-tooltip-content has-tooltip="" title="[[_computeSizeTooltip(change)]]">
-      <template is="dom-if" if="[[_changeSize]]">
-        <span>[[_changeSize]]</span>
-      </template>
-      <template is="dom-if" if="[[!_changeSize]]">
-        <span class="placeholder">--</span>
-      </template>
-    </gr-tooltip-content>
-  </td>
-  <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-    <td
-      title$="[[_computeLabelTitle(change, labelName)]]"
-      class$="[[_computeLabelClass(change, labelName)]]"
-    >
-      [[_computeLabelValue(change, labelName)]]
-    </td>
-  </template>
-  <template
-    is="dom-repeat"
-    items="[[_dynamicCellEndpoints]]"
-    as="pluginEndpointName"
-  >
-    <td class="cell endpoint">
-      <gr-endpoint-decorator name$="[[pluginEndpointName]]">
-        <gr-endpoint-param name="change" value="[[change]]">
-        </gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </td>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
new file mode 100644
index 0000000..7fa59d4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -0,0 +1,295 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: table-row;
+      color: var(--primary-text-color);
+    }
+    :host(:focus) {
+      outline: none;
+    }
+    :host(:hover) {
+      background-color: var(--hover-background-color);
+    }
+    :host([needs-review]) {
+      font-weight: var(--font-weight-bold);
+      color: var(--primary-text-color);
+    }
+    :host([highlight]) {
+      background-color: var(--assignee-highlight-color);
+    }
+    .container {
+      position: relative;
+    }
+    .content {
+      overflow: hidden;
+      position: absolute;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      width: 100%;
+    }
+    .content a {
+      display: block;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      width: 100%;
+    }
+    .comments,
+    .reviewers {
+      white-space: nowrap;
+    }
+    .reviewers {
+      --account-max-length: 90px;
+    }
+    .spacer {
+      height: 0;
+      overflow: hidden;
+    }
+    .status {
+      align-items: center;
+      display: inline-flex;
+    }
+    .status .comma {
+      padding-right: var(--spacing-xs);
+    }
+    /* Used to hide the leading separator comma for statuses. */
+    .status .comma:first-of-type {
+      display: none;
+    }
+    .size gr-tooltip-content {
+      margin: -0.4rem -0.6rem;
+      max-width: 2.5rem;
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    a {
+      color: inherit;
+      cursor: pointer;
+      text-decoration: none;
+    }
+    a:hover {
+      text-decoration: underline;
+    }
+    .u-monospace {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+    }
+    .u-green {
+      color: var(--positive-green-text-color);
+    }
+    .u-red {
+      color: var(--negative-red-text-color);
+    }
+    .u-gray-background {
+      background-color: var(--table-header-background-color);
+    }
+    .comma,
+    .placeholder {
+      color: var(--deemphasized-text-color);
+    }
+    .cell.label {
+      font-weight: var(--font-weight-normal);
+    }
+    .lastChildHidden:last-of-type {
+      display: none;
+    }
+    @media only screen and (max-width: 50em) {
+      :host {
+        display: flex;
+      }
+    }
+  </style>
+  <style include="gr-change-list-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <td aria-hidden="true" class="cell leftPadding"></td>
+  <td class="cell star" hidden$="[[!showStar]]" hidden="">
+    <gr-change-star change="{{change}}"></gr-change-star>
+  </td>
+  <td class="cell number" hidden$="[[!showNumber]]" hidden="">
+    <a href$="[[changeURL]]">[[change._number]]</a>
+  </td>
+  <td
+    class="cell subject"
+    hidden$="[[isColumnHidden('Subject', visibleChangeTableColumns)]]"
+  >
+    <div class="container">
+      <div class="content">
+        <a
+          title$="[[change.subject]]"
+          href$="[[changeURL]]"
+          on-click="_handleChangeClick"
+        >
+          [[change.subject]]
+        </a>
+      </div>
+      <div class="spacer">
+        [[change.subject]]
+      </div>
+      <span>&nbsp;</span>
+    </div>
+  </td>
+  <td
+    class="cell status"
+    hidden$="[[isColumnHidden('Status', visibleChangeTableColumns)]]"
+  >
+    <template is="dom-repeat" items="[[statuses]]" as="status">
+      <div class="comma">,</div>
+      <gr-change-status flat="" status="[[status]]"></gr-change-status>
+    </template>
+    <template is="dom-if" if="[[!statuses.length]]">
+      <span class="placeholder">--</span>
+    </template>
+  </td>
+  <td
+    class="cell owner"
+    hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]"
+  >
+    <gr-account-link
+      highlight-attention
+      change="[[change]]"
+      account="[[change.owner]]"
+    ></gr-account-link>
+  </td>
+  <td
+    class="cell assignee"
+    hidden$="[[isColumnHidden('Assignee', visibleChangeTableColumns)]]"
+  >
+    <template is="dom-if" if="[[change.assignee]]">
+      <gr-account-link
+        id="assigneeAccountLink"
+        account="[[change.assignee]]"
+      ></gr-account-link>
+    </template>
+    <template is="dom-if" if="[[!change.assignee]]">
+      <span class="placeholder">--</span>
+    </template>
+  </td>
+  <td
+    class="cell reviewers"
+    hidden$="[[isColumnHidden('Reviewers', visibleChangeTableColumns)]]"
+  >
+    <div>
+      <template
+        is="dom-repeat"
+        items="[[_computePrimaryReviewers(change)]]"
+        as="reviewer"
+      >
+        <gr-account-link
+          hide-avatar=""
+          hide-status=""
+          highlight-attention
+          change="[[change]]"
+          account="[[reviewer]]"
+        ></gr-account-link
+        ><span class="lastChildHidden" aria-hidden="true">, </span>
+      </template>
+      <template is="dom-if" if="[[_computeAdditionalReviewersCount(change)]]">
+        <span title="[[_computeAdditionalReviewersTitle(change, config)]]">
+          +[[_computeAdditionalReviewersCount(change, config)]]
+        </span>
+      </template>
+    </div>
+  </td>
+  <td
+    class="cell comments"
+    hidden$="[[isColumnHidden('Comments', visibleChangeTableColumns)]]"
+  >
+    <iron-icon
+      hidden$="[[!change.unresolved_comment_count]]"
+      icon="gr-icons:comment"
+    ></iron-icon>
+    <span>[[_computeComments(change.unresolved_comment_count)]]</span>
+  </td>
+  <td
+    class="cell repo"
+    hidden$="[[isColumnHidden('Repo', visibleChangeTableColumns)]]"
+  >
+    <a class="fullRepo" href$="[[_computeRepoUrl(change)]]">
+      [[_computeRepoDisplay(change)]]
+    </a>
+    <a
+      class="truncatedRepo"
+      href$="[[_computeRepoUrl(change)]]"
+      title$="[[_computeRepoDisplay(change)]]"
+    >
+      [[_computeRepoDisplay(change, 'true')]]
+    </a>
+  </td>
+  <td
+    class="cell branch"
+    hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]"
+  >
+    <a href$="[[_computeRepoBranchURL(change)]]">
+      [[change.branch]]
+    </a>
+    <template is="dom-if" if="[[change.topic]]">
+      (<a href$="[[_computeTopicURL(change)]]"
+        ><!--
+       --><gr-limited-text limit="50" text="[[change.topic]]"> </gr-limited-text
+        ><!--
+     --></a
+      >)
+    </template>
+  </td>
+  <td
+    class="cell updated"
+    hidden$="[[isColumnHidden('Updated', visibleChangeTableColumns)]]"
+  >
+    <gr-date-formatter
+      has-tooltip=""
+      date-str="[[change.updated]]"
+    ></gr-date-formatter>
+  </td>
+  <td
+    class="cell size"
+    hidden$="[[isColumnHidden('Size', visibleChangeTableColumns)]]"
+  >
+    <gr-tooltip-content has-tooltip="" title="[[_computeSizeTooltip(change)]]">
+      <template is="dom-if" if="[[_changeSize]]">
+        <span>[[_changeSize]]</span>
+      </template>
+      <template is="dom-if" if="[[!_changeSize]]">
+        <span class="placeholder">--</span>
+      </template>
+    </gr-tooltip-content>
+  </td>
+  <template is="dom-repeat" items="[[labelNames]]" as="labelName">
+    <td
+      title$="[[_computeLabelTitle(change, labelName)]]"
+      class$="[[_computeLabelClass(change, labelName)]]"
+    >
+      [[_computeLabelValue(change, labelName)]]
+    </td>
+  </template>
+  <template
+    is="dom-repeat"
+    items="[[_dynamicCellEndpoints]]"
+    as="pluginEndpointName"
+  >
+    <td class="cell endpoint">
+      <gr-endpoint-decorator name$="[[pluginEndpointName]]">
+        <gr-endpoint-param name="change" value="[[change]]">
+        </gr-endpoint-param>
+      </gr-endpoint-decorator>
+    </td>
+  </template>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
deleted file mode 100644
index 6b45618..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
+++ /dev/null
@@ -1,281 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-change-list-item</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-list-item></gr-change-list-item>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-change-list-item.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-change-list-item tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
-    element = fixture('basic');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('computed fields', () => {
-    assert.equal(element._computeLabelClass({labels: {}}),
-        'cell label u-gray-background');
-    assert.equal(element._computeLabelClass(
-        {labels: {}}, 'Verified'), 'cell label u-gray-background');
-    assert.equal(element._computeLabelClass(
-        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
-    'cell label u-green u-monospace');
-    assert.equal(element._computeLabelClass(
-        {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
-    'cell label u-monospace u-red');
-    assert.equal(element._computeLabelClass(
-        {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
-    'cell label u-green u-monospace');
-    assert.equal(element._computeLabelClass(
-        {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
-    'cell label u-monospace u-red');
-    assert.equal(element._computeLabelClass(
-        {labels: {'Code-Review': {value: -1}}}, 'Verified'),
-    'cell label u-gray-background');
-
-    assert.equal(element._computeLabelTitle({labels: {}}, 'Verified'),
-        'Label not applicable');
-    assert.equal(element._computeLabelTitle(
-        {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Verified'),
-    'Verified\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Code-Review'),
-    'Label not applicable');
-    assert.equal(element._computeLabelTitle(
-        {labels: {Verified: {rejected: {name: 'Diffy'}}}}, 'Verified'),
-    'Verified\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}}},
-        'Code-Review'), 'Code-Review\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}}},
-        'Code-Review'), 'Code-Review\nby Diffy');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-          rejected: {name: 'Admin'}}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {approved: {name: 'Diffy'},
-          rejected: {name: 'Admin'}}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-    'Code-Review\nby Admin');
-    assert.equal(element._computeLabelTitle(
-        {labels: {'Code-Review': {approved: {name: 'Diffy'},
-          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
-    'Code-Review\nby Diffy');
-
-    assert.equal(element._computeLabelValue({labels: {}}), '');
-    assert.equal(element._computeLabelValue({labels: {}}, 'Verified'), '');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'), '✓');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {value: 1}}}, 'Verified'), '+1');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {value: -1}}}, 'Verified'), '-1');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {approved: true}}}, 'Verified'), '✓');
-    assert.equal(element._computeLabelValue(
-        {labels: {Verified: {rejected: true}}}, 'Verified'), '✕');
-  });
-
-  test('no hidden columns', () => {
-    element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-
-    flushAsynchronousOperations();
-
-    for (const column of element.columnNames) {
-      const elementClass = '.' + column.toLowerCase();
-      assert.isOk(element.shadowRoot
-          .querySelector(elementClass),
-      `Expect ${elementClass} element to be found`);
-      assert.isFalse(element.shadowRoot
-          .querySelector(elementClass).hidden);
-    }
-  });
-
-  test('repo column hidden', () => {
-    element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-
-    flushAsynchronousOperations();
-
-    for (const column of element.columnNames) {
-      const elementClass = '.' + column.toLowerCase();
-      if (column === 'Repo') {
-        assert.isTrue(element.shadowRoot
-            .querySelector(elementClass).hidden);
-      } else {
-        assert.isFalse(element.shadowRoot
-            .querySelector(elementClass).hidden);
-      }
-    }
-  });
-
-  test('random column does not exist', () => {
-    element.visibleChangeTableColumns = [
-      'Bad',
-    ];
-
-    flushAsynchronousOperations();
-    const elementClass = '.bad';
-    assert.isNotOk(element.shadowRoot
-        .querySelector(elementClass));
-  });
-
-  test('assignee only displayed if there is one', () => {
-    element.change = {};
-    flushAsynchronousOperations();
-    assert.isNotOk(element.shadowRoot
-        .querySelector('.assignee gr-account-link'));
-    assert.equal(element.shadowRoot
-        .querySelector('.assignee').textContent.trim(), '--');
-    element.change = {
-      assignee: {
-        name: 'test',
-        status: 'test',
-      },
-    };
-    flushAsynchronousOperations();
-    assert.isOk(element.shadowRoot
-        .querySelector('.assignee gr-account-link'));
-  });
-
-  test('TShirt sizing tooltip', () => {
-    assert.equal(element._computeSizeTooltip({
-      insertions: 'foo',
-      deletions: 'bar',
-    }), 'Size unknown');
-    assert.equal(element._computeSizeTooltip({
-      insertions: 0,
-      deletions: 0,
-    }), 'Size unknown');
-    assert.equal(element._computeSizeTooltip({
-      insertions: 1,
-      deletions: 2,
-    }), '+1, -2');
-  });
-
-  test('TShirt sizing', () => {
-    assert.equal(element._computeChangeSize({
-      insertions: 'foo',
-      deletions: 'bar',
-    }), null);
-    assert.equal(element._computeChangeSize({
-      insertions: 1,
-      deletions: 1,
-    }), 'XS');
-    assert.equal(element._computeChangeSize({
-      insertions: 9,
-      deletions: 1,
-    }), 'S');
-    assert.equal(element._computeChangeSize({
-      insertions: 10,
-      deletions: 200,
-    }), 'M');
-    assert.equal(element._computeChangeSize({
-      insertions: 99,
-      deletions: 900,
-    }), 'L');
-    assert.equal(element._computeChangeSize({
-      insertions: 99,
-      deletions: 999,
-    }), 'XL');
-  });
-
-  test('change params passed to gr-navigation', () => {
-    sandbox.stub(GerritNav);
-    const change = {
-      internalHost: 'test-host',
-      project: 'test-repo',
-      topic: 'test-topic',
-      branch: 'test-branch',
-    };
-    element.change = change;
-    flushAsynchronousOperations();
-
-    assert.deepEqual(GerritNav.getUrlForChange.lastCall.args, [change]);
-    assert.deepEqual(GerritNav.getUrlForProjectChanges.lastCall.args,
-        [change.project, true, change.internalHost]);
-    assert.deepEqual(GerritNav.getUrlForBranch.lastCall.args,
-        [change.branch, change.project, null, change.internalHost]);
-    assert.deepEqual(GerritNav.getUrlForTopic.lastCall.args,
-        [change.topic, change.internalHost]);
-  });
-
-  test('_computeRepoDisplay', () => {
-    const change = {
-      project: 'a/test/repo',
-      internalHost: 'host',
-    };
-    assert.equal(element._computeRepoDisplay(change), 'host/a/test/repo');
-    assert.equal(element._computeRepoDisplay(change, true),
-        'host/…/test/repo');
-    delete change.internalHost;
-    assert.equal(element._computeRepoDisplay(change), 'a/test/repo');
-    assert.equal(element._computeRepoDisplay(change, true),
-        '…/test/repo');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
new file mode 100644
index 0000000..6d51310
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.js
@@ -0,0 +1,300 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-list-item.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-change-list-item');
+
+suite('gr-change-list-item tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('computed fields', () => {
+    assert.equal(element._computeLabelClass({labels: {}}),
+        'cell label u-gray-background');
+    assert.equal(element._computeLabelClass(
+        {labels: {}}, 'Verified'), 'cell label u-gray-background');
+    assert.equal(element._computeLabelClass(
+        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
+    'cell label u-green u-monospace');
+    assert.equal(element._computeLabelClass(
+        {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
+    'cell label u-monospace u-red');
+    assert.equal(element._computeLabelClass(
+        {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
+    'cell label u-green u-monospace');
+    assert.equal(element._computeLabelClass(
+        {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
+    'cell label u-monospace u-red');
+    assert.equal(element._computeLabelClass(
+        {labels: {'Code-Review': {value: -1}}}, 'Verified'),
+    'cell label u-gray-background');
+
+    assert.equal(element._computeLabelTitle({labels: {}}, 'Verified'),
+        'Label not applicable');
+    assert.equal(element._computeLabelTitle(
+        {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Verified'),
+    'Verified\nby Diffy');
+    assert.equal(element._computeLabelTitle(
+        {labels: {Verified: {approved: {name: 'Diffy'}}}}, 'Code-Review'),
+    'Label not applicable');
+    assert.equal(element._computeLabelTitle(
+        {labels: {Verified: {rejected: {name: 'Diffy'}}}}, 'Verified'),
+    'Verified\nby Diffy');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {disliked: {name: 'Diffy'}, value: -1}}},
+        'Code-Review'), 'Code-Review\nby Diffy');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {recommended: {name: 'Diffy'}, value: 1}}},
+        'Code-Review'), 'Code-Review\nby Diffy');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {recommended: {name: 'Diffy'},
+          rejected: {name: 'Admin'}}}}, 'Code-Review'),
+    'Code-Review\nby Admin');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {approved: {name: 'Diffy'},
+          rejected: {name: 'Admin'}}}}, 'Code-Review'),
+    'Code-Review\nby Admin');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {recommended: {name: 'Diffy'},
+          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
+    'Code-Review\nby Admin');
+    assert.equal(element._computeLabelTitle(
+        {labels: {'Code-Review': {approved: {name: 'Diffy'},
+          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
+    'Code-Review\nby Diffy');
+
+    assert.equal(element._computeLabelValue({labels: {}}), '');
+    assert.equal(element._computeLabelValue({labels: {}}, 'Verified'), '');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {approved: true, value: 1}}}, 'Verified'), '✓');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {value: 1}}}, 'Verified'), '+1');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {value: -1}}}, 'Verified'), '-1');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {approved: true}}}, 'Verified'), '✓');
+    assert.equal(element._computeLabelValue(
+        {labels: {Verified: {rejected: true}}}, 'Verified'), '✕');
+  });
+
+  test('no hidden columns', () => {
+    element.visibleChangeTableColumns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Repo',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+
+    flushAsynchronousOperations();
+
+    for (const column of element.columnNames) {
+      const elementClass = '.' + column.toLowerCase();
+      assert.isOk(element.shadowRoot
+          .querySelector(elementClass),
+      `Expect ${elementClass} element to be found`);
+      assert.isFalse(element.shadowRoot
+          .querySelector(elementClass).hidden);
+    }
+  });
+
+  test('repo column hidden', () => {
+    element.visibleChangeTableColumns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+
+    flushAsynchronousOperations();
+
+    for (const column of element.columnNames) {
+      const elementClass = '.' + column.toLowerCase();
+      if (column === 'Repo') {
+        assert.isTrue(element.shadowRoot
+            .querySelector(elementClass).hidden);
+      } else {
+        assert.isFalse(element.shadowRoot
+            .querySelector(elementClass).hidden);
+      }
+    }
+  });
+
+  function checkComputeReviewers(
+      userId, reviewerIds, reviewerNames, attSetIds, expected) {
+    element.account = userId ? {_account_id: userId} : null;
+    element.change = {
+      owner: {
+        _account_id: 99,
+      },
+      reviewers: {
+        REVIEWER: [],
+      },
+      attention_set: {},
+    };
+    for (let i = 0; i < reviewerIds.length; i++) {
+      element.change.reviewers.REVIEWER.push({
+        _account_id: reviewerIds[i],
+        name: reviewerNames[i],
+      });
+    }
+    attSetIds.forEach(id => element.change.attention_set[id] = {});
+
+    const actual = element._computeReviewers(element.change)
+        .map(r => r._account_id);
+    assert.deepEqual(actual, expected);
+  }
+
+  test('compute reviewers', () => {
+    checkComputeReviewers(null, [], [], [], []);
+    checkComputeReviewers(1, [], [], [], []);
+    checkComputeReviewers(1, [2], ['a'], [], [2]);
+    checkComputeReviewers(1, [2, 3], [undefined, 'a'], [], [2, 3]);
+    checkComputeReviewers(1, [2, 3], ['a', undefined], [], [3, 2]);
+    checkComputeReviewers(1, [99], ['owner'], [], []);
+    checkComputeReviewers(
+        1, [2, 3, 4, 5], ['b', 'a', 'd', 'c'], [3, 4], [3, 4, 2, 5]);
+    checkComputeReviewers(
+        1, [2, 3, 1, 4, 5], ['b', 'a', 'x', 'd', 'c'], [3, 4], [1, 3, 4, 2, 5]);
+  });
+
+  test('random column does not exist', () => {
+    element.visibleChangeTableColumns = [
+      'Bad',
+    ];
+
+    flushAsynchronousOperations();
+    const elementClass = '.bad';
+    assert.isNotOk(element.shadowRoot
+        .querySelector(elementClass));
+  });
+
+  test('assignee only displayed if there is one', () => {
+    element.change = {};
+    flushAsynchronousOperations();
+    assert.isNotOk(element.shadowRoot
+        .querySelector('.assignee gr-account-link'));
+    assert.equal(element.shadowRoot
+        .querySelector('.assignee').textContent.trim(), '--');
+    element.change = {
+      assignee: {
+        name: 'test',
+        status: 'test',
+      },
+    };
+    flushAsynchronousOperations();
+    assert.isOk(element.shadowRoot
+        .querySelector('.assignee gr-account-link'));
+  });
+
+  test('TShirt sizing tooltip', () => {
+    assert.equal(element._computeSizeTooltip({
+      insertions: 'foo',
+      deletions: 'bar',
+    }), 'Size unknown');
+    assert.equal(element._computeSizeTooltip({
+      insertions: 0,
+      deletions: 0,
+    }), 'Size unknown');
+    assert.equal(element._computeSizeTooltip({
+      insertions: 1,
+      deletions: 2,
+    }), 'added 1, removed 2 lines');
+  });
+
+  test('TShirt sizing', () => {
+    assert.equal(element._computeChangeSize({
+      insertions: 'foo',
+      deletions: 'bar',
+    }), null);
+    assert.equal(element._computeChangeSize({
+      insertions: 1,
+      deletions: 1,
+    }), 'XS');
+    assert.equal(element._computeChangeSize({
+      insertions: 9,
+      deletions: 1,
+    }), 'S');
+    assert.equal(element._computeChangeSize({
+      insertions: 10,
+      deletions: 200,
+    }), 'M');
+    assert.equal(element._computeChangeSize({
+      insertions: 99,
+      deletions: 900,
+    }), 'L');
+    assert.equal(element._computeChangeSize({
+      insertions: 99,
+      deletions: 999,
+    }), 'XL');
+  });
+
+  test('change params passed to gr-navigation', () => {
+    sinon.stub(GerritNav);
+    const change = {
+      internalHost: 'test-host',
+      project: 'test-repo',
+      topic: 'test-topic',
+      branch: 'test-branch',
+    };
+    element.change = change;
+    flushAsynchronousOperations();
+
+    assert.deepEqual(GerritNav.getUrlForChange.lastCall.args, [change]);
+    assert.deepEqual(GerritNav.getUrlForProjectChanges.lastCall.args,
+        [change.project, true, change.internalHost]);
+    assert.deepEqual(GerritNav.getUrlForBranch.lastCall.args,
+        [change.branch, change.project, null, change.internalHost]);
+    assert.deepEqual(GerritNav.getUrlForTopic.lastCall.args,
+        [change.topic, change.internalHost]);
+  });
+
+  test('_computeRepoDisplay', () => {
+    const change = {
+      project: 'a/test/repo',
+      internalHost: 'host',
+    };
+    assert.equal(element._computeRepoDisplay(change), 'host/a/test/repo');
+    assert.equal(element._computeRepoDisplay(change, true),
+        'host/…/test/repo');
+    delete change.internalHost;
+    assert.equal(element._computeRepoDisplay(change), 'a/test/repo');
+    assert.equal(element._computeRepoDisplay(change, true),
+        '…/test/repo');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
index c416b11..953c917 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
@@ -15,20 +15,16 @@
  * limitations under the License.
  */
 
-import '../../../scripts/bundled-polymer.js';
 import '../../shared/gr-icons/gr-icons.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-change-list/gr-change-list.js';
 import '../gr-repo-header/gr-repo-header.js';
 import '../gr-user-header/gr-user-header.js';
 import '../../../styles/shared-styles.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-change-list-view_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
 import page from 'page/page.mjs';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
@@ -45,14 +41,11 @@
 const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrChangeListView extends mixinBehaviors( [
-  BaseUrlBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
+class GrChangeListView extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-change-list-view'; }
@@ -193,7 +186,9 @@
             for (const query in LookupQueryPatterns) {
               if (LookupQueryPatterns.hasOwnProperty(query) &&
               this._query.match(LookupQueryPatterns[query])) {
-                GerritNav.navigateToChange(changes[0]);
+                // "Back"/"Forward" buttons work correctly only with
+                // opt_redirect options
+                GerritNav.navigateToChange(changes[0], null, null, null, true);
                 return;
               }
             }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
deleted file mode 100644
index 4add1da..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.js
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    .loading {
-      color: var(--deemphasized-text-color);
-      padding: var(--spacing-l);
-    }
-    gr-change-list {
-      width: 100%;
-    }
-    gr-user-header,
-    gr-repo-header {
-      border-bottom: 1px solid var(--border-color);
-    }
-    nav {
-      align-items: center;
-      display: flex;
-      height: 3rem;
-      justify-content: flex-end;
-      margin-right: 20px;
-    }
-    nav,
-    iron-icon {
-      color: var(--deemphasized-text-color);
-    }
-    iron-icon {
-      height: 1.85rem;
-      margin-left: 16px;
-      width: 1.85rem;
-    }
-    .hide {
-      display: none;
-    }
-    @media only screen and (max-width: 50em) {
-      .loading,
-      .error {
-        padding: 0 var(--spacing-l);
-      }
-    }
-  </style>
-  <div class="loading" hidden$="[[!_loading]]" hidden="">Loading...</div>
-  <div hidden$="[[_loading]]" hidden="">
-    <gr-repo-header
-      repo="[[_repo]]"
-      class$="[[_computeHeaderClass(_repo)]]"
-    ></gr-repo-header>
-    <gr-user-header
-      user-id="[[_userId]]"
-      show-dashboard-link=""
-      logged-in="[[_loggedIn]]"
-      class$="[[_computeHeaderClass(_userId)]]"
-    ></gr-user-header>
-    <gr-change-list
-      account="[[account]]"
-      changes="{{_changes}}"
-      preferences="[[preferences]]"
-      selected-index="{{viewState.selectedChangeIndex}}"
-      show-star="[[_loggedIn]]"
-      on-toggle-star="_handleToggleStar"
-      on-toggle-reviewed="_handleToggleReviewed"
-    ></gr-change-list>
-    <nav class$="[[_computeNavClass(_loading)]]">
-      Page [[_computePage(_offset, _changesPerPage)]]
-      <a
-        id="prevArrow"
-        href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
-        class$="[[_computePrevArrowClass(_offset)]]"
-      >
-        <iron-icon icon="gr-icons:chevron-left"></iron-icon>
-      </a>
-      <a
-        id="nextArrow"
-        href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
-        class$="[[_computeNextArrowClass(_changes)]]"
-      >
-        <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-      </a>
-    </nav>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
new file mode 100644
index 0000000..0e8f843
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.ts
@@ -0,0 +1,102 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    .loading {
+      color: var(--deemphasized-text-color);
+      padding: var(--spacing-l);
+    }
+    gr-change-list {
+      width: 100%;
+    }
+    gr-user-header,
+    gr-repo-header {
+      border-bottom: 1px solid var(--border-color);
+    }
+    nav {
+      align-items: center;
+      display: flex;
+      height: 3rem;
+      justify-content: flex-end;
+      margin-right: 20px;
+    }
+    nav,
+    iron-icon {
+      color: var(--deemphasized-text-color);
+    }
+    iron-icon {
+      height: 1.85rem;
+      margin-left: 16px;
+      width: 1.85rem;
+    }
+    .hide {
+      display: none;
+    }
+    @media only screen and (max-width: 50em) {
+      .loading,
+      .error {
+        padding: 0 var(--spacing-l);
+      }
+    }
+  </style>
+  <div class="loading" hidden$="[[!_loading]]" hidden="">Loading...</div>
+  <div hidden$="[[_loading]]" hidden="">
+    <gr-repo-header
+      repo="[[_repo]]"
+      class$="[[_computeHeaderClass(_repo)]]"
+    ></gr-repo-header>
+    <gr-user-header
+      user-id="[[_userId]]"
+      show-dashboard-link=""
+      logged-in="[[_loggedIn]]"
+      class$="[[_computeHeaderClass(_userId)]]"
+    ></gr-user-header>
+    <gr-change-list
+      account="[[account]]"
+      changes="{{_changes}}"
+      preferences="[[preferences]]"
+      selected-index="{{viewState.selectedChangeIndex}}"
+      show-star="[[_loggedIn]]"
+      on-toggle-star="_handleToggleStar"
+      on-toggle-reviewed="_handleToggleReviewed"
+    ></gr-change-list>
+    <nav class$="[[_computeNavClass(_loading)]]">
+      Page [[_computePage(_offset, _changesPerPage)]]
+      <a
+        id="prevArrow"
+        href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
+        class$="[[_computePrevArrowClass(_offset)]]"
+      >
+        <iron-icon icon="gr-icons:chevron-left" aria-label="Older"> </iron-icon>
+      </a>
+      <a
+        id="nextArrow"
+        href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
+        class$="[[_computeNextArrowClass(_changes)]]"
+      >
+        <iron-icon icon="gr-icons:chevron-right" aria-label="Newer">
+        </iron-icon>
+      </a>
+    </nav>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
deleted file mode 100644
index 58ec4e1..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
+++ /dev/null
@@ -1,269 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-change-list-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-list-view></gr-change-list-view>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-change-list-view.js';
-import page from 'page/page.mjs';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
-const COMMIT_HASH = '12345678';
-
-suite('gr-change-list-view tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      getChanges(num, query) {
-        return Promise.resolve([]);
-      },
-      getAccountDetails() { return Promise.resolve({}); },
-      getAccountStatus() { return Promise.resolve({}); },
-    });
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(done => {
-    flush(() => {
-      sandbox.restore();
-      done();
-    });
-  });
-
-  test('_computePage', () => {
-    assert.equal(element._computePage(0, 25), 1);
-    assert.equal(element._computePage(50, 25), 3);
-  });
-
-  test('_limitFor', () => {
-    const defaultLimit = 25;
-    const _limitFor = q => element._limitFor(q, defaultLimit);
-    assert.equal(_limitFor(''), defaultLimit);
-    assert.equal(_limitFor('limit:10'), 10);
-    assert.equal(_limitFor('xlimit:10'), defaultLimit);
-    assert.equal(_limitFor('x(limit:10'), 10);
-  });
-
-  test('_computeNavLink', () => {
-    const getUrlStub = sandbox.stub(GerritNav, 'getUrlForSearchQuery')
-        .returns('');
-    const query = 'status:open';
-    let offset = 0;
-    let direction = 1;
-    const changesPerPage = 5;
-
-    element._computeNavLink(query, offset, direction, changesPerPage);
-    assert.equal(getUrlStub.lastCall.args[1], 5);
-
-    direction = -1;
-    element._computeNavLink(query, offset, direction, changesPerPage);
-    assert.equal(getUrlStub.lastCall.args[1], 0);
-
-    offset = 5;
-    direction = 1;
-    element._computeNavLink(query, offset, direction, changesPerPage);
-    assert.equal(getUrlStub.lastCall.args[1], 10);
-  });
-
-  test('_computePrevArrowClass', () => {
-    let offset = 0;
-    assert.equal(element._computePrevArrowClass(offset), 'hide');
-    offset = 5;
-    assert.equal(element._computePrevArrowClass(offset), '');
-  });
-
-  test('_computeNextArrowClass', () => {
-    let changes = _.times(25, _.constant({_more_changes: true}));
-    assert.equal(element._computeNextArrowClass(changes), '');
-    changes = _.times(25, _.constant({}));
-    assert.equal(element._computeNextArrowClass(changes), 'hide');
-  });
-
-  test('_computeNavClass', () => {
-    let loading = true;
-    assert.equal(element._computeNavClass(loading), 'hide');
-    loading = false;
-    assert.equal(element._computeNavClass(loading), 'hide');
-    element._changes = [];
-    assert.equal(element._computeNavClass(loading), 'hide');
-    element._changes = _.times(5, _.constant({}));
-    assert.equal(element._computeNavClass(loading), '');
-  });
-
-  test('_handleNextPage', () => {
-    const showStub = sandbox.stub(page, 'show');
-    element.$.nextArrow.hidden = true;
-    element._handleNextPage();
-    assert.isFalse(showStub.called);
-    element.$.nextArrow.hidden = false;
-    element._handleNextPage();
-    assert.isTrue(showStub.called);
-  });
-
-  test('_handlePreviousPage', () => {
-    const showStub = sandbox.stub(page, 'show');
-    element.$.prevArrow.hidden = true;
-    element._handlePreviousPage();
-    assert.isFalse(showStub.called);
-    element.$.prevArrow.hidden = false;
-    element._handlePreviousPage();
-    assert.isTrue(showStub.called);
-  });
-
-  test('_userId query', done => {
-    assert.isNull(element._userId);
-    element._query = 'owner: foo@bar';
-    element._changes = [{owner: {email: 'foo@bar'}}];
-    flush(() => {
-      assert.equal(element._userId, 'foo@bar');
-
-      element._query = 'foo bar baz';
-      element._changes = [{owner: {email: 'foo@bar'}}];
-      assert.isNull(element._userId);
-
-      done();
-    });
-  });
-
-  test('_userId query without email', done => {
-    assert.isNull(element._userId);
-    element._query = 'owner: foo@bar';
-    element._changes = [{owner: {}}];
-    flush(() => {
-      assert.isNull(element._userId);
-      done();
-    });
-  });
-
-  test('_repo query', done => {
-    assert.isNull(element._repo);
-    element._query = 'project: test-repo';
-    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-    flush(() => {
-      assert.equal(element._repo, 'test-repo');
-      element._query = 'foo bar baz';
-      element._changes = [{owner: {email: 'foo@bar'}}];
-      assert.isNull(element._repo);
-      done();
-    });
-  });
-
-  test('_repo query with open status', done => {
-    assert.isNull(element._repo);
-    element._query = 'project:test-repo status:open';
-    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-    flush(() => {
-      assert.equal(element._repo, 'test-repo');
-      element._query = 'foo bar baz';
-      element._changes = [{owner: {email: 'foo@bar'}}];
-      assert.isNull(element._repo);
-      done();
-    });
-  });
-
-  suite('query based navigation', () => {
-    setup(() => {
-    });
-
-    teardown(done => {
-      flush(() => {
-        sandbox.restore();
-        done();
-      });
-    });
-
-    test('Searching for a change ID redirects to change', done => {
-      const change = {_number: 1};
-      sandbox.stub(element, '_getChanges')
-          .returns(Promise.resolve([change]));
-      sandbox.stub(GerritNav, 'navigateToChange', url => {
-        assert.equal(url, change);
-        done();
-      });
-
-      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-    });
-
-    test('Searching for a change num redirects to change', done => {
-      const change = {_number: 1};
-      sandbox.stub(element, '_getChanges')
-          .returns(Promise.resolve([change]));
-      sandbox.stub(GerritNav, 'navigateToChange', url => {
-        assert.equal(url, change);
-        done();
-      });
-
-      element.params = {view: GerritNav.View.SEARCH, query: '1'};
-    });
-
-    test('Commit hash redirects to change', done => {
-      const change = {_number: 1};
-      sandbox.stub(element, '_getChanges')
-          .returns(Promise.resolve([change]));
-      sandbox.stub(GerritNav, 'navigateToChange', url => {
-        assert.equal(url, change);
-        done();
-      });
-
-      element.params = {view: GerritNav.View.SEARCH, query: COMMIT_HASH};
-    });
-
-    test('Searching for an invalid change ID searches', () => {
-      sandbox.stub(element, '_getChanges')
-          .returns(Promise.resolve([]));
-      const stub = sandbox.stub(GerritNav, 'navigateToChange');
-
-      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-      flushAsynchronousOperations();
-
-      assert.isFalse(stub.called);
-    });
-
-    test('Change ID with multiple search results searches', () => {
-      sandbox.stub(element, '_getChanges')
-          .returns(Promise.resolve([{}, {}]));
-      const stub = sandbox.stub(GerritNav, 'navigateToChange');
-
-      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-      flushAsynchronousOperations();
-
-      assert.isFalse(stub.called);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
new file mode 100644
index 0000000..db622d5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
@@ -0,0 +1,256 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-list-view.js';
+import page from 'page/page.mjs';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-change-list-view');
+
+const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
+const COMMIT_HASH = '12345678';
+
+suite('gr-change-list-view tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getChanges(num, query) {
+        return Promise.resolve([]);
+      },
+      getAccountDetails() { return Promise.resolve({}); },
+      getAccountStatus() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  teardown(done => {
+    flush(() => {
+      done();
+    });
+  });
+
+  test('_computePage', () => {
+    assert.equal(element._computePage(0, 25), 1);
+    assert.equal(element._computePage(50, 25), 3);
+  });
+
+  test('_limitFor', () => {
+    const defaultLimit = 25;
+    const _limitFor = q => element._limitFor(q, defaultLimit);
+    assert.equal(_limitFor(''), defaultLimit);
+    assert.equal(_limitFor('limit:10'), 10);
+    assert.equal(_limitFor('xlimit:10'), defaultLimit);
+    assert.equal(_limitFor('x(limit:10'), 10);
+  });
+
+  test('_computeNavLink', () => {
+    const getUrlStub = sinon.stub(GerritNav, 'getUrlForSearchQuery')
+        .returns('');
+    const query = 'status:open';
+    let offset = 0;
+    let direction = 1;
+    const changesPerPage = 5;
+
+    element._computeNavLink(query, offset, direction, changesPerPage);
+    assert.equal(getUrlStub.lastCall.args[1], 5);
+
+    direction = -1;
+    element._computeNavLink(query, offset, direction, changesPerPage);
+    assert.equal(getUrlStub.lastCall.args[1], 0);
+
+    offset = 5;
+    direction = 1;
+    element._computeNavLink(query, offset, direction, changesPerPage);
+    assert.equal(getUrlStub.lastCall.args[1], 10);
+  });
+
+  test('_computePrevArrowClass', () => {
+    let offset = 0;
+    assert.equal(element._computePrevArrowClass(offset), 'hide');
+    offset = 5;
+    assert.equal(element._computePrevArrowClass(offset), '');
+  });
+
+  test('_computeNextArrowClass', () => {
+    let changes = _.times(25, _.constant({_more_changes: true}));
+    assert.equal(element._computeNextArrowClass(changes), '');
+    changes = _.times(25, _.constant({}));
+    assert.equal(element._computeNextArrowClass(changes), 'hide');
+  });
+
+  test('_computeNavClass', () => {
+    let loading = true;
+    assert.equal(element._computeNavClass(loading), 'hide');
+    loading = false;
+    assert.equal(element._computeNavClass(loading), 'hide');
+    element._changes = [];
+    assert.equal(element._computeNavClass(loading), 'hide');
+    element._changes = _.times(5, _.constant({}));
+    assert.equal(element._computeNavClass(loading), '');
+  });
+
+  test('_handleNextPage', () => {
+    const showStub = sinon.stub(page, 'show');
+    element.$.nextArrow.hidden = true;
+    element._handleNextPage();
+    assert.isFalse(showStub.called);
+    element.$.nextArrow.hidden = false;
+    element._handleNextPage();
+    assert.isTrue(showStub.called);
+  });
+
+  test('_handlePreviousPage', () => {
+    const showStub = sinon.stub(page, 'show');
+    element.$.prevArrow.hidden = true;
+    element._handlePreviousPage();
+    assert.isFalse(showStub.called);
+    element.$.prevArrow.hidden = false;
+    element._handlePreviousPage();
+    assert.isTrue(showStub.called);
+  });
+
+  test('_userId query', done => {
+    assert.isNull(element._userId);
+    element._query = 'owner: foo@bar';
+    element._changes = [{owner: {email: 'foo@bar'}}];
+    flush(() => {
+      assert.equal(element._userId, 'foo@bar');
+
+      element._query = 'foo bar baz';
+      element._changes = [{owner: {email: 'foo@bar'}}];
+      assert.isNull(element._userId);
+
+      done();
+    });
+  });
+
+  test('_userId query without email', done => {
+    assert.isNull(element._userId);
+    element._query = 'owner: foo@bar';
+    element._changes = [{owner: {}}];
+    flush(() => {
+      assert.isNull(element._userId);
+      done();
+    });
+  });
+
+  test('_repo query', done => {
+    assert.isNull(element._repo);
+    element._query = 'project: test-repo';
+    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
+    flush(() => {
+      assert.equal(element._repo, 'test-repo');
+      element._query = 'foo bar baz';
+      element._changes = [{owner: {email: 'foo@bar'}}];
+      assert.isNull(element._repo);
+      done();
+    });
+  });
+
+  test('_repo query with open status', done => {
+    assert.isNull(element._repo);
+    element._query = 'project:test-repo status:open';
+    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
+    flush(() => {
+      assert.equal(element._repo, 'test-repo');
+      element._query = 'foo bar baz';
+      element._changes = [{owner: {email: 'foo@bar'}}];
+      assert.isNull(element._repo);
+      done();
+    });
+  });
+
+  suite('query based navigation', () => {
+    setup(() => {
+    });
+
+    teardown(done => {
+      flush(() => {
+        sinon.restore();
+        done();
+      });
+    });
+
+    test('Searching for a change ID redirects to change', done => {
+      const change = {_number: 1};
+      sinon.stub(element, '_getChanges')
+          .returns(Promise.resolve([change]));
+      sinon.stub(GerritNav, 'navigateToChange').callsFake(
+          (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
+            assert.equal(url, change);
+            assert.isTrue(opt_redirect);
+            done();
+          });
+
+      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
+    });
+
+    test('Searching for a change num redirects to change', done => {
+      const change = {_number: 1};
+      sinon.stub(element, '_getChanges')
+          .returns(Promise.resolve([change]));
+      sinon.stub(GerritNav, 'navigateToChange').callsFake(
+          (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
+            assert.equal(url, change);
+            assert.isTrue(opt_redirect);
+            done();
+          });
+
+      element.params = {view: GerritNav.View.SEARCH, query: '1'};
+    });
+
+    test('Commit hash redirects to change', done => {
+      const change = {_number: 1};
+      sinon.stub(element, '_getChanges')
+          .returns(Promise.resolve([change]));
+      sinon.stub(GerritNav, 'navigateToChange').callsFake(
+          (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
+            assert.equal(url, change);
+            assert.isTrue(opt_redirect);
+            done();
+          });
+
+      element.params = {view: GerritNav.View.SEARCH, query: COMMIT_HASH};
+    });
+
+    test('Searching for an invalid change ID searches', () => {
+      sinon.stub(element, '_getChanges')
+          .returns(Promise.resolve([]));
+      const stub = sinon.stub(GerritNav, 'navigateToChange');
+
+      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
+      flushAsynchronousOperations();
+
+      assert.isFalse(stub.called);
+    });
+
+    test('Change ID with multiple search results searches', () => {
+      sinon.stub(element, '_getChanges')
+          .returns(Promise.resolve([{}, {}]));
+      const stub = sinon.stub(GerritNav, 'navigateToChange');
+
+      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
+      flushAsynchronousOperations();
+
+      assert.isFalse(stub.called);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index 0a19ae1..9a65559 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 
-import '../../../scripts/bundled-polymer.js';
 import '../../../styles/gr-change-list-styles.js';
 import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
@@ -24,20 +23,17 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-change-list_html.js';
 import {appContext} from '../../../services/app-context.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {ChangeTableBehavior} from '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
+import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {changeIsOpen} from '../../../utils/change-util.js';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -45,17 +41,11 @@
 const MAX_SHORTCUT_CHARS = 5;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrChangeList extends mixinBehaviors( [
-  BaseUrlBehavior,
-  ChangeTableBehavior,
-  KeyboardShortcutBehavior,
-  RESTClientBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrChangeList extends ChangeTableMixin(
+    KeyboardShortcutMixin(GestureEventListeners(
+        LegacyElementMixin(PolymerElement)))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-change-list'; }
@@ -143,14 +133,14 @@
 
   keyboardShortcuts() {
     return {
-      [this.Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
-      [this.Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
-      [this.Shortcut.NEXT_PAGE]: '_nextPage',
-      [this.Shortcut.PREV_PAGE]: '_prevPage',
-      [this.Shortcut.OPEN_CHANGE]: '_openChange',
-      [this.Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
-      [this.Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
-      [this.Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
+      [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
+      [Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
+      [Shortcut.NEXT_PAGE]: '_nextPage',
+      [Shortcut.PREV_PAGE]: '_prevPage',
+      [Shortcut.OPEN_CHANGE]: '_openChange',
+      [Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
+      [Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
+      [Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
     };
   }
 
@@ -169,7 +159,6 @@
   /** @override */
   ready() {
     super.ready();
-    this._ensureAttribute('tabindex', 0);
     this.$.restAPI.getConfig().then(config => {
       this._config = config;
     });
@@ -179,7 +168,7 @@
   attached() {
     super.attached();
     pluginLoader.awaitPluginsLoaded().then(() => {
-      this._dynamicHeaderEndpoints = pluginEndpoints.getDynamicEndpoints(
+      this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
           'change-list-header');
     });
   }
@@ -204,7 +193,7 @@
 
   _computePreferences(account, preferences, config) {
     // Polymer 2: check for undefined
-    if ([account, preferences, config].some(arg => arg === undefined)) {
+    if ([account, preferences, config].includes(undefined)) {
       return;
     }
 
@@ -297,10 +286,17 @@
     return idx == selectedIndex;
   }
 
-  _computeItemNeedsReview(account, change, showReviewedState) {
-    return showReviewedState && !change.reviewed &&
+  _computeTabIndex(sectionIndex, index, selectedIndex) {
+    return this._computeItemSelected(sectionIndex, index, selectedIndex)
+      ? 0 : undefined;
+  }
+
+  _computeItemNeedsReview(account, change, showReviewedState, config) {
+    const isAttentionSetEnabled =
+        !!config && !!config.change && config.change.enable_attention_set;
+    return !isAttentionSetEnabled && showReviewedState && !change.reviewed &&
         !change.work_in_progress &&
-        this.changeIsOpen(change) &&
+        changeIsOpen(change) &&
         (!account || account._account_id != change.owner._account_id);
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
deleted file mode 100644
index 17150df..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.js
+++ /dev/null
@@ -1,145 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-change-list-styles">
-    #changeList {
-      border-collapse: collapse;
-      width: 100%;
-    }
-    .section-count-label {
-      color: var(--deemphasized-text-color);
-      font-family: var(--font-family);
-      font-size: var(--font-size-small);
-      font-weight: var(--font-weight-normal);
-      line-height: var(--line-height-small);
-    }
-    a.section-title:hover {
-      text-decoration: none;
-    }
-    a.section-title:hover .section-count-label {
-      text-decoration: none;
-    }
-    a.section-title:hover .section-name {
-      text-decoration: underline;
-    }
-  </style>
-  <table id="changeList">
-    <template
-      is="dom-repeat"
-      items="[[sections]]"
-      as="changeSection"
-      index-as="sectionIndex"
-    >
-      <template is="dom-if" if="[[changeSection.name]]">
-        <tbody>
-          <tr class="groupHeader">
-            <td class="leftPadding"></td>
-            <td class="star" hidden$="[[!showStar]]" hidden=""></td>
-            <td
-              class="cell"
-              colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
-            >
-              <a
-                href$="[[_sectionHref(changeSection.query)]]"
-                class="section-title"
-              >
-                <span class="section-name">[[changeSection.name]]</span>
-                <span class="section-count-label"
-                  >[[changeSection.countLabel]]</span
-                >
-              </a>
-            </td>
-          </tr>
-        </tbody>
-      </template>
-      <tbody class="groupContent">
-        <template is="dom-if" if="[[_isEmpty(changeSection)]]">
-          <tr class="noChanges">
-            <td class="leftPadding"></td>
-            <td class="star" hidden$="[[!showStar]]" hidden=""></td>
-            <td
-              class="cell"
-              colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
-            >
-              <template is="dom-if" if="[[_isOutgoing(changeSection)]]">
-                <slot name="empty-outgoing"></slot>
-              </template>
-              <template is="dom-if" if="[[!_isOutgoing(changeSection)]]">
-                No changes
-              </template>
-            </td>
-          </tr>
-        </template>
-        <template is="dom-if" if="[[!_isEmpty(changeSection)]]">
-          <tr class="groupTitle">
-            <td class="leftPadding"></td>
-            <td class="star" hidden$="[[!showStar]]" hidden=""></td>
-            <td class="number" hidden$="[[!showNumber]]" hidden="">#</td>
-            <template is="dom-repeat" items="[[changeTableColumns]]" as="item">
-              <td
-                class$="[[_lowerCase(item)]]"
-                hidden$="[[isColumnHidden(item, visibleChangeTableColumns)]]"
-              >
-                [[item]]
-              </td>
-            </template>
-            <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-              <td class="label" title$="[[labelName]]">
-                [[_computeLabelShortcut(labelName)]]
-              </td>
-            </template>
-            <template
-              is="dom-repeat"
-              items="[[_dynamicHeaderEndpoints]]"
-              as="pluginHeader"
-            >
-              <td class="endpoint">
-                <gr-endpoint-decorator name$="[[pluginHeader]]">
-                </gr-endpoint-decorator>
-              </td>
-            </template>
-          </tr>
-        </template>
-        <template is="dom-repeat" items="[[changeSection.results]]" as="change">
-          <gr-change-list-item
-            selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
-            highlight$="[[_computeItemHighlight(account, change)]]"
-            needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState)]]"
-            change="[[change]]"
-            visible-change-table-columns="[[visibleChangeTableColumns]]"
-            show-number="[[showNumber]]"
-            show-star="[[showStar]]"
-            tabindex="0"
-            label-names="[[labelNames]]"
-          ></gr-change-list-item>
-        </template>
-      </tbody>
-    </template>
-  </table>
-  <gr-cursor-manager
-    id="cursor"
-    index="{{selectedIndex}}"
-    scroll-behavior="keep-visible"
-    focus-on-move=""
-  ></gr-cursor-manager>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
new file mode 100644
index 0000000..404456c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
@@ -0,0 +1,158 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-change-list-styles">
+    #changeList {
+      border-collapse: collapse;
+      width: 100%;
+    }
+    .section-count-label {
+      color: var(--deemphasized-text-color);
+      font-family: var(--font-family);
+      font-size: var(--font-size-small);
+      font-weight: var(--font-weight-normal);
+      line-height: var(--line-height-small);
+    }
+    a.section-title:hover {
+      text-decoration: none;
+    }
+    a.section-title:hover .section-count-label {
+      text-decoration: none;
+    }
+    a.section-title:hover .section-name {
+      text-decoration: underline;
+    }
+  </style>
+  <table id="changeList">
+    <template
+      is="dom-repeat"
+      items="[[sections]]"
+      as="changeSection"
+      index-as="sectionIndex"
+    >
+      <template is="dom-if" if="[[changeSection.name]]">
+        <tbody>
+          <tr class="groupHeader">
+            <td aria-hidden="true" class="leftPadding"></td>
+            <td
+              aria-hidden="true"
+              class="star"
+              hidden$="[[!showStar]]"
+              hidden=""
+            ></td>
+            <td
+              class="cell"
+              colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
+            >
+              <a
+                href$="[[_sectionHref(changeSection.query)]]"
+                class="section-title"
+              >
+                <span class="section-name">[[changeSection.name]]</span>
+                <span class="section-count-label"
+                  >[[changeSection.countLabel]]</span
+                >
+              </a>
+            </td>
+          </tr>
+        </tbody>
+      </template>
+      <tbody class="groupContent">
+        <template is="dom-if" if="[[_isEmpty(changeSection)]]">
+          <tr class="noChanges">
+            <td aria-hidden="true" class="leftPadding"></td>
+            <td aria-hidden="true" class="star" hidden></td>
+            <td
+              class="cell"
+              colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
+            >
+              <template is="dom-if" if="[[_isOutgoing(changeSection)]]">
+                <slot name="empty-outgoing"></slot>
+              </template>
+              <template is="dom-if" if="[[!_isOutgoing(changeSection)]]">
+                No changes
+              </template>
+            </td>
+          </tr>
+        </template>
+        <template is="dom-if" if="[[!_isEmpty(changeSection)]]">
+          <tr class="groupTitle">
+            <td aria-hidden="true" class="leftPadding"></td>
+            <td
+              aria-label="Star status column"
+              class="star"
+              hidden$="[[!showStar]]"
+              hidden=""
+            ></td>
+            <td class="number" hidden$="[[!showNumber]]" hidden="">#</td>
+            <template is="dom-repeat" items="[[changeTableColumns]]" as="item">
+              <td
+                class$="[[_lowerCase(item)]]"
+                hidden$="[[isColumnHidden(item, visibleChangeTableColumns)]]"
+              >
+                [[item]]
+              </td>
+            </template>
+            <template is="dom-repeat" items="[[labelNames]]" as="labelName">
+              <td class="label" title$="[[labelName]]">
+                [[_computeLabelShortcut(labelName)]]
+              </td>
+            </template>
+            <template
+              is="dom-repeat"
+              items="[[_dynamicHeaderEndpoints]]"
+              as="pluginHeader"
+            >
+              <td class="endpoint">
+                <gr-endpoint-decorator name$="[[pluginHeader]]">
+                </gr-endpoint-decorator>
+              </td>
+            </template>
+          </tr>
+        </template>
+        <template is="dom-repeat" items="[[changeSection.results]]" as="change">
+          <gr-change-list-item
+            account="[[account]]"
+            selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
+            highlight$="[[_computeItemHighlight(account, change)]]"
+            needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState, _config)]]"
+            change="[[change]]"
+            config="[[_config]]"
+            section-name="[[changeSection.name]]"
+            visible-change-table-columns="[[visibleChangeTableColumns]]"
+            show-number="[[showNumber]]"
+            show-star="[[showStar]]"
+            tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex)]]"
+            label-names="[[labelNames]]"
+          ></gr-change-list-item>
+        </template>
+      </tbody>
+    </template>
+  </table>
+  <gr-cursor-manager
+    id="cursor"
+    index="{{selectedIndex}}"
+    scroll-mode="keep-visible"
+    focus-on-move=""
+  ></gr-cursor-manager>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
deleted file mode 100644
index 62763d9..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ /dev/null
@@ -1,653 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-change-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script src="/node_modules/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-list></gr-change-list>
-  </template>
-</test-fixture>
-
-<test-fixture id="grouped">
-  <template>
-    <gr-change-list></gr-change-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-change-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
-import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-change-list basic tests', () => {
-  // Define keybindings before attaching other fixtures.
-  const kb = KeyboardShortcutBinder;
-  kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_CHANGE, 'j');
-  kb.bindShortcut(kb.Shortcut.CURSOR_PREV_CHANGE, 'k');
-  kb.bindShortcut(kb.Shortcut.OPEN_CHANGE, 'o');
-  kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
-  kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
-  kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
-  kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'n');
-  kb.bindShortcut(kb.Shortcut.NEXT_PAGE, 'p');
-
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  suite('test show change number not logged in', () => {
-    setup(() => {
-      element = fixture('basic');
-      element.account = null;
-      element.preferences = null;
-      element._config = {};
-    });
-
-    test('show number disabled', () => {
-      assert.isFalse(element.showNumber);
-    });
-  });
-
-  suite('test show change number preference enabled', () => {
-    setup(() => {
-      element = fixture('basic');
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [],
-      };
-      element.account = {_account_id: 1001};
-      element._config = {};
-      flushAsynchronousOperations();
-    });
-
-    test('show number enabled', () => {
-      assert.isTrue(element.showNumber);
-    });
-  });
-
-  suite('test show change number preference disabled', () => {
-    setup(() => {
-      element = fixture('basic');
-      // legacycid_in_change_table is not set when false.
-      element.preferences = {
-        time_format: 'HHMM_12',
-        change_table: [],
-      };
-      element.account = {_account_id: 1001};
-      element._config = {};
-      flushAsynchronousOperations();
-    });
-
-    test('show number disabled', () => {
-      assert.isFalse(element.showNumber);
-    });
-  });
-
-  test('computed fields', () => {
-    assert.equal(element._computeLabelNames(
-        [{results: [{_number: 0, labels: {}}]}]).length, 0);
-    assert.equal(element._computeLabelNames([
-      {results: [
-        {_number: 0, labels: {Verified: {approved: {}}}},
-        {
-          _number: 1,
-          labels: {
-            'Verified': {approved: {}},
-            'Code-Review': {approved: {}},
-          },
-        },
-        {
-          _number: 2,
-          labels: {
-            'Verified': {approved: {}},
-            'Library-Compliance': {approved: {}},
-          },
-        },
-      ]},
-    ]).length, 3);
-
-    assert.equal(element._computeLabelShortcut('Code-Review'), 'CR');
-    assert.equal(element._computeLabelShortcut('Verified'), 'V');
-    assert.equal(element._computeLabelShortcut('Library-Compliance'), 'LC');
-    assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
-    assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
-    assert.equal(element._computeLabelShortcut(
-        'Invalid-Prolog-Rules-Label-Name--Verified'), 'V');
-    assert.equal(element._computeLabelShortcut(
-        'Some-Special-Label-7'), 'SSL7');
-    assert.equal(element._computeLabelShortcut('--Too----many----dashes---'),
-        'TMD');
-    assert.equal(element._computeLabelShortcut(
-        'Really-rather-entirely-too-long-of-a-label-name'), 'RRETL');
-  });
-
-  test('colspans', () => {
-    element.sections = [
-      {results: [{}]},
-    ];
-    flushAsynchronousOperations();
-    const tdItemCount = dom(element.root).querySelectorAll(
-        'td').length;
-
-    const changeTableColumns = [];
-    const labelNames = [];
-    assert.equal(tdItemCount, element._computeColspan(
-        changeTableColumns, labelNames));
-  });
-
-  test('keyboard shortcuts', done => {
-    sandbox.stub(element, '_computeLabelNames');
-    element.sections = [
-      {results: new Array(1)},
-      {results: new Array(2)},
-    ];
-    element.selectedIndex = 0;
-    element.changes = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    flushAsynchronousOperations();
-    afterNextRender(element, () => {
-      const elementItems = dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 3);
-
-      assert.isTrue(elementItems[0].hasAttribute('selected'));
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert.equal(element.selectedIndex, 1);
-      assert.isTrue(elementItems[1].hasAttribute('selected'));
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert.equal(element.selectedIndex, 2);
-      assert.isTrue(elementItems[2].hasAttribute('selected'));
-
-      const navStub = sandbox.stub(GerritNav, 'navigateToChange');
-      assert.equal(element.selectedIndex, 2);
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-          'Should navigate to /c/2/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-          'Should navigate to /c/1/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      assert.equal(element.selectedIndex, 0);
-
-      const reloadStub = sandbox.stub(element, '_reloadWindow');
-      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-      assert.isTrue(reloadStub.called);
-
-      done();
-    });
-  });
-
-  test('changes needing review', () => {
-    element.changes = [
-      {
-        _number: 0,
-        status: 'NEW',
-        reviewed: true,
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 1,
-        status: 'NEW',
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 2,
-        status: 'MERGED',
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 3,
-        status: 'ABANDONED',
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 4,
-        status: 'NEW',
-        work_in_progress: true,
-        owner: {_account_id: 0},
-      },
-    ];
-    flushAsynchronousOperations();
-    let elementItems = dom(element.root).querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(elementItems.length, 5);
-    for (let i = 0; i < elementItems.length; i++) {
-      assert.isFalse(elementItems[i].hasAttribute('needs-review'));
-    }
-
-    element.showReviewedState = true;
-    elementItems = dom(element.root).querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(elementItems.length, 5);
-    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
-    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
-
-    element.account = {_account_id: 42};
-    elementItems = dom(element.root).querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(elementItems.length, 5);
-    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
-    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
-  });
-
-  test('no changes', () => {
-    element.changes = [];
-    flushAsynchronousOperations();
-    const listItems = dom(element.root).querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(listItems.length, 0);
-    const noChangesMsg =
-        dom(element.root).querySelector('.noChanges');
-    assert.ok(noChangesMsg);
-  });
-
-  test('empty sections', () => {
-    element.sections = [{results: []}, {results: []}];
-    flushAsynchronousOperations();
-    const listItems = dom(element.root).querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(listItems.length, 0);
-    const noChangesMsg = dom(element.root).querySelectorAll(
-        '.noChanges');
-    assert.equal(noChangesMsg.length, 2);
-  });
-
-  suite('empty outgoing', () => {
-    test('not shown on empty non-outgoing sections', () => {
-      const section = {results: []};
-      assert.isTrue(element._isEmpty(section));
-      assert.isFalse(element._isOutgoing(section));
-    });
-
-    test('shown on empty outgoing sections', () => {
-      const section = {results: [], isOutgoing: true};
-      assert.isTrue(element._isEmpty(section));
-      assert.isTrue(element._isOutgoing(section));
-    });
-
-    test('not shown on non-empty outgoing sections', () => {
-      const section = {isOutgoing: true, results: [
-        {_number: 0, labels: {Verified: {approved: {}}}}]};
-      assert.isFalse(element._isEmpty(section));
-      assert.isTrue(element._isOutgoing(section));
-    });
-  });
-
-  test('_isOutgoing', () => {
-    assert.isTrue(element._isOutgoing({results: [], isOutgoing: true}));
-    assert.isFalse(element._isOutgoing({results: []}));
-  });
-
-  suite('empty column preference', () => {
-    let element;
-
-    setup(() => {
-      element = fixture('basic');
-      element.sections = [
-        {results: [{}]},
-      ];
-      element.account = {_account_id: 1001};
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [],
-      };
-      element._config = {};
-      flushAsynchronousOperations();
-    });
-
-    test('show number enabled', () => {
-      assert.isTrue(element.showNumber);
-    });
-
-    test('all columns visible', () => {
-      for (const column of element.columnNames) {
-        const elementClass = '.' + element._lowerCase(column);
-        assert.isFalse(element.shadowRoot
-            .querySelector(elementClass).hidden);
-      }
-    });
-  });
-
-  suite('full column preference', () => {
-    let element;
-
-    setup(() => {
-      element = fixture('basic');
-      element.sections = [
-        {results: [{}]},
-      ];
-      element.account = {_account_id: 1001};
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [
-          'Subject',
-          'Status',
-          'Owner',
-          'Assignee',
-          'Reviewers',
-          'Comments',
-          'Repo',
-          'Branch',
-          'Updated',
-          'Size',
-        ],
-      };
-      element._config = {};
-      flushAsynchronousOperations();
-    });
-
-    test('all columns visible', () => {
-      for (const column of element.changeTableColumns) {
-        const elementClass = '.' + element._lowerCase(column);
-        assert.isFalse(element.shadowRoot
-            .querySelector(elementClass).hidden);
-      }
-    });
-  });
-
-  suite('partial column preference', () => {
-    let element;
-
-    setup(() => {
-      element = fixture('basic');
-      element.sections = [
-        {results: [{}]},
-      ];
-      element.account = {_account_id: 1001};
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [
-          'Subject',
-          'Status',
-          'Owner',
-          'Assignee',
-          'Reviewers',
-          'Comments',
-          'Branch',
-          'Updated',
-          'Size',
-        ],
-      };
-      element._config = {};
-      flushAsynchronousOperations();
-    });
-
-    test('all columns except repo visible', () => {
-      for (const column of element.changeTableColumns) {
-        const elementClass = '.' + column.toLowerCase();
-        if (column === 'Repo') {
-          assert.isTrue(element.shadowRoot
-              .querySelector(elementClass).hidden);
-        } else {
-          assert.isFalse(element.shadowRoot
-              .querySelector(elementClass).hidden);
-        }
-      }
-    });
-  });
-
-  suite('random column does not exist', () => {
-    let element;
-
-    /* This would only exist if somebody manually updated the config
-    file. */
-    setup(() => {
-      element = fixture('basic');
-      element.account = {_account_id: 1001};
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [
-          'Bad',
-        ],
-      };
-      flushAsynchronousOperations();
-    });
-
-    test('bad column does not exist', () => {
-      const elementClass = '.bad';
-      assert.isNotOk(element.shadowRoot
-          .querySelector(elementClass));
-    });
-  });
-
-  suite('dashboard queries', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('query without age and limit unchanged', () => {
-      const query = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), query);
-    });
-
-    test('query with age and limit', () => {
-      const query = 'status:closed age:1week limit:10 owner:me';
-      const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with age', () => {
-      const query = 'status:closed age:1week owner:me';
-      const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with limit', () => {
-      const query = 'status:closed limit:10 owner:me';
-      const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with age as value and not key', () => {
-      const query = 'status:closed random:age';
-      const expectedQuery = 'status:closed random:age';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with limit as value and not key', () => {
-      const query = 'status:closed random:limit';
-      const expectedQuery = 'status:closed random:limit';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with -age key', () => {
-      const query = 'status:closed -age:1week';
-      const expectedQuery = 'status:closed';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-  });
-
-  suite('gr-change-list sections', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('keyboard shortcuts', done => {
-      element.selectedIndex = 0;
-      element.sections = [
-        {
-          results: [
-            {_number: 0},
-            {_number: 1},
-            {_number: 2},
-          ],
-        },
-        {
-          results: [
-            {_number: 3},
-            {_number: 4},
-            {_number: 5},
-          ],
-        },
-        {
-          results: [
-            {_number: 6},
-            {_number: 7},
-            {_number: 8},
-          ],
-        },
-      ];
-      flushAsynchronousOperations();
-      afterNextRender(element, () => {
-        const elementItems = dom(element.root).querySelectorAll(
-            'gr-change-list-item');
-        assert.equal(elementItems.length, 9);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-
-        const navStub = sandbox.stub(GerritNav, 'navigateToChange');
-        assert.equal(element.selectedIndex, 2);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-            'Should navigate to /c/2/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-            'Should navigate to /c/1/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        assert.equal(element.selectedIndex, 4);
-        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
-            'Should navigate to /c/4/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
-        const change = element._changeForIndex(element.selectedIndex);
-        assert.equal(change.reviewed, true,
-            'Should mark change as reviewed');
-        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
-        assert.equal(change.reviewed, false,
-            'Should mark change as unreviewed');
-        done();
-      });
-    });
-
-    test('highlight attribute is updated correctly', () => {
-      element.changes = [
-        {
-          _number: 0,
-          status: 'NEW',
-          owner: {_account_id: 0},
-        },
-        {
-          _number: 1,
-          status: 'ABANDONED',
-          owner: {_account_id: 0},
-        },
-      ];
-      element.account = {_account_id: 42};
-      flushAsynchronousOperations();
-      let items = element._getListItems();
-      assert.equal(items.length, 2);
-      assert.isFalse(items[0].hasAttribute('highlight'));
-      assert.isFalse(items[1].hasAttribute('highlight'));
-
-      // Assign all issues to the user, but only the first one is highlighted
-      // because the second one is abandoned.
-      element.set(['changes', 0, 'assignee'], {_account_id: 12});
-      element.set(['changes', 1, 'assignee'], {_account_id: 12});
-      element.account = {_account_id: 12};
-      flushAsynchronousOperations();
-      items = element._getListItems();
-      assert.isTrue(items[0].hasAttribute('highlight'));
-      assert.isFalse(items[1].hasAttribute('highlight'));
-    });
-
-    test('_computeItemHighlight gives false for null account', () => {
-      assert.isFalse(
-          element._computeItemHighlight(null, {assignee: {_account_id: 42}}));
-    });
-
-    test('_computeItemAbsoluteIndex', () => {
-      sandbox.stub(element, '_computeLabelNames');
-      element.sections = [
-        {results: new Array(1)},
-        {results: new Array(2)},
-        {results: new Array(3)},
-      ];
-
-      assert.equal(element._computeItemAbsoluteIndex(0, 0), 0);
-      // Out of range but no matter.
-      assert.equal(element._computeItemAbsoluteIndex(0, 1), 1);
-
-      assert.equal(element._computeItemAbsoluteIndex(1, 0), 1);
-      assert.equal(element._computeItemAbsoluteIndex(1, 1), 2);
-      assert.equal(element._computeItemAbsoluteIndex(1, 2), 3);
-      assert.equal(element._computeItemAbsoluteIndex(2, 0), 3);
-      assert.equal(element._computeItemAbsoluteIndex(3, 0), 6);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
new file mode 100644
index 0000000..9c8b04d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -0,0 +1,636 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+
+const basicFixture = fixtureFromElement('gr-change-list');
+
+suite('gr-change-list basic tests', () => {
+  let element;
+
+  suiteSetup(() => {
+    const kb = TestKeyboardShortcutBinder.push();
+    kb.bindShortcut(Shortcut.CURSOR_NEXT_CHANGE, 'j');
+    kb.bindShortcut(Shortcut.CURSOR_PREV_CHANGE, 'k');
+    kb.bindShortcut(Shortcut.OPEN_CHANGE, 'o');
+    kb.bindShortcut(Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
+    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
+    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
+    kb.bindShortcut(Shortcut.NEXT_PAGE, 'n');
+    kb.bindShortcut(Shortcut.NEXT_PAGE, 'p');
+  });
+
+  suiteTeardown(() => {
+    TestKeyboardShortcutBinder.pop();
+  });
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('test show change number not logged in', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.account = null;
+      element.preferences = null;
+      element._config = {};
+    });
+
+    test('show number disabled', () => {
+      assert.isFalse(element.showNumber);
+    });
+  });
+
+  suite('test show change number preference enabled', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [],
+      };
+      element.account = {_account_id: 1001};
+      element._config = {};
+      flushAsynchronousOperations();
+    });
+
+    test('show number enabled', () => {
+      assert.isTrue(element.showNumber);
+    });
+  });
+
+  suite('test show change number preference disabled', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      // legacycid_in_change_table is not set when false.
+      element.preferences = {
+        time_format: 'HHMM_12',
+        change_table: [],
+      };
+      element.account = {_account_id: 1001};
+      element._config = {};
+      flushAsynchronousOperations();
+    });
+
+    test('show number disabled', () => {
+      assert.isFalse(element.showNumber);
+    });
+  });
+
+  test('computed fields', () => {
+    assert.equal(element._computeLabelNames(
+        [{results: [{_number: 0, labels: {}}]}]).length, 0);
+    assert.equal(element._computeLabelNames([
+      {results: [
+        {_number: 0, labels: {Verified: {approved: {}}}},
+        {
+          _number: 1,
+          labels: {
+            'Verified': {approved: {}},
+            'Code-Review': {approved: {}},
+          },
+        },
+        {
+          _number: 2,
+          labels: {
+            'Verified': {approved: {}},
+            'Library-Compliance': {approved: {}},
+          },
+        },
+      ]},
+    ]).length, 3);
+
+    assert.equal(element._computeLabelShortcut('Code-Review'), 'CR');
+    assert.equal(element._computeLabelShortcut('Verified'), 'V');
+    assert.equal(element._computeLabelShortcut('Library-Compliance'), 'LC');
+    assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
+    assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
+    assert.equal(element._computeLabelShortcut(
+        'Invalid-Prolog-Rules-Label-Name--Verified'), 'V');
+    assert.equal(element._computeLabelShortcut(
+        'Some-Special-Label-7'), 'SSL7');
+    assert.equal(element._computeLabelShortcut('--Too----many----dashes---'),
+        'TMD');
+    assert.equal(element._computeLabelShortcut(
+        'Really-rather-entirely-too-long-of-a-label-name'), 'RRETL');
+  });
+
+  test('colspans', () => {
+    element.sections = [
+      {results: [{}]},
+    ];
+    flushAsynchronousOperations();
+    const tdItemCount = dom(element.root).querySelectorAll(
+        'td').length;
+
+    const changeTableColumns = [];
+    const labelNames = [];
+    assert.equal(tdItemCount, element._computeColspan(
+        changeTableColumns, labelNames));
+  });
+
+  test('keyboard shortcuts', done => {
+    sinon.stub(element, '_computeLabelNames');
+    element.sections = [
+      {results: new Array(1)},
+      {results: new Array(2)},
+    ];
+    element.selectedIndex = 0;
+    element.changes = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    flushAsynchronousOperations();
+    afterNextRender(element, () => {
+      const elementItems = dom(element.root).querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(elementItems.length, 3);
+
+      assert.isTrue(elementItems[0].hasAttribute('selected'));
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert.equal(element.selectedIndex, 1);
+      assert.isTrue(elementItems[1].hasAttribute('selected'));
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert.equal(element.selectedIndex, 2);
+      assert.isTrue(elementItems[2].hasAttribute('selected'));
+
+      const navStub = sinon.stub(GerritNav, 'navigateToChange');
+      assert.equal(element.selectedIndex, 2);
+      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
+          'Should navigate to /c/2/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      assert.equal(element.selectedIndex, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
+          'Should navigate to /c/1/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      assert.equal(element.selectedIndex, 0);
+
+      const reloadStub = sinon.stub(element, '_reloadWindow');
+      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      assert.isTrue(reloadStub.called);
+
+      done();
+    });
+  });
+
+  test('changes needing review', () => {
+    element.changes = [
+      {
+        _number: 0,
+        status: 'NEW',
+        reviewed: true,
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 1,
+        status: 'NEW',
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 2,
+        status: 'MERGED',
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 3,
+        status: 'ABANDONED',
+        owner: {_account_id: 0},
+      },
+      {
+        _number: 4,
+        status: 'NEW',
+        work_in_progress: true,
+        owner: {_account_id: 0},
+      },
+    ];
+    flushAsynchronousOperations();
+    let elementItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(elementItems.length, 5);
+    for (let i = 0; i < elementItems.length; i++) {
+      assert.isFalse(elementItems[i].hasAttribute('needs-review'));
+    }
+
+    element.showReviewedState = true;
+    elementItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(elementItems.length, 5);
+    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
+    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
+
+    element.account = {_account_id: 42};
+    elementItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(elementItems.length, 5);
+    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
+    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
+    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
+
+    element._config = {
+      change: {enable_attention_set: true},
+    };
+    elementItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    for (let i = 0; i < elementItems.length; i++) {
+      assert.isFalse(elementItems[i].hasAttribute('needs-review'));
+    }
+  });
+
+  test('no changes', () => {
+    element.changes = [];
+    flushAsynchronousOperations();
+    const listItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(listItems.length, 0);
+    const noChangesMsg =
+        dom(element.root).querySelector('.noChanges');
+    assert.ok(noChangesMsg);
+  });
+
+  test('empty sections', () => {
+    element.sections = [{results: []}, {results: []}];
+    flushAsynchronousOperations();
+    const listItems = dom(element.root).querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(listItems.length, 0);
+    const noChangesMsg = dom(element.root).querySelectorAll(
+        '.noChanges');
+    assert.equal(noChangesMsg.length, 2);
+  });
+
+  suite('empty outgoing', () => {
+    test('not shown on empty non-outgoing sections', () => {
+      const section = {results: []};
+      assert.isTrue(element._isEmpty(section));
+      assert.isFalse(element._isOutgoing(section));
+    });
+
+    test('shown on empty outgoing sections', () => {
+      const section = {results: [], isOutgoing: true};
+      assert.isTrue(element._isEmpty(section));
+      assert.isTrue(element._isOutgoing(section));
+    });
+
+    test('not shown on non-empty outgoing sections', () => {
+      const section = {isOutgoing: true, results: [
+        {_number: 0, labels: {Verified: {approved: {}}}}]};
+      assert.isFalse(element._isEmpty(section));
+      assert.isTrue(element._isOutgoing(section));
+    });
+  });
+
+  test('_isOutgoing', () => {
+    assert.isTrue(element._isOutgoing({results: [], isOutgoing: true}));
+    assert.isFalse(element._isOutgoing({results: []}));
+  });
+
+  suite('empty column preference', () => {
+    let element;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.sections = [
+        {results: [{}]},
+      ];
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [],
+      };
+      element._config = {};
+      flushAsynchronousOperations();
+    });
+
+    test('show number enabled', () => {
+      assert.isTrue(element.showNumber);
+    });
+
+    test('all columns visible', () => {
+      for (const column of element.columnNames) {
+        const elementClass = '.' + element._lowerCase(column);
+        assert.isFalse(element.shadowRoot
+            .querySelector(elementClass).hidden);
+      }
+    });
+  });
+
+  suite('full column preference', () => {
+    let element;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.sections = [
+        {results: [{}]},
+      ];
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [
+          'Subject',
+          'Status',
+          'Owner',
+          'Assignee',
+          'Reviewers',
+          'Comments',
+          'Repo',
+          'Branch',
+          'Updated',
+          'Size',
+        ],
+      };
+      element._config = {};
+      flushAsynchronousOperations();
+    });
+
+    test('all columns visible', () => {
+      for (const column of element.changeTableColumns) {
+        const elementClass = '.' + element._lowerCase(column);
+        assert.isFalse(element.shadowRoot
+            .querySelector(elementClass).hidden);
+      }
+    });
+  });
+
+  suite('partial column preference', () => {
+    let element;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.sections = [
+        {results: [{}]},
+      ];
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [
+          'Subject',
+          'Status',
+          'Owner',
+          'Assignee',
+          'Reviewers',
+          'Comments',
+          'Branch',
+          'Updated',
+          'Size',
+        ],
+      };
+      element._config = {};
+      flushAsynchronousOperations();
+    });
+
+    test('all columns except repo visible', () => {
+      for (const column of element.changeTableColumns) {
+        const elementClass = '.' + column.toLowerCase();
+        if (column === 'Repo') {
+          assert.isTrue(element.shadowRoot
+              .querySelector(elementClass).hidden);
+        } else {
+          assert.isFalse(element.shadowRoot
+              .querySelector(elementClass).hidden);
+        }
+      }
+    });
+  });
+
+  suite('random column does not exist', () => {
+    let element;
+
+    /* This would only exist if somebody manually updated the config
+    file. */
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.account = {_account_id: 1001};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: 'HHMM_12',
+        change_table: [
+          'Bad',
+        ],
+      };
+      flushAsynchronousOperations();
+    });
+
+    test('bad column does not exist', () => {
+      const elementClass = '.bad';
+      assert.isNotOk(element.shadowRoot
+          .querySelector(elementClass));
+    });
+  });
+
+  suite('dashboard queries', () => {
+    let element;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+    });
+
+    teardown(() => { sinon.restore(); });
+
+    test('query without age and limit unchanged', () => {
+      const query = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), query);
+    });
+
+    test('query with age and limit', () => {
+      const query = 'status:closed age:1week limit:10 owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with age', () => {
+      const query = 'status:closed age:1week owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with limit', () => {
+      const query = 'status:closed limit:10 owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with age as value and not key', () => {
+      const query = 'status:closed random:age';
+      const expectedQuery = 'status:closed random:age';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with limit as value and not key', () => {
+      const query = 'status:closed random:limit';
+      const expectedQuery = 'status:closed random:limit';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+
+    test('query with -age key', () => {
+      const query = 'status:closed -age:1week';
+      const expectedQuery = 'status:closed';
+      assert.deepEqual(element._processQuery(query), expectedQuery);
+    });
+  });
+
+  suite('gr-change-list sections', () => {
+    let element;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+    });
+
+    test('keyboard shortcuts', done => {
+      element.selectedIndex = 0;
+      element.sections = [
+        {
+          results: [
+            {_number: 0},
+            {_number: 1},
+            {_number: 2},
+          ],
+        },
+        {
+          results: [
+            {_number: 3},
+            {_number: 4},
+            {_number: 5},
+          ],
+        },
+        {
+          results: [
+            {_number: 6},
+            {_number: 7},
+            {_number: 8},
+          ],
+        },
+      ];
+      flushAsynchronousOperations();
+      afterNextRender(element, () => {
+        const elementItems = dom(element.root).querySelectorAll(
+            'gr-change-list-item');
+        assert.equal(elementItems.length, 9);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+
+        const navStub = sinon.stub(GerritNav, 'navigateToChange');
+        assert.equal(element.selectedIndex, 2);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+        assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
+            'Should navigate to /c/2/');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+        assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
+            'Should navigate to /c/1/');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+        assert.equal(element.selectedIndex, 4);
+        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+        assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
+            'Should navigate to /c/4/');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+        const change = element._changeForIndex(element.selectedIndex);
+        assert.equal(change.reviewed, true,
+            'Should mark change as reviewed');
+        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+        assert.equal(change.reviewed, false,
+            'Should mark change as unreviewed');
+        done();
+      });
+    });
+
+    test('highlight attribute is updated correctly', () => {
+      element.changes = [
+        {
+          _number: 0,
+          status: 'NEW',
+          owner: {_account_id: 0},
+        },
+        {
+          _number: 1,
+          status: 'ABANDONED',
+          owner: {_account_id: 0},
+        },
+      ];
+      element.account = {_account_id: 42};
+      flushAsynchronousOperations();
+      let items = element._getListItems();
+      assert.equal(items.length, 2);
+      assert.isFalse(items[0].hasAttribute('highlight'));
+      assert.isFalse(items[1].hasAttribute('highlight'));
+
+      // Assign all issues to the user, but only the first one is highlighted
+      // because the second one is abandoned.
+      element.set(['changes', 0, 'assignee'], {_account_id: 12});
+      element.set(['changes', 1, 'assignee'], {_account_id: 12});
+      element.account = {_account_id: 12};
+      flushAsynchronousOperations();
+      items = element._getListItems();
+      assert.isTrue(items[0].hasAttribute('highlight'));
+      assert.isFalse(items[1].hasAttribute('highlight'));
+    });
+
+    test('_computeItemHighlight gives false for null account', () => {
+      assert.isFalse(
+          element._computeItemHighlight(null, {assignee: {_account_id: 42}}));
+    });
+
+    test('_computeItemAbsoluteIndex', () => {
+      sinon.stub(element, '_computeLabelNames');
+      element.sections = [
+        {results: new Array(1)},
+        {results: new Array(2)},
+        {results: new Array(3)},
+      ];
+
+      assert.equal(element._computeItemAbsoluteIndex(0, 0), 0);
+      // Out of range but no matter.
+      assert.equal(element._computeItemAbsoluteIndex(0, 1), 1);
+
+      assert.equal(element._computeItemAbsoluteIndex(1, 0), 1);
+      assert.equal(element._computeItemAbsoluteIndex(1, 1), 2);
+      assert.equal(element._computeItemAbsoluteIndex(1, 2), 3);
+      assert.equal(element._computeItemAbsoluteIndex(2, 0), 3);
+      assert.equal(element._computeItemAbsoluteIndex(3, 0), 6);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
index 3758a78..d9fd378 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-button/gr-button.js';
@@ -24,7 +23,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-create-change-help_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrCreateChangeHelp extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js
deleted file mode 100644
index 4a357af..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.js
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    #graphic,
-    #help {
-      display: inline-block;
-      margin: var(--spacing-m);
-    }
-    #graphic #circle {
-      align-items: center;
-      background-color: var(--chip-background-color);
-      border-radius: 50%;
-      display: flex;
-      height: 10em;
-      justify-content: center;
-      width: 10em;
-    }
-    #graphic iron-icon {
-      color: #9e9e9e;
-      height: 5em;
-      width: 5em;
-    }
-    #graphic p {
-      color: var(--deemphasized-text-color);
-      text-align: center;
-    }
-    #help {
-      padding-top: var(--spacing-xl);
-      vertical-align: top;
-    }
-    #help h1 {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-    }
-    #help p {
-      margin-bottom: var(--spacing-m);
-      max-width: 35em;
-    }
-    @media only screen and (max-width: 50em) {
-      #graphic {
-        display: none;
-      }
-    }
-  </style>
-  <div id="graphic">
-    <div id="circle">
-      <iron-icon id="icon" icon="gr-icons:zeroState"></iron-icon>
-    </div>
-    <p>
-      No outgoing changes yet
-    </p>
-  </div>
-  <div id="help">
-    <h1>Push your first change for code review</h1>
-    <p>
-      Pushing a change for review is easy, but a little different from other git
-      code review tools. Click on the \`Create Change' button and follow the
-      step by step instructions.
-    </p>
-    <gr-button on-click="_handleCreateTap">Create Change</gr-button>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
new file mode 100644
index 0000000..c2f97a6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    #graphic,
+    #help {
+      display: inline-block;
+      margin: var(--spacing-m);
+    }
+    #graphic #circle {
+      align-items: center;
+      background-color: var(--chip-background-color);
+      border-radius: 50%;
+      display: flex;
+      height: 10em;
+      justify-content: center;
+      width: 10em;
+    }
+    #graphic iron-icon {
+      color: #9e9e9e;
+      height: 5em;
+      width: 5em;
+    }
+    #graphic p {
+      color: var(--deemphasized-text-color);
+      text-align: center;
+    }
+    #help {
+      padding-top: var(--spacing-xl);
+      vertical-align: top;
+    }
+    #help p {
+      margin-bottom: var(--spacing-m);
+      max-width: 35em;
+    }
+    @media only screen and (max-width: 50em) {
+      #graphic {
+        display: none;
+      }
+    }
+  </style>
+  <div id="graphic">
+    <div id="circle">
+      <iron-icon id="icon" icon="gr-icons:zeroState"></iron-icon>
+    </div>
+    <p>
+      No outgoing changes yet
+    </p>
+  </div>
+  <div id="help">
+    <h2 class="heading-3">Push your first change for code review</h2>
+    <p>
+      Pushing a change for review is easy, but a little different from other git
+      code review tools. Click on the \`Create Change' button and follow the
+      step by step instructions.
+    </p>
+    <gr-button on-click="_handleCreateTap">Create Change</gr-button>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html
deleted file mode 100644
index 9b8ed29..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.html
+++ /dev/null
@@ -1,50 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-create-change-help</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-create-change-help></gr-create-change-help>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-create-change-help.js';
-suite('gr-create-change-help tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('Create change tap', done => {
-    element.addEventListener('create-tap', () => done());
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.js
new file mode 100644
index 0000000..9dd0a35
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.js
@@ -0,0 +1,36 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-create-change-help.js';
+
+const basicFixture = fixtureFromElement('gr-create-change-help');
+
+suite('gr-create-change-help tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('Create change tap', done => {
+    element.addEventListener('create-tap', () => done());
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button'));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
index 7e5e749..cc53e49 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 
 import '../../shared/gr-dialog/gr-dialog.js';
 import '../../shared/gr-overlay/gr-overlay.js';
@@ -30,7 +29,7 @@
   PUSH_PREFIX: 'git push origin HEAD:refs/for/',
 };
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrCreateCommandsDialog extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.js b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.js
deleted file mode 100644
index d2a1af9..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.js
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    ol {
-      list-style: decimal;
-      margin-left: var(--spacing-l);
-    }
-    p {
-      margin-bottom: var(--spacing-m);
-    }
-    #commandsDialog {
-      max-width: 40em;
-    }
-  </style>
-  <gr-overlay id="commandsOverlay" with-backdrop="">
-    <gr-dialog
-      id="commandsDialog"
-      confirm-label="Done"
-      cancel-label=""
-      confirm-on-enter=""
-      on-confirm="_handleClose"
-    >
-      <div class="header" slot="header">
-        Create change commands
-      </div>
-      <div class="main" slot="main">
-        <ol>
-          <li>
-            <p>
-              Make the changes to the files on your machine
-            </p>
-          </li>
-          <li>
-            <p>
-              If you are making a new commit use
-            </p>
-            <gr-shell-command
-              command="[[_createNewCommitCommand]]"
-            ></gr-shell-command>
-            <p>
-              Or to amend an existing commit use
-            </p>
-            <gr-shell-command
-              command="[[_amendExistingCommitCommand]]"
-            ></gr-shell-command>
-            <p>
-              Please make sure you add a commit message as it becomes the
-              description for your change.
-            </p>
-          </li>
-          <li>
-            <p>
-              Push the change for code review
-            </p>
-            <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
-          </li>
-          <li>
-            <p>
-              Close this dialog and you should be able to see your recently
-              created change in the 'Outgoing changes' section on the 'Your
-              changes' page.
-            </p>
-          </li>
-        </ol>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.ts
new file mode 100644
index 0000000..9031738
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.ts
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    ol {
+      list-style: decimal;
+      margin-left: var(--spacing-l);
+    }
+    p {
+      margin-bottom: var(--spacing-m);
+    }
+    #commandsDialog {
+      max-width: 40em;
+    }
+  </style>
+  <gr-overlay id="commandsOverlay" with-backdrop="">
+    <gr-dialog
+      id="commandsDialog"
+      confirm-label="Done"
+      cancel-label=""
+      confirm-on-enter=""
+      on-confirm="_handleClose"
+    >
+      <div class="header" slot="header">
+        Create change commands
+      </div>
+      <div class="main" slot="main">
+        <ol>
+          <li>
+            <p>
+              Make the changes to the files on your machine
+            </p>
+          </li>
+          <li>
+            <p>
+              If you are making a new commit use
+            </p>
+            <gr-shell-command
+              command="[[_createNewCommitCommand]]"
+            ></gr-shell-command>
+            <p>
+              Or to amend an existing commit use
+            </p>
+            <gr-shell-command
+              command="[[_amendExistingCommitCommand]]"
+            ></gr-shell-command>
+            <p>
+              Please make sure you add a commit message as it becomes the
+              description for your change.
+            </p>
+          </li>
+          <li>
+            <p>
+              Push the change for code review
+            </p>
+            <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
+          </li>
+          <li>
+            <p>
+              Close this dialog and you should be able to see your recently
+              created change in the 'Outgoing changes' section on the 'Your
+              changes' page.
+            </p>
+          </li>
+        </ol>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
deleted file mode 100644
index e6cd587..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html
+++ /dev/null
@@ -1,54 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-create-commands-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-create-commands-dialog></gr-create-commands-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-create-commands-dialog.js';
-suite('gr-create-commands-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('_computePushCommand', () => {
-    element.branch = 'master';
-    assert.equal(element._pushCommand,
-        'git push origin HEAD:refs/for/master');
-
-    element.branch = 'stable-2.15';
-    assert.equal(element._pushCommand,
-        'git push origin HEAD:refs/for/stable-2.15');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.js b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.js
new file mode 100644
index 0000000..9dbcd29
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.js
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-create-commands-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-create-commands-dialog');
+
+suite('gr-create-commands-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_computePushCommand', () => {
+    element.branch = 'master';
+    assert.equal(element._pushCommand,
+        'git push origin HEAD:refs/for/master');
+
+    element.branch = 'stable-2.15';
+    assert.equal(element._pushCommand,
+        'git push origin HEAD:refs/for/stable-2.15');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
index f8757ba..9062a3f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 
 import '../../shared/gr-dialog/gr-dialog.js';
 import '../../shared/gr-overlay/gr-overlay.js';
@@ -29,7 +28,7 @@
  * name and the branch name.
  *
  * @event confirm
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrCreateDestinationDialog extends GestureEventListeners(
     LegacyElementMixin(
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.js b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.js
deleted file mode 100644
index c7cd647..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles"></style>
-  <gr-overlay id="createOverlay" with-backdrop="">
-    <gr-dialog
-      confirm-label="View commands"
-      on-confirm="_pickerConfirm"
-      on-cancel="_handleClose"
-      disabled="[[!_repoAndBranchSelected]]"
-    >
-      <div class="header" slot="header">
-        Create change
-      </div>
-      <div class="main" slot="main">
-        <gr-repo-branch-picker
-          repo="{{_repo}}"
-          branch="{{_branch}}"
-        ></gr-repo-branch-picker>
-        <p>
-          If you haven't done so, you will need to clone the repository.
-        </p>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.ts
new file mode 100644
index 0000000..9155d9a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles"></style>
+  <gr-overlay id="createOverlay" with-backdrop="">
+    <gr-dialog
+      confirm-label="View commands"
+      on-confirm="_pickerConfirm"
+      on-cancel="_handleClose"
+      disabled="[[!_repoAndBranchSelected]]"
+    >
+      <div class="header" slot="header">
+        Create change
+      </div>
+      <div class="main" slot="main">
+        <gr-repo-branch-picker
+          repo="{{_repo}}"
+          branch="{{_branch}}"
+        ></gr-repo-branch-picker>
+        <p>
+          If you haven't done so, you will need to clone the repository.
+        </p>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index 8b8b981..3b44381 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -14,11 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../gr-change-list/gr-change-list.js';
-import '../../core/gr-reporting/gr-reporting.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-dialog/gr-dialog.js';
 import '../../shared/gr-overlay/gr-overlay.js';
@@ -27,24 +24,21 @@
 import '../gr-create-change-help/gr-create-change-help.js';
 import '../gr-create-destination-dialog/gr-create-destination-dialog.js';
 import '../gr-user-header/gr-user-header.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-dashboard-view_html.js';
-import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {appContext} from '../../../services/app-context.js';
+import {changeIsOpen} from '../../../utils/change-util.js';
 
 const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrDashboardView extends mixinBehaviors( [
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrDashboardView extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-dashboard-view'; }
@@ -98,6 +92,11 @@
     };
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   static get observers() {
     return [
       '_paramsChanged(params.*)',
@@ -108,6 +107,10 @@
   attached() {
     super.attached();
     this._loadPreferences();
+    this.addEventListener('reload', e => {
+      e.stopPropagation();
+      this._reload();
+    });
   }
 
   _loadPreferences() {
@@ -179,10 +182,14 @@
     const {project, dashboard, title, user, sections} = this.params;
     const dashboardPromise = project ?
       this._getProjectDashboard(project, dashboard) :
-      Promise.resolve(GerritNav.getUserDashboard(
-          user,
-          sections,
-          title || this._computeTitle(user)));
+      this.$.restAPI.getConfig().then(
+          config => Promise.resolve(GerritNav.getUserDashboard(
+              user,
+              sections,
+              title || this._computeTitle(user),
+              config
+          ))
+      );
 
     const checkForNewUser = !project && user === 'self';
     return dashboardPromise
@@ -197,7 +204,7 @@
         })
         .then(() => {
           this._maybeShowDraftsBanner();
-          this.$.reporting.dashboardDisplayed();
+          this.reporting.dashboardDisplayed();
         })
         .catch(err => {
           this.dispatchEvent(new CustomEvent('title-change', {
@@ -293,7 +300,7 @@
     if (!draftSection || !draftSection.results.length) { return; }
 
     const closedChanges = draftSection.results
-        .filter(change => !this.changeIsOpen(change));
+        .filter(change => !changeIsOpen(change));
     if (!closedChanges.length) { return; }
 
     this._showDraftsBanner = true;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.js
deleted file mode 100644
index 3389bd0..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.js
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    .loading {
-      color: var(--deemphasized-text-color);
-      padding: var(--spacing-l);
-    }
-    gr-change-list {
-      width: 100%;
-    }
-    gr-user-header {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .banner {
-      align-items: center;
-      background-color: var(--comment-background-color);
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-xs) var(--spacing-l);
-    }
-    .banner gr-button {
-      --gr-button: {
-        color: var(--primary-text-color);
-      }
-    }
-    .hide {
-      display: none;
-    }
-    #emptyOutgoing {
-      display: block;
-    }
-    @media only screen and (max-width: 50em) {
-      .loading {
-        padding: 0 var(--spacing-l);
-      }
-    }
-  </style>
-  <div class$="banner [[_computeBannerClass(_showDraftsBanner)]]">
-    <div>
-      You have draft comments on closed changes.
-      <a href$="[[_computeDraftsLink(_showDraftsBanner)]]" target="_blank"
-        >(view all)</a
-      >
-    </div>
-    <div>
-      <gr-button class="delete" link="" on-click="_handleOpenDeleteDialog"
-        >Delete All</gr-button
-      >
-    </div>
-  </div>
-  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-  <div hidden$="[[_loading]]" hidden="">
-    <gr-user-header
-      user-id="[[params.user]]"
-      class$="[[_computeUserHeaderClass(params)]]"
-    ></gr-user-header>
-    <gr-change-list
-      show-star=""
-      show-reviewed-state=""
-      account="[[account]]"
-      preferences="[[preferences]]"
-      selected-index="{{viewState.selectedChangeIndex}}"
-      sections="[[_results]]"
-      on-toggle-star="_handleToggleStar"
-      on-toggle-reviewed="_handleToggleReviewed"
-    >
-      <div id="emptyOutgoing" slot="empty-outgoing">
-        <template is="dom-if" if="[[_showNewUserHelp]]">
-          <gr-create-change-help
-            on-create-tap="createChangeTap"
-          ></gr-create-change-help>
-        </template>
-        <template is="dom-if" if="[[!_showNewUserHelp]]">
-          No changes
-        </template>
-      </div>
-    </gr-change-list>
-  </div>
-  <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
-    <gr-dialog
-      id="confirmDeleteDialog"
-      confirm-label="Delete"
-      on-confirm="_handleConfirmDelete"
-      on-cancel="_closeConfirmDeleteOverlay"
-    >
-      <div class="header" slot="header">
-        Delete comments
-      </div>
-      <div class="main" slot="main">
-        Are you sure you want to delete all your draft comments in closed
-        changes? This action cannot be undone.
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-  <gr-create-destination-dialog
-    id="destinationDialog"
-    on-confirm="_handleDestinationConfirm"
-  ></gr-create-destination-dialog>
-  <gr-create-commands-dialog id="commandsDialog"></gr-create-commands-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-reporting id="reporting"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
new file mode 100644
index 0000000..ea04c5a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -0,0 +1,122 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    .loading {
+      color: var(--deemphasized-text-color);
+      padding: var(--spacing-l);
+    }
+    gr-change-list {
+      width: 100%;
+    }
+    gr-user-header {
+      border-bottom: 1px solid var(--border-color);
+    }
+    .banner {
+      align-items: center;
+      background-color: var(--comment-background-color);
+      border-bottom: 1px solid var(--border-color);
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-xs) var(--spacing-l);
+    }
+    .banner gr-button {
+      --gr-button: {
+        color: var(--primary-text-color);
+      }
+    }
+    .hide {
+      display: none;
+    }
+    #emptyOutgoing {
+      display: block;
+    }
+    @media only screen and (max-width: 50em) {
+      .loading {
+        padding: 0 var(--spacing-l);
+      }
+    }
+  </style>
+  <div class$="banner [[_computeBannerClass(_showDraftsBanner)]]">
+    <div>
+      You have draft comments on closed changes.
+      <a href$="[[_computeDraftsLink(_showDraftsBanner)]]" target="_blank"
+        >(view all)</a
+      >
+    </div>
+    <div>
+      <gr-button class="delete" link="" on-click="_handleOpenDeleteDialog"
+        >Delete All</gr-button
+      >
+    </div>
+  </div>
+  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+  <div hidden$="[[_loading]]" hidden="">
+    <gr-user-header
+      user-id="[[params.user]]"
+      class$="[[_computeUserHeaderClass(params)]]"
+    ></gr-user-header>
+    <gr-change-list
+      show-star=""
+      show-reviewed-state=""
+      account="[[account]]"
+      preferences="[[preferences]]"
+      selected-index="{{viewState.selectedChangeIndex}}"
+      sections="[[_results]]"
+      on-toggle-star="_handleToggleStar"
+      on-toggle-reviewed="_handleToggleReviewed"
+    >
+      <div id="emptyOutgoing" slot="empty-outgoing">
+        <template is="dom-if" if="[[_showNewUserHelp]]">
+          <gr-create-change-help
+            on-create-tap="createChangeTap"
+          ></gr-create-change-help>
+        </template>
+        <template is="dom-if" if="[[!_showNewUserHelp]]">
+          No changes
+        </template>
+      </div>
+    </gr-change-list>
+  </div>
+  <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
+    <gr-dialog
+      id="confirmDeleteDialog"
+      confirm-label="Delete"
+      on-confirm="_handleConfirmDelete"
+      on-cancel="_closeConfirmDeleteOverlay"
+    >
+      <div class="header" slot="header">
+        Delete comments
+      </div>
+      <div class="main" slot="main">
+        Are you sure you want to delete all your draft comments in closed
+        changes? This action cannot be undone.
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-create-destination-dialog
+    id="destinationDialog"
+    on-confirm="_handleDestinationConfirm"
+  ></gr-create-destination-dialog>
+  <gr-create-commands-dialog id="commandsDialog"></gr-create-commands-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
deleted file mode 100644
index 5965d06..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ /dev/null
@@ -1,381 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-dashboard-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-dashboard-view></gr-dashboard-view>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-dashboard-view.js';
-import {isHidden} from '../../../test/test-utils.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-dashboard-view tests', () => {
-  let element;
-  let sandbox;
-  let paramsChangedPromise;
-  let getChangesStub;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      getAccountDetails() { return Promise.resolve({}); },
-      getAccountStatus() { return Promise.resolve(false); },
-    });
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    getChangesStub = sandbox.stub(element.$.restAPI, 'getChanges',
-        (_, qs) => Promise.resolve(qs.map(() => [])));
-
-    let resolver;
-    paramsChangedPromise = new Promise(resolve => {
-      resolver = resolve;
-    });
-    const paramsChanged = element._paramsChanged.bind(element);
-    sandbox.stub(element, '_paramsChanged', params => {
-      paramsChanged(params).then(() => resolver());
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('drafts banner functionality', () => {
-    suite('_maybeShowDraftsBanner', () => {
-      test('not dashboard/self', () => {
-        element.params = {user: 'notself'};
-        element._maybeShowDraftsBanner();
-        assert.isFalse(element._showDraftsBanner);
-      });
-
-      test('no drafts at all', () => {
-        element.params = {user: 'self'};
-        element._results = [];
-        element._maybeShowDraftsBanner();
-        assert.isFalse(element._showDraftsBanner);
-      });
-
-      test('no drafts on open changes', () => {
-        element.params = {user: 'self'};
-        element._results = [{query: 'has:draft', results: [{status: '_'}]}];
-        sandbox.stub(element, 'changeIsOpen').returns(true);
-        element._maybeShowDraftsBanner();
-        assert.isFalse(element._showDraftsBanner);
-      });
-
-      test('no drafts on open changes', () => {
-        element.params = {user: 'self'};
-        element._results = [{query: 'has:draft', results: [{status: '_'}]}];
-        sandbox.stub(element, 'changeIsOpen').returns(false);
-        element._maybeShowDraftsBanner();
-        assert.isTrue(element._showDraftsBanner);
-      });
-    });
-
-    test('_showDraftsBanner', () => {
-      element._showDraftsBanner = false;
-      flushAsynchronousOperations();
-      assert.isTrue(isHidden(element.shadowRoot
-          .querySelector('.banner')));
-
-      element._showDraftsBanner = true;
-      flushAsynchronousOperations();
-      assert.isFalse(isHidden(element.shadowRoot
-          .querySelector('.banner')));
-    });
-
-    test('delete tap opens dialog', () => {
-      sandbox.stub(element, '_handleOpenDeleteDialog');
-      element._showDraftsBanner = true;
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.banner .delete'));
-      assert.isTrue(element._handleOpenDeleteDialog.called);
-    });
-
-    test('delete comments flow', async () => {
-      sandbox.spy(element, '_handleConfirmDelete');
-      sandbox.stub(element, '_reload');
-
-      // Set up control over timing of when RPC resolves.
-      let deleteDraftCommentsPromiseResolver;
-      const deleteDraftCommentsPromise = new Promise(resolve => {
-        deleteDraftCommentsPromiseResolver = resolve;
-      });
-      sandbox.stub(element.$.restAPI, 'deleteDraftComments')
-          .returns(deleteDraftCommentsPromise);
-
-      // Open confirmation dialog and tap confirm button.
-      await element.$.confirmDeleteOverlay.open();
-      MockInteractions.tap(element.$.confirmDeleteDialog.$.confirm);
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.restAPI.deleteDraftComments
-          .calledWithExactly('-is:open'));
-      assert.isTrue(element.$.confirmDeleteDialog.disabled);
-      assert.equal(element._reload.callCount, 0);
-
-      // Verify state after RPC resolves.
-      deleteDraftCommentsPromiseResolver([]);
-      await deleteDraftCommentsPromise;
-      assert.equal(element._reload.callCount, 1);
-    });
-  });
-
-  test('_computeTitle', () => {
-    assert.equal(element._computeTitle('self'), 'My Reviews');
-    assert.equal(element._computeTitle('not self'), 'Dashboard for not self');
-  });
-
-  suite('_computeSectionCountLabel', () => {
-    test('empty changes dont count label', () => {
-      assert.equal('', element._computeSectionCountLabel([]));
-    });
-
-    test('1 change', () => {
-      assert.equal('(1)',
-          element._computeSectionCountLabel(['1']));
-    });
-
-    test('2 changes', () => {
-      assert.equal('(2)',
-          element._computeSectionCountLabel(['1', '2']));
-    });
-
-    test('1 change and more', () => {
-      assert.equal('(1 and more)',
-          element._computeSectionCountLabel([{_more_changes: true}]));
-    });
-  });
-
-  suite('_isViewActive', () => {
-    test('nothing happens when user param is falsy', () => {
-      element.params = {};
-      flushAsynchronousOperations();
-      assert.equal(getChangesStub.callCount, 0);
-
-      element.params = {user: ''};
-      flushAsynchronousOperations();
-      assert.equal(getChangesStub.callCount, 0);
-    });
-
-    test('content is refreshed when user param is updated', () => {
-      element.params = {
-        view: GerritNav.View.DASHBOARD,
-        user: 'self',
-      };
-      return paramsChangedPromise.then(() => {
-        assert.equal(getChangesStub.callCount, 1);
-      });
-    });
-  });
-
-  suite('selfOnly sections', () => {
-    test('viewing self dashboard includes selfOnly sections', () => {
-      element.params = {
-        view: GerritNav.View.DASHBOARD,
-        sections: [
-          {query: '1'},
-          {query: '2', selfOnly: true},
-        ],
-        user: 'self',
-      };
-      return paramsChangedPromise.then(() => {
-        assert.isTrue(
-            getChangesStub.calledWith(null, ['1', '2', 'owner:self limit:1']));
-      });
-    });
-
-    test('viewing another user\'s dashboard omits selfOnly sections', () => {
-      element.params = {
-        view: GerritNav.View.DASHBOARD,
-        sections: [
-          {query: '1'},
-          {query: '2', selfOnly: true},
-        ],
-        user: 'user',
-      };
-      return paramsChangedPromise.then(() => {
-        assert.isTrue(getChangesStub.calledWith(null, ['1']));
-      });
-    });
-  });
-
-  test('suffixForDashboard is included in getChanges query', () => {
-    element.params = {
-      view: GerritNav.View.DASHBOARD,
-      sections: [
-        {query: '1'},
-        {query: '2', suffixForDashboard: 'suffix'},
-      ],
-    };
-    return paramsChangedPromise.then(() => {
-      assert.isTrue(getChangesStub.calledOnce);
-      assert.deepEqual(
-          getChangesStub.firstCall.args, [null, ['1', '2 suffix']]);
-    });
-  });
-
-  suite('_getProjectDashboard', () => {
-    test('dashboard with foreach', () => {
-      sandbox.stub(element.$.restAPI, 'getDashboard', () => Promise.resolve({
-        title: 'title',
-        foreach: 'foreach for ${project}',
-        sections: [
-          {name: 'section 1', query: 'query 1'},
-          {name: 'section 2', query: '${project} query 2'},
-        ],
-      }));
-      return element._getProjectDashboard('project', '').then(dashboard => {
-        assert.deepEqual(
-            dashboard,
-            {
-              title: 'title',
-              sections: [
-                {name: 'section 1', query: 'query 1 foreach for project'},
-                {
-                  name: 'section 2',
-                  query: 'project query 2 foreach for project',
-                },
-              ],
-            });
-      });
-    });
-
-    test('dashboard without foreach', () => {
-      sandbox.stub(element.$.restAPI, 'getDashboard', () => Promise.resolve({
-        title: 'title',
-        sections: [
-          {name: 'section 1', query: 'query 1'},
-          {name: 'section 2', query: '${project} query 2'},
-        ],
-      }));
-      return element._getProjectDashboard('project', '').then(dashboard => {
-        assert.deepEqual(
-            dashboard,
-            {
-              title: 'title',
-              sections: [
-                {name: 'section 1', query: 'query 1'},
-                {name: 'section 2', query: 'project query 2'},
-              ],
-            });
-      });
-    });
-  });
-
-  test('hideIfEmpty sections', () => {
-    const sections = [
-      {name: 'test1', query: 'test1', hideIfEmpty: true},
-      {name: 'test2', query: 'test2', hideIfEmpty: true},
-    ];
-    getChangesStub.restore();
-    sandbox.stub(element.$.restAPI, 'getChanges')
-        .returns(Promise.resolve([[], ['nonempty']]));
-
-    return element._fetchDashboardChanges({sections}, false).then(() => {
-      assert.equal(element._results.length, 1);
-      assert.equal(element._results[0].name, 'test2');
-    });
-  });
-
-  test('preserve isOutgoing sections', () => {
-    const sections = [
-      {name: 'test1', query: 'test1', isOutgoing: true},
-      {name: 'test2', query: 'test2'},
-    ];
-    getChangesStub.restore();
-    sandbox.stub(element.$.restAPI, 'getChanges')
-        .returns(Promise.resolve([[], []]));
-
-    return element._fetchDashboardChanges({sections}, false).then(() => {
-      assert.equal(element._results.length, 2);
-      assert.isTrue(element._results[0].isOutgoing);
-      assert.isNotOk(element._results[1].isOutgoing);
-    });
-  });
-
-  test('_showNewUserHelp', () => {
-    element._loading = false;
-    element._showNewUserHelp = false;
-    flushAsynchronousOperations();
-
-    assert.equal(element.$.emptyOutgoing.textContent.trim(), 'No changes');
-    assert.isNotOk(element.shadowRoot
-        .querySelector('gr-create-change-help'));
-    element._showNewUserHelp = true;
-    flushAsynchronousOperations();
-
-    assert.notEqual(element.$.emptyOutgoing.textContent.trim(), 'No changes');
-    assert.isOk(element.shadowRoot
-        .querySelector('gr-create-change-help'));
-  });
-
-  test('_computeUserHeaderClass', () => {
-    assert.equal(element._computeUserHeaderClass(undefined), 'hide');
-    assert.equal(element._computeUserHeaderClass({}), 'hide');
-    assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
-    assert.equal(element._computeUserHeaderClass({user: 'user'}), '');
-    assert.equal(
-        element._computeUserHeaderClass({project: 'p', user: 'user'}),
-        'hide');
-  });
-
-  test('404 page', done => {
-    const response = {status: 404};
-    sandbox.stub(element.$.restAPI, 'getDashboard',
-        async (project, dashboard, errFn) => {
-          errFn(response);
-        });
-    element.addEventListener('page-error', e => {
-      assert.strictEqual(e.detail.response, response);
-      done();
-    });
-    element.params = {
-      view: GerritNav.View.DASHBOARD,
-      project: 'project',
-      dashboard: 'dashboard',
-    };
-  });
-
-  test('params change triggers dashboardDisplayed()', () => {
-    sandbox.stub(element.$.reporting, 'dashboardDisplayed');
-    element.params = {
-      view: GerritNav.View.DASHBOARD,
-      project: 'project',
-      dashboard: 'dashboard',
-    };
-    return paramsChangedPromise.then(() => {
-      assert.isTrue(element.$.reporting.dashboardDisplayed.calledOnce);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
new file mode 100644
index 0000000..f56ad75
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
@@ -0,0 +1,367 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-dashboard-view.js';
+import {isHidden} from '../../../test/test-utils.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {changeIsOpen} from '../../../utils/change-util.js';
+import {ChangeStatus} from '../../../constants/constants.js';
+
+const basicFixture = fixtureFromElement('gr-dashboard-view');
+
+suite('gr-dashboard-view tests', () => {
+  let element;
+
+  let paramsChangedPromise;
+  let getChangesStub;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getAccountDetails() { return Promise.resolve({}); },
+      getAccountStatus() { return Promise.resolve(false); },
+    });
+    element = basicFixture.instantiate();
+
+    getChangesStub = sinon.stub(element.$.restAPI, 'getChanges').callsFake(
+        (_, qs) => Promise.resolve(qs.map(() => [])));
+
+    let resolver;
+    paramsChangedPromise = new Promise(resolve => {
+      resolver = resolve;
+    });
+    const paramsChanged = element._paramsChanged.bind(element);
+    sinon.stub(element, '_paramsChanged').callsFake( params => {
+      paramsChanged(params).then(() => resolver());
+    });
+  });
+
+  suite('drafts banner functionality', () => {
+    suite('_maybeShowDraftsBanner', () => {
+      test('not dashboard/self', () => {
+        element.params = {user: 'notself'};
+        element._maybeShowDraftsBanner();
+        assert.isFalse(element._showDraftsBanner);
+      });
+
+      test('no drafts at all', () => {
+        element.params = {user: 'self'};
+        element._results = [];
+        element._maybeShowDraftsBanner();
+        assert.isFalse(element._showDraftsBanner);
+      });
+
+      test('no drafts on open changes', () => {
+        element.params = {user: 'self'};
+        const openChange = {status: ChangeStatus.NEW};
+        element._results = [{query: 'has:draft', results: [openChange]}];
+        element._maybeShowDraftsBanner();
+        assert.isFalse(element._showDraftsBanner);
+      });
+
+      test('no drafts on not open changes', () => {
+        element.params = {user: 'self'};
+        const notOpenChange = {status: '_'};
+        element._results = [{query: 'has:draft', results: [notOpenChange]}];
+        assert.isFalse(changeIsOpen(element._results[0].results[0]));
+        element._maybeShowDraftsBanner();
+        assert.isTrue(element._showDraftsBanner);
+      });
+    });
+
+    test('_showDraftsBanner', () => {
+      element._showDraftsBanner = false;
+      flushAsynchronousOperations();
+      assert.isTrue(isHidden(element.shadowRoot
+          .querySelector('.banner')));
+
+      element._showDraftsBanner = true;
+      flushAsynchronousOperations();
+      assert.isFalse(isHidden(element.shadowRoot
+          .querySelector('.banner')));
+    });
+
+    test('delete tap opens dialog', () => {
+      sinon.stub(element, '_handleOpenDeleteDialog');
+      element._showDraftsBanner = true;
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.banner .delete'));
+      assert.isTrue(element._handleOpenDeleteDialog.called);
+    });
+
+    test('delete comments flow', async () => {
+      sinon.spy(element, '_handleConfirmDelete');
+      sinon.stub(element, '_reload');
+
+      // Set up control over timing of when RPC resolves.
+      let deleteDraftCommentsPromiseResolver;
+      const deleteDraftCommentsPromise = new Promise(resolve => {
+        deleteDraftCommentsPromiseResolver = resolve;
+      });
+      sinon.stub(element.$.restAPI, 'deleteDraftComments')
+          .returns(deleteDraftCommentsPromise);
+
+      // Open confirmation dialog and tap confirm button.
+      await element.$.confirmDeleteOverlay.open();
+      MockInteractions.tap(element.$.confirmDeleteDialog.$.confirm);
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.restAPI.deleteDraftComments
+          .calledWithExactly('-is:open'));
+      assert.isTrue(element.$.confirmDeleteDialog.disabled);
+      assert.equal(element._reload.callCount, 0);
+
+      // Verify state after RPC resolves.
+      deleteDraftCommentsPromiseResolver([]);
+      await deleteDraftCommentsPromise;
+      assert.equal(element._reload.callCount, 1);
+    });
+  });
+
+  test('_computeTitle', () => {
+    assert.equal(element._computeTitle('self'), 'My Reviews');
+    assert.equal(element._computeTitle('not self'), 'Dashboard for not self');
+  });
+
+  suite('_computeSectionCountLabel', () => {
+    test('empty changes dont count label', () => {
+      assert.equal('', element._computeSectionCountLabel([]));
+    });
+
+    test('1 change', () => {
+      assert.equal('(1)',
+          element._computeSectionCountLabel(['1']));
+    });
+
+    test('2 changes', () => {
+      assert.equal('(2)',
+          element._computeSectionCountLabel(['1', '2']));
+    });
+
+    test('1 change and more', () => {
+      assert.equal('(1 and more)',
+          element._computeSectionCountLabel([{_more_changes: true}]));
+    });
+  });
+
+  suite('_isViewActive', () => {
+    test('nothing happens when user param is falsy', () => {
+      element.params = {};
+      flushAsynchronousOperations();
+      assert.equal(getChangesStub.callCount, 0);
+
+      element.params = {user: ''};
+      flushAsynchronousOperations();
+      assert.equal(getChangesStub.callCount, 0);
+    });
+
+    test('content is refreshed when user param is updated', () => {
+      element.params = {
+        view: GerritNav.View.DASHBOARD,
+        user: 'self',
+      };
+      return paramsChangedPromise.then(() => {
+        assert.equal(getChangesStub.callCount, 1);
+      });
+    });
+  });
+
+  suite('selfOnly sections', () => {
+    test('viewing self dashboard includes selfOnly sections', () => {
+      element.params = {
+        view: GerritNav.View.DASHBOARD,
+        sections: [
+          {query: '1'},
+          {query: '2', selfOnly: true},
+        ],
+        user: 'self',
+      };
+      return paramsChangedPromise.then(() => {
+        assert.isTrue(
+            getChangesStub.calledWith(null, ['1', '2', 'owner:self limit:1']));
+      });
+    });
+
+    test('viewing another user\'s dashboard omits selfOnly sections', () => {
+      element.params = {
+        view: GerritNav.View.DASHBOARD,
+        sections: [
+          {query: '1'},
+          {query: '2', selfOnly: true},
+        ],
+        user: 'user',
+      };
+      return paramsChangedPromise.then(() => {
+        assert.isTrue(getChangesStub.calledWith(null, ['1']));
+      });
+    });
+  });
+
+  test('suffixForDashboard is included in getChanges query', () => {
+    element.params = {
+      view: GerritNav.View.DASHBOARD,
+      sections: [
+        {query: '1'},
+        {query: '2', suffixForDashboard: 'suffix'},
+      ],
+    };
+    return paramsChangedPromise.then(() => {
+      assert.isTrue(getChangesStub.calledOnce);
+      assert.deepEqual(
+          getChangesStub.firstCall.args, [null, ['1', '2 suffix']]);
+    });
+  });
+
+  suite('_getProjectDashboard', () => {
+    test('dashboard with foreach', () => {
+      sinon.stub(element.$.restAPI, 'getDashboard')
+          .callsFake( () => Promise.resolve({
+            title: 'title',
+            foreach: 'foreach for ${project}',
+            sections: [
+              {name: 'section 1', query: 'query 1'},
+              {name: 'section 2', query: '${project} query 2'},
+            ],
+          }));
+      return element._getProjectDashboard('project', '').then(dashboard => {
+        assert.deepEqual(
+            dashboard,
+            {
+              title: 'title',
+              sections: [
+                {name: 'section 1', query: 'query 1 foreach for project'},
+                {
+                  name: 'section 2',
+                  query: 'project query 2 foreach for project',
+                },
+              ],
+            });
+      });
+    });
+
+    test('dashboard without foreach', () => {
+      sinon.stub(element.$.restAPI, 'getDashboard').callsFake(
+          () => Promise.resolve({
+            title: 'title',
+            sections: [
+              {name: 'section 1', query: 'query 1'},
+              {name: 'section 2', query: '${project} query 2'},
+            ],
+          }));
+      return element._getProjectDashboard('project', '').then(dashboard => {
+        assert.deepEqual(
+            dashboard,
+            {
+              title: 'title',
+              sections: [
+                {name: 'section 1', query: 'query 1'},
+                {name: 'section 2', query: 'project query 2'},
+              ],
+            });
+      });
+    });
+  });
+
+  test('hideIfEmpty sections', () => {
+    const sections = [
+      {name: 'test1', query: 'test1', hideIfEmpty: true},
+      {name: 'test2', query: 'test2', hideIfEmpty: true},
+    ];
+    getChangesStub.restore();
+    sinon.stub(element.$.restAPI, 'getChanges')
+        .returns(Promise.resolve([[], ['nonempty']]));
+
+    return element._fetchDashboardChanges({sections}, false).then(() => {
+      assert.equal(element._results.length, 1);
+      assert.equal(element._results[0].name, 'test2');
+    });
+  });
+
+  test('preserve isOutgoing sections', () => {
+    const sections = [
+      {name: 'test1', query: 'test1', isOutgoing: true},
+      {name: 'test2', query: 'test2'},
+    ];
+    getChangesStub.restore();
+    sinon.stub(element.$.restAPI, 'getChanges')
+        .returns(Promise.resolve([[], []]));
+
+    return element._fetchDashboardChanges({sections}, false).then(() => {
+      assert.equal(element._results.length, 2);
+      assert.isTrue(element._results[0].isOutgoing);
+      assert.isNotOk(element._results[1].isOutgoing);
+    });
+  });
+
+  test('_showNewUserHelp', () => {
+    element._loading = false;
+    element._showNewUserHelp = false;
+    flushAsynchronousOperations();
+
+    assert.equal(element.$.emptyOutgoing.textContent.trim(), 'No changes');
+    assert.isNotOk(element.shadowRoot
+        .querySelector('gr-create-change-help'));
+    element._showNewUserHelp = true;
+    flushAsynchronousOperations();
+
+    assert.notEqual(element.$.emptyOutgoing.textContent.trim(), 'No changes');
+    assert.isOk(element.shadowRoot
+        .querySelector('gr-create-change-help'));
+  });
+
+  test('_computeUserHeaderClass', () => {
+    assert.equal(element._computeUserHeaderClass(undefined), 'hide');
+    assert.equal(element._computeUserHeaderClass({}), 'hide');
+    assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
+    assert.equal(element._computeUserHeaderClass({user: 'user'}), '');
+    assert.equal(
+        element._computeUserHeaderClass({project: 'p', user: 'user'}),
+        'hide');
+  });
+
+  test('404 page', done => {
+    const response = {status: 404};
+    sinon.stub(element.$.restAPI, 'getDashboard').callsFake(
+        async (project, dashboard, errFn) => {
+          errFn(response);
+        });
+    element.addEventListener('page-error', e => {
+      assert.strictEqual(e.detail.response, response);
+      paramsChangedPromise.then(done);
+    });
+    element.params = {
+      view: GerritNav.View.DASHBOARD,
+      project: 'project',
+      dashboard: 'dashboard',
+    };
+  });
+
+  test('params change triggers dashboardDisplayed()', () => {
+    sinon.stub(element.reporting, 'dashboardDisplayed');
+    element.params = {
+      view: GerritNav.View.DASHBOARD,
+      project: 'project',
+      dashboard: 'dashboard',
+    };
+    return paramsChangedPromise.then(() => {
+      assert.isTrue(element.reporting.dashboardDisplayed.calledOnce);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
deleted file mode 100644
index 2523700..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import '../gr-change-list/gr-change-list.js';
-import '../gr-create-change-help/gr-create-change-help.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-embed-dashboard_html.js';
-
-/** @extends Polymer.Element */
-class GrEmbedDashboard extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-embed-dashboard'; }
-
-  static get properties() {
-    return {
-      account: Object,
-      sections: Array,
-      preferences: Object,
-      showNewUserHelp: Boolean,
-    };
-  }
-}
-
-customElements.define(GrEmbedDashboard.is, GrEmbedDashboard);
diff --git a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard_html.js b/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard_html.js
deleted file mode 100644
index 802e365..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-embed-dashboard/gr-embed-dashboard_html.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <gr-change-list
-    show-star=""
-    account="[[account]]"
-    preferences="[[preferences]]"
-    sections="[[sections]]"
-  >
-    <div id="emptyOutgoing" slot="empty-outgoing">
-      <template is="dom-if" if="[[showNewUserHelp]]">
-        <gr-create-change-help></gr-create-change-help>
-      </template>
-      <template is="dom-if" if="[[!showNewUserHelp]]">
-        No changes
-      </template>
-    </div>
-  </gr-change-list>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
index 5f0021e..303c612 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 
 import '../../../styles/dashboard-header-styles.js';
 import '../../../styles/shared-styles.js';
@@ -26,7 +25,7 @@
 import {htmlTemplate} from './gr-repo-header_html.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrRepoHeader extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js
deleted file mode 100644
index f6fb1d0..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.js
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="dashboard-header-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="info">
-    <h1 class$="name">
-      [[repo]]
-      <hr />
-    </h1>
-    <div><span>Detail:</span> <a href$="[[_repoUrl]]">Repo settings</a></div>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
new file mode 100644
index 0000000..9fd27c6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
@@ -0,0 +1,34 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="dashboard-header-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <div class="info">
+    <h1 class="heading-1">
+      [[repo]]
+      <hr />
+    </h1>
+    <div><span>Detail:</span> <a href$="[[_repoUrl]]">Repo settings</a></div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
deleted file mode 100644
index 78c1f09..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.html
+++ /dev/null
@@ -1,59 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-repo-header</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-repo-header></gr-repo-header>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-header.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-repo-header tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('repoUrl reset once repo changed', () => {
-    sandbox.stub(GerritNav, 'getUrlForRepo',
-        repoName => `http://test.com/${repoName}`
-    );
-    assert.equal(element._repoUrl, undefined);
-    element.repo = 'test';
-    assert.equal(element._repoUrl, 'http://test.com/test');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.js
new file mode 100644
index 0000000..4f93d54
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.js
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-repo-header.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-repo-header');
+
+suite('gr-repo-header tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('repoUrl reset once repo changed', () => {
+    sinon.stub(GerritNav, 'getUrlForRepo').callsFake(
+        repoName => `http://test.com/${repoName}`
+    );
+    assert.equal(element._repoUrl, undefined);
+    element.repo = 'test';
+    assert.equal(element._repoUrl, 'http://test.com/test');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
index 6bb1bf8..7901c53 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 
 import '../../../styles/shared-styles.js';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
@@ -30,7 +29,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrUserHeader extends GestureEventListeners(
     LegacyElementMixin(
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js
deleted file mode 100644
index 5a5d590..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.js
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="dashboard-header-styles">
-    .name {
-      display: inline-block;
-    }
-    .name hr {
-      width: 100%;
-    }
-    .status.hide,
-    .name.hide,
-    .dashboardLink.hide {
-      display: none;
-    }
-  </style>
-  <gr-avatar
-    account="[[_accountDetails]]"
-    image-size="100"
-    aria-label="Account avatar"
-  ></gr-avatar>
-  <div class="info">
-    <h1 class="name">
-      [[_computeDetail(_accountDetails, 'name')]]
-    </h1>
-    <hr />
-    <div class$="status [[_computeStatusClass(_accountDetails)]]">
-      <span>Status:</span> [[_status]]
-    </div>
-    <div>
-      <span>Email:</span>
-      <a href="mailto:[[_computeDetail(_accountDetails, 'email')]]"
-        ><!--
-          -->[[_computeDetail(_accountDetails, 'email')]]</a
-      >
-    </div>
-    <div>
-      <span>Joined:</span>
-      <gr-date-formatter
-        date-str="[[_computeDetail(_accountDetails, 'registered_on')]]"
-      >
-      </gr-date-formatter>
-    </div>
-    <gr-endpoint-decorator name="user-header">
-      <gr-endpoint-param name="accountDetails" value="[[_accountDetails]]">
-      </gr-endpoint-param>
-      <gr-endpoint-param name="loggedIn" value="[[loggedIn]]">
-      </gr-endpoint-param>
-    </gr-endpoint-decorator>
-  </div>
-  <div class="info">
-    <div class$="[[_computeDashboardLinkClass(showDashboardLink, loggedIn)]]">
-      <a href$="[[_computeDashboardUrl(_accountDetails)]]">View dashboard</a>
-    </div>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
new file mode 100644
index 0000000..72bdca6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="dashboard-header-styles">
+    .status.hide,
+    .name.hide,
+    .dashboardLink.hide {
+      display: none;
+    }
+  </style>
+  <gr-avatar
+    account="[[_accountDetails]]"
+    image-size="100"
+    aria-label="Account avatar"
+  ></gr-avatar>
+  <div class="info">
+    <h1 class="heading-1">
+      [[_computeDetail(_accountDetails, 'name')]]
+    </h1>
+    <hr />
+    <div class$="status [[_computeStatusClass(_accountDetails)]]">
+      <span>Status:</span> [[_status]]
+    </div>
+    <div>
+      <span>Email:</span>
+      <a href="mailto:[[_computeDetail(_accountDetails, 'email')]]"
+        ><!--
+          -->[[_computeDetail(_accountDetails, 'email')]]</a
+      >
+    </div>
+    <div>
+      <span>Joined:</span>
+      <gr-date-formatter
+        date-str="[[_computeDetail(_accountDetails, 'registered_on')]]"
+      >
+      </gr-date-formatter>
+    </div>
+    <gr-endpoint-decorator name="user-header">
+      <gr-endpoint-param name="accountDetails" value="[[_accountDetails]]">
+      </gr-endpoint-param>
+      <gr-endpoint-param name="loggedIn" value="[[loggedIn]]">
+      </gr-endpoint-param>
+    </gr-endpoint-decorator>
+  </div>
+  <div class="info">
+    <div class$="[[_computeDashboardLinkClass(showDashboardLink, loggedIn)]]">
+      <a href$="[[_computeDashboardUrl(_accountDetails)]]">View dashboard</a>
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
deleted file mode 100644
index 44eb96c..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
+++ /dev/null
@@ -1,81 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-user-header</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-user-header></gr-user-header>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-user-header.js';
-suite('gr-user-header tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('loads and clears account info', done => {
-    sandbox.stub(element.$.restAPI, 'getAccountDetails')
-        .returns(Promise.resolve({
-          name: 'foo',
-          email: 'bar',
-          registered_on: '2015-03-12 18:32:08.000000000',
-        }));
-    sandbox.stub(element.$.restAPI, 'getAccountStatus')
-        .returns(Promise.resolve('baz'));
-
-    element.userId = 'foo.bar@baz';
-    flush(() => {
-      assert.isOk(element._accountDetails);
-      assert.isOk(element._status);
-
-      element.userId = null;
-      flush(() => {
-        flushAsynchronousOperations();
-        assert.isNull(element._accountDetails);
-        assert.isNull(element._status);
-
-        done();
-      });
-    });
-  });
-
-  test('_computeDashboardLinkClass', () => {
-    assert.include(element._computeDashboardLinkClass(false, false), 'hide');
-    assert.include(element._computeDashboardLinkClass(true, false), 'hide');
-    assert.include(element._computeDashboardLinkClass(false, true), 'hide');
-    assert.notInclude(element._computeDashboardLinkClass(true, true), 'hide');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
new file mode 100644
index 0000000..6baacef
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-user-header.js';
+
+const basicFixture = fixtureFromElement('gr-user-header');
+
+suite('gr-user-header tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('loads and clears account info', done => {
+    sinon.stub(element.$.restAPI, 'getAccountDetails')
+        .returns(Promise.resolve({
+          name: 'foo',
+          email: 'bar',
+          registered_on: '2015-03-12 18:32:08.000000000',
+        }));
+    sinon.stub(element.$.restAPI, 'getAccountStatus')
+        .returns(Promise.resolve('baz'));
+
+    element.userId = 'foo.bar@baz';
+    flush(() => {
+      assert.isOk(element._accountDetails);
+      assert.isOk(element._status);
+
+      element.userId = null;
+      flush(() => {
+        flushAsynchronousOperations();
+        assert.isNull(element._accountDetails);
+        assert.isNull(element._status);
+
+        done();
+      });
+    });
+  });
+
+  test('_computeDashboardLinkClass', () => {
+    assert.include(element._computeDashboardLinkClass(false, false), 'hide');
+    assert.include(element._computeDashboardLinkClass(true, false), 'hide');
+    assert.include(element._computeDashboardLinkClass(false, true), 'hide');
+    assert.notInclude(element._computeDashboardLinkClass(true, true), 'hide');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index ca9016f..510be16 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -14,10 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../admin/gr-create-change-dialog/gr-create-change-dialog.js';
-import '../../core/gr-reporting/gr-reporting.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-dialog/gr-dialog.js';
 import '../../shared/gr-dropdown/gr-dropdown.js';
@@ -35,15 +32,22 @@
 import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog.js';
 import '../../../styles/shared-styles.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-change-actions_html.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
-import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {appContext} from '../../../services/app-context.js';
+import {
+  fetchChangeUpdates,
+  patchNumEquals,
+} from '../../../utils/patch-set-util.js';
+import {
+  changeIsOpen,
+  ListChangesOption,
+  listChangesOptionsToHex,
+} from '../../../utils/change-util.js';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -89,13 +93,15 @@
   PRIVATE: 'private',
   PRIVATE_DELETE: 'private.delete',
   PUBLISH_EDIT: 'publishEdit',
-  READY: 'ready',
+  REBASE: 'rebase',
   REBASE_EDIT: 'rebaseEdit',
+  READY: 'ready',
   RESTORE: 'restore',
   REVERT: 'revert',
   REVERT_SUBMISSION: 'revert_submission',
   REVIEWED: 'reviewed',
   STOP_EDIT: 'stopEdit',
+  SUBMIT: 'submit',
   UNIGNORE: 'unignore',
   UNREVIEWED: 'unreviewed',
   WIP: 'wip',
@@ -233,22 +239,23 @@
 */
 const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
 
+const SKIP_ACTION_KEYS_ATTENTION_SET = [
+  ChangeActions.REVIEWED,
+  ChangeActions.UNREVIEWED,
+];
+
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrChangeActions extends mixinBehaviors( [
-  PatchSetBehavior,
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrChangeActions extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-change-actions'; }
   /**
    * Fired when the change should be reloaded.
    *
-   * @event reload-change
+   * @event reload
    */
 
   /**
@@ -274,6 +281,7 @@
     this.ActionType = ActionType;
     this.ChangeActions = ChangeActions;
     this.RevisionActions = RevisionActions;
+    this.reporting = appContext.reportingService;
   }
 
   static get properties() {
@@ -359,7 +367,7 @@
         type: Array,
         computed: '_computeAllActions(actions.*, revisionActions.*,' +
           'primaryActionKeys.*, _additionalActions.*, change, ' +
-          '_actionPriorityOverrides.*)',
+          '_config, _actionPriorityOverrides.*)',
       },
       _topLevelActions: {
         type: Array,
@@ -461,6 +469,7 @@
         type: Boolean,
         value: true,
       },
+      _config: Object,
     };
   }
 
@@ -486,6 +495,9 @@
   ready() {
     super.ready();
     this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
+    this.$.restAPI.getConfig().then(config => {
+      this._config = config;
+    });
     this._handleLoadingComplete();
   }
 
@@ -668,7 +680,7 @@
       actionsChangeRecord,
       revisionActionsChangeRecord,
       additionalActionsChangeRecord,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return;
     }
 
@@ -707,7 +719,7 @@
       editMode,
       editBasedOnCurrentPatchSet,
       disableEdit,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return;
     }
 
@@ -722,7 +734,7 @@
     if (this.actions && editPatchsetLoaded) {
       // Only show actions that mutate an edit if an actual edit patch set
       // is loaded.
-      if (this.changeIsOpen(this.change)) {
+      if (changeIsOpen(this.change)) {
         if (editBasedOnCurrentPatchSet) {
           if (!this.actions.publishEdit) {
             this.set('actions.publishEdit', PUBLISH_EDIT);
@@ -744,7 +756,7 @@
       this._deleteAndNotify('deleteEdit');
     }
 
-    if (this.actions && this.changeIsOpen(this.change)) {
+    if (this.actions && changeIsOpen(this.change)) {
       // Only show edit button if there is no edit patchset loaded and the
       // file list is not in edit mode.
       if (editPatchsetLoaded || editMode) {
@@ -846,7 +858,7 @@
     if (!approval) {
       return null;
     }
-    const action = Object.assign({}, QUICK_APPROVE_ACTION);
+    const action = {...QUICK_APPROVE_ACTION};
     action.label = approval.label + approval.score;
     const review = {
       drafts: 'PUBLISH_ALL_REVISIONS',
@@ -887,7 +899,7 @@
       actions[a].label = this._getActionLabel(actions[a]);
 
       // Triggers a re-render by ensuring object inequality.
-      result.push(Object.assign({}, actions[a]));
+      result.push({...actions[a]});
     });
 
     let additionalActions = (additionalActionsChangeRecord &&
@@ -897,7 +909,7 @@
         .map(a => {
           a.__primary = primaryActionKeys.includes(a.__key);
           // Triggers a re-render by ensuring object inequality.
-          return Object.assign({}, a);
+          return {...a};
         });
     return result.concat(additionalActions).concat(pluginActions);
   }
@@ -948,7 +960,7 @@
 
   _getRevision(change, patchNum) {
     for (const rev of Object.values(change.revisions)) {
-      if (this.patchNumEquals(rev._number, patchNum)) {
+      if (patchNumEquals(rev._number, patchNum)) {
         return rev;
       }
     }
@@ -1001,7 +1013,7 @@
     this._handleAction(type, key);
   }
 
-  _handleOveflowItemTap(e) {
+  _handleOverflowItemTap(e) {
     e.preventDefault();
     const el = dom(e).localTarget;
     const key = e.detail.action.__key;
@@ -1017,7 +1029,7 @@
   }
 
   _handleAction(type, key) {
-    this.$.reporting.reportInteraction(`${type}-${key}`);
+    this.reporting.reportInteraction(`${type}-${key}`);
     switch (type) {
       case ActionType.REVISION:
         this._handleRevisionAction(key);
@@ -1357,7 +1369,14 @@
         case ChangeActions.DELETE_EDIT:
         case ChangeActions.PUBLISH_EDIT:
         case ChangeActions.REBASE_EDIT:
-          GerritNav.navigateToChange(this.change);
+        case ChangeActions.REBASE:
+        case ChangeActions.SUBMIT:
+          this.dispatchEvent(new CustomEvent('reload',
+              {
+                detail: {clearPatchset: true},
+                bubbles: false,
+                composed: true,
+              }));
           break;
         case ChangeActions.REVERT_SUBMISSION:
           if (!obj.revert_changes || !obj.revert_changes.length) return;
@@ -1367,8 +1386,12 @@
               obj.revert_changes[0].topic);
           break;
         default:
-          this.dispatchEvent(new CustomEvent('reload-change',
-              {detail: {action: action.__key}, bubbles: false}));
+          this.dispatchEvent(new CustomEvent('reload',
+              {
+                detail: {action: action.__key, clearPatchset: true},
+                bubbles: false,
+                composed: true,
+              }));
           break;
       }
     });
@@ -1410,7 +1433,7 @@
       cleanupFn.call(this);
       this._handleResponseError(action, response, payload);
     };
-    return this.fetchChangeUpdates(this.change, this.$.restAPI)
+    return fetchChangeUpdates(this.change, this.$.restAPI)
         .then(result => {
           if (!result.isLatest) {
             this.dispatchEvent(new CustomEvent('show-alert', {
@@ -1419,8 +1442,12 @@
                   'uploaded to this change.',
                 action: 'Reload',
                 callback: () => {
-                // Load the current change without any patch range.
-                  GerritNav.navigateToChange(this.change);
+                  this.dispatchEvent(new CustomEvent('reload',
+                      {
+                        detail: {clearPatchset: true},
+                        bubbles: false,
+                        composed: true,
+                      }));
                 },
               },
               composed: true, bubbles: true,
@@ -1450,8 +1477,8 @@
     this.$.confirmCherrypick.branch = '';
     const query = `topic: "${this.change.topic}"`;
     const options =
-      this.listChangesOptionsToHex(this.ListChangesOption.MESSAGES,
-          this.ListChangesOption.ALL_REVISIONS);
+      listChangesOptionsToHex(ListChangesOption.MESSAGES,
+          ListChangesOption.ALL_REVISIONS);
     this.$.restAPI.getChanges('', query, undefined, options)
         .then(changes => {
           this.$.confirmCherrypick.updateChanges(changes);
@@ -1512,10 +1539,11 @@
    * @param {!Array} primariesRecord
    * @param {!Array} additionalActionsRecord
    * @param {!Object} change The change object.
+   * @param {!Object} config server configuration info
    * @return {!Array}
    */
   _computeAllActions(changeActionsRecord, revisionActionsRecord,
-      primariesRecord, additionalActionsRecord, change) {
+      primariesRecord, additionalActionsRecord, change, config) {
     // Polymer 2: check for undefined
     if ([
       changeActionsRecord,
@@ -1523,7 +1551,7 @@
       primariesRecord,
       additionalActionsRecord,
       change,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return [];
     }
 
@@ -1543,9 +1571,15 @@
           if (ACTIONS_WITH_ICONS.has(action.__key)) {
             action.icon = action.__key;
           }
+          // TODO(brohlfs): Temporary hack until change 269573 is live in all
+          // backends.
+          if (action.__key === ChangeActions.READY) {
+            action.label = 'Mark as Active';
+          }
+          // End of hack
           return action;
         })
-        .filter(action => !this._shouldSkipAction(action));
+        .filter(action => !this._shouldSkipAction(action, config));
   }
 
   _getActionPriority(action) {
@@ -1583,8 +1617,14 @@
     }
   }
 
-  _shouldSkipAction(action) {
-    return SKIP_ACTION_KEYS.includes(action.__key);
+  _shouldSkipAction(action, config) {
+    const skipActionKeys = [...SKIP_ACTION_KEYS];
+    const isAttentionSetEnabled = !!config && !!config.change
+        && config.change.enable_attention_set;
+    if (isAttentionSetEnabled) {
+      skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
+    }
+    return skipActionKeys.includes(action.__key);
   }
 
   _computeTopLevelActions(actionRecord, hiddenActionsRecord) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
deleted file mode 100644
index f12e600..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.js
+++ /dev/null
@@ -1,275 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: flex;
-      font-family: var(--font-family);
-    }
-    #actionLoadingMessage,
-    #mainContent,
-    section {
-      display: flex;
-    }
-    #actionLoadingMessage,
-    gr-button,
-    gr-dropdown {
-      /* px because don't have the same font size */
-      margin-left: 8px;
-    }
-    #actionLoadingMessage {
-      align-items: center;
-      color: var(--deemphasized-text-color);
-    }
-    #confirmSubmitDialog .changeSubject {
-      margin: var(--spacing-l);
-      text-align: center;
-    }
-    iron-icon {
-      color: inherit;
-      margin-right: var(--spacing-xs);
-    }
-    #moreActions iron-icon {
-      margin: 0;
-    }
-    #moreMessage,
-    .hidden {
-      display: none;
-    }
-    @media screen and (max-width: 50em) {
-      #mainContent {
-        flex-wrap: wrap;
-      }
-      gr-button {
-        --gr-button: {
-          padding: var(--spacing-m);
-          white-space: nowrap;
-        }
-      }
-      gr-button,
-      gr-dropdown {
-        margin: 0;
-      }
-      #actionLoadingMessage {
-        margin: var(--spacing-m);
-        text-align: center;
-      }
-      #moreMessage {
-        display: inline;
-      }
-    }
-  </style>
-  <div id="mainContent">
-    <span id="actionLoadingMessage" hidden$="[[!_actionLoadingMessage]]">
-      [[_actionLoadingMessage]]</span
-    >
-    <section
-      id="primaryActions"
-      hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
-    >
-      <template is="dom-repeat" items="[[_topLevelPrimaryActions]]" as="action">
-        <gr-button
-          link=""
-          title$="[[action.title]]"
-          has-tooltip="[[_computeHasTooltip(action.title)]]"
-          position-below="true"
-          data-action-key$="[[action.__key]]"
-          data-action-type$="[[action.__type]]"
-          data-label$="[[action.label]]"
-          disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-          on-click="_handleActionTap"
-        >
-          <iron-icon
-            class$="[[_computeHasIcon(action)]]"
-            icon$="gr-icons:[[action.icon]]"
-          ></iron-icon>
-          [[action.label]]
-        </gr-button>
-      </template>
-    </section>
-    <section
-      id="secondaryActions"
-      hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
-    >
-      <template
-        is="dom-repeat"
-        items="[[_topLevelSecondaryActions]]"
-        as="action"
-      >
-        <gr-button
-          link=""
-          title$="[[action.title]]"
-          has-tooltip="[[_computeHasTooltip(action.title)]]"
-          position-below="true"
-          data-action-key$="[[action.__key]]"
-          data-action-type$="[[action.__type]]"
-          data-label$="[[action.label]]"
-          disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-          on-click="_handleActionTap"
-        >
-          <iron-icon
-            class$="[[_computeHasIcon(action)]]"
-            icon$="gr-icons:[[action.icon]]"
-          ></iron-icon>
-          [[action.label]]
-        </gr-button>
-      </template>
-    </section>
-    <gr-button hidden$="[[!_loading]]" disabled=""
-      >Loading actions...</gr-button
-    >
-    <gr-dropdown
-      id="moreActions"
-      link=""
-      tabindex="0"
-      vertical-offset="32"
-      horizontal-align="right"
-      on-tap-item="_handleOveflowItemTap"
-      hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
-      disabled-ids="[[_disabledMenuActions]]"
-      items="[[_menuActions]]"
-    >
-      <iron-icon icon="gr-icons:more-vert"></iron-icon>
-      <span id="moreMessage">More</span>
-    </gr-dropdown>
-  </div>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-confirm-rebase-dialog
-      id="confirmRebase"
-      class="confirmDialog"
-      change-number="[[change._number]]"
-      on-confirm="_handleRebaseConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      branch="[[change.branch]]"
-      has-parent="[[hasParent]]"
-      rebase-on-current="[[_computeRebaseOnCurrent(_revisionRebaseAction)]]"
-      hidden=""
-    ></gr-confirm-rebase-dialog>
-    <gr-confirm-cherrypick-dialog
-      id="confirmCherrypick"
-      class="confirmDialog"
-      change-status="[[changeStatus]]"
-      commit-message="[[commitMessage]]"
-      commit-num="[[commitNum]]"
-      on-confirm="_handleCherrypickConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      project="[[change.project]]"
-      hidden=""
-    ></gr-confirm-cherrypick-dialog>
-    <gr-confirm-cherrypick-conflict-dialog
-      id="confirmCherrypickConflict"
-      class="confirmDialog"
-      on-confirm="_handleCherrypickConflictConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-cherrypick-conflict-dialog>
-    <gr-confirm-move-dialog
-      id="confirmMove"
-      class="confirmDialog"
-      on-confirm="_handleMoveConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      project="[[change.project]]"
-      hidden=""
-    ></gr-confirm-move-dialog>
-    <gr-confirm-revert-dialog
-      id="confirmRevertDialog"
-      class="confirmDialog"
-      on-confirm="_handleRevertDialogConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-revert-dialog>
-    <gr-confirm-revert-submission-dialog
-      id="confirmRevertSubmissionDialog"
-      class="confirmDialog"
-      commit-message="[[commitMessage]]"
-      on-confirm="_handleRevertSubmissionDialogConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-revert-submission-dialog>
-    <gr-confirm-abandon-dialog
-      id="confirmAbandonDialog"
-      class="confirmDialog"
-      on-confirm="_handleAbandonDialogConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-abandon-dialog>
-    <gr-confirm-submit-dialog
-      id="confirmSubmitDialog"
-      class="confirmDialog"
-      change="[[change]]"
-      action="[[_revisionSubmitAction]]"
-      on-cancel="_handleConfirmDialogCancel"
-      on-confirm="_handleSubmitConfirm"
-      hidden=""
-    ></gr-confirm-submit-dialog>
-    <gr-dialog
-      id="createFollowUpDialog"
-      class="confirmDialog"
-      confirm-label="Create"
-      on-confirm="_handleCreateFollowUpChange"
-      on-cancel="_handleCloseCreateFollowUpChange"
-    >
-      <div class="header" slot="header">
-        Create Follow-Up Change
-      </div>
-      <div class="main" slot="main">
-        <gr-create-change-dialog
-          id="createFollowUpChange"
-          branch="[[change.branch]]"
-          base-change="[[change.id]]"
-          repo-name="[[change.project]]"
-          private-by-default="[[privateByDefault]]"
-        ></gr-create-change-dialog>
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="confirmDeleteDialog"
-      class="confirmDialog"
-      confirm-label="Delete"
-      confirm-on-enter=""
-      on-cancel="_handleConfirmDialogCancel"
-      on-confirm="_handleDeleteConfirm"
-    >
-      <div class="header" slot="header">
-        Delete Change
-      </div>
-      <div class="main" slot="main">
-        Do you really want to delete the change?
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="confirmDeleteEditDialog"
-      class="confirmDialog"
-      confirm-label="Delete"
-      confirm-on-enter=""
-      on-cancel="_handleConfirmDialogCancel"
-      on-confirm="_handleDeleteEditConfirm"
-    >
-      <div class="header" slot="header">
-        Delete Change Edit
-      </div>
-      <div class="main" slot="main">
-        Do you really want to delete the edit?
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-reporting id="reporting" category="change-actions"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
new file mode 100644
index 0000000..4e315af
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
@@ -0,0 +1,274 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: flex;
+      font-family: var(--font-family);
+    }
+    #actionLoadingMessage,
+    #mainContent,
+    section {
+      display: flex;
+    }
+    #actionLoadingMessage,
+    gr-button,
+    gr-dropdown {
+      /* px because don't have the same font size */
+      margin-left: 8px;
+    }
+    #actionLoadingMessage {
+      align-items: center;
+      color: var(--deemphasized-text-color);
+    }
+    #confirmSubmitDialog .changeSubject {
+      margin: var(--spacing-l);
+      text-align: center;
+    }
+    iron-icon {
+      color: inherit;
+      margin-right: var(--spacing-xs);
+    }
+    #moreActions iron-icon {
+      margin: 0;
+    }
+    #moreMessage,
+    .hidden {
+      display: none;
+    }
+    @media screen and (max-width: 50em) {
+      #mainContent {
+        flex-wrap: wrap;
+      }
+      gr-button {
+        --gr-button: {
+          padding: var(--spacing-m);
+          white-space: nowrap;
+        }
+      }
+      gr-button,
+      gr-dropdown {
+        margin: 0;
+      }
+      #actionLoadingMessage {
+        margin: var(--spacing-m);
+        text-align: center;
+      }
+      #moreMessage {
+        display: inline;
+      }
+    }
+  </style>
+  <div id="mainContent">
+    <span id="actionLoadingMessage" hidden$="[[!_actionLoadingMessage]]">
+      [[_actionLoadingMessage]]</span
+    >
+    <section
+      id="primaryActions"
+      hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
+    >
+      <template is="dom-repeat" items="[[_topLevelPrimaryActions]]" as="action">
+        <gr-button
+          link=""
+          title$="[[action.title]]"
+          has-tooltip="[[_computeHasTooltip(action.title)]]"
+          position-below="true"
+          data-action-key$="[[action.__key]]"
+          data-action-type$="[[action.__type]]"
+          data-label$="[[action.label]]"
+          disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
+          on-click="_handleActionTap"
+        >
+          <iron-icon
+            class$="[[_computeHasIcon(action)]]"
+            icon$="gr-icons:[[action.icon]]"
+          ></iron-icon>
+          [[action.label]]
+        </gr-button>
+      </template>
+    </section>
+    <section
+      id="secondaryActions"
+      hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
+    >
+      <template
+        is="dom-repeat"
+        items="[[_topLevelSecondaryActions]]"
+        as="action"
+      >
+        <gr-button
+          link=""
+          title$="[[action.title]]"
+          has-tooltip="[[_computeHasTooltip(action.title)]]"
+          position-below="true"
+          data-action-key$="[[action.__key]]"
+          data-action-type$="[[action.__type]]"
+          data-label$="[[action.label]]"
+          disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
+          on-click="_handleActionTap"
+        >
+          <iron-icon
+            class$="[[_computeHasIcon(action)]]"
+            icon$="gr-icons:[[action.icon]]"
+          ></iron-icon>
+          [[action.label]]
+        </gr-button>
+      </template>
+    </section>
+    <gr-button hidden$="[[!_loading]]" disabled=""
+      >Loading actions...</gr-button
+    >
+    <gr-dropdown
+      id="moreActions"
+      link=""
+      vertical-offset="32"
+      horizontal-align="right"
+      on-tap-item="_handleOverflowItemTap"
+      hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
+      disabled-ids="[[_disabledMenuActions]]"
+      items="[[_menuActions]]"
+    >
+      <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
+      </iron-icon>
+      <span id="moreMessage">More</span>
+    </gr-dropdown>
+  </div>
+  <gr-overlay id="overlay" with-backdrop="">
+    <gr-confirm-rebase-dialog
+      id="confirmRebase"
+      class="confirmDialog"
+      change-number="[[change._number]]"
+      on-confirm="_handleRebaseConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      branch="[[change.branch]]"
+      has-parent="[[hasParent]]"
+      rebase-on-current="[[_computeRebaseOnCurrent(_revisionRebaseAction)]]"
+      hidden=""
+    ></gr-confirm-rebase-dialog>
+    <gr-confirm-cherrypick-dialog
+      id="confirmCherrypick"
+      class="confirmDialog"
+      change-status="[[changeStatus]]"
+      commit-message="[[commitMessage]]"
+      commit-num="[[commitNum]]"
+      on-confirm="_handleCherrypickConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      project="[[change.project]]"
+      hidden=""
+    ></gr-confirm-cherrypick-dialog>
+    <gr-confirm-cherrypick-conflict-dialog
+      id="confirmCherrypickConflict"
+      class="confirmDialog"
+      on-confirm="_handleCherrypickConflictConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      hidden=""
+    ></gr-confirm-cherrypick-conflict-dialog>
+    <gr-confirm-move-dialog
+      id="confirmMove"
+      class="confirmDialog"
+      on-confirm="_handleMoveConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      project="[[change.project]]"
+      hidden=""
+    ></gr-confirm-move-dialog>
+    <gr-confirm-revert-dialog
+      id="confirmRevertDialog"
+      class="confirmDialog"
+      on-confirm="_handleRevertDialogConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      hidden=""
+    ></gr-confirm-revert-dialog>
+    <gr-confirm-revert-submission-dialog
+      id="confirmRevertSubmissionDialog"
+      class="confirmDialog"
+      commit-message="[[commitMessage]]"
+      on-confirm="_handleRevertSubmissionDialogConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      hidden=""
+    ></gr-confirm-revert-submission-dialog>
+    <gr-confirm-abandon-dialog
+      id="confirmAbandonDialog"
+      class="confirmDialog"
+      on-confirm="_handleAbandonDialogConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      hidden=""
+    ></gr-confirm-abandon-dialog>
+    <gr-confirm-submit-dialog
+      id="confirmSubmitDialog"
+      class="confirmDialog"
+      change="[[change]]"
+      action="[[_revisionSubmitAction]]"
+      on-cancel="_handleConfirmDialogCancel"
+      on-confirm="_handleSubmitConfirm"
+      hidden=""
+    ></gr-confirm-submit-dialog>
+    <gr-dialog
+      id="createFollowUpDialog"
+      class="confirmDialog"
+      confirm-label="Create"
+      on-confirm="_handleCreateFollowUpChange"
+      on-cancel="_handleCloseCreateFollowUpChange"
+    >
+      <div class="header" slot="header">
+        Create Follow-Up Change
+      </div>
+      <div class="main" slot="main">
+        <gr-create-change-dialog
+          id="createFollowUpChange"
+          branch="[[change.branch]]"
+          base-change="[[change.id]]"
+          repo-name="[[change.project]]"
+          private-by-default="[[privateByDefault]]"
+        ></gr-create-change-dialog>
+      </div>
+    </gr-dialog>
+    <gr-dialog
+      id="confirmDeleteDialog"
+      class="confirmDialog"
+      confirm-label="Delete"
+      confirm-on-enter=""
+      on-cancel="_handleConfirmDialogCancel"
+      on-confirm="_handleDeleteConfirm"
+    >
+      <div class="header" slot="header">
+        Delete Change
+      </div>
+      <div class="main" slot="main">
+        Do you really want to delete the change?
+      </div>
+    </gr-dialog>
+    <gr-dialog
+      id="confirmDeleteEditDialog"
+      class="confirmDialog"
+      confirm-label="Delete"
+      confirm-on-enter=""
+      on-cancel="_handleConfirmDialogCancel"
+      on-confirm="_handleDeleteEditConfirm"
+    >
+      <div class="header" slot="header">
+        Delete Change Edit
+      </div>
+      <div class="main" slot="main">
+        Do you really want to delete the edit?
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
deleted file mode 100644
index acf17ea..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ /dev/null
@@ -1,2041 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-change-actions</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-actions></gr-change-actions>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-change-actions.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-const CHERRY_PICK_TYPES = {
-  SINGLE_CHANGE: 1,
-  TOPIC: 2,
-};
-// TODO(dhruvsri): remove use of _populateRevertMessage as it's private
-suite('gr-change-actions tests', () => {
-  let element;
-  let sandbox;
-
-  suite('basic tests', () => {
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getChangeRevisionActions() {
-          return Promise.resolve({
-            cherrypick: {
-              method: 'POST',
-              label: 'Cherry Pick',
-              title: 'Cherry pick change to a different branch',
-              enabled: true,
-            },
-            rebase: {
-              method: 'POST',
-              label: 'Rebase',
-              title: 'Rebase onto tip of branch or parent change',
-              enabled: true,
-            },
-            submit: {
-              method: 'POST',
-              label: 'Submit',
-              title: 'Submit patch set 2 into master',
-              enabled: true,
-            },
-            revert_submission: {
-              method: 'POST',
-              label: 'Revert submission',
-              title: 'Revert this submission',
-              enabled: true,
-            },
-          });
-        },
-        send(method, url, payload) {
-          if (method !== 'POST') {
-            return Promise.reject(new Error('bad method'));
-          }
-
-          if (url === '/changes/test~42/revisions/2/submit') {
-            return Promise.resolve({
-              ok: true,
-              text() { return Promise.resolve(')]}\'\n{}'); },
-            });
-          } else if (url === '/changes/test~42/revisions/2/rebase') {
-            return Promise.resolve({
-              ok: true,
-              text() { return Promise.resolve(')]}\'\n{}'); },
-            });
-          }
-
-          return Promise.reject(new Error('bad url'));
-        },
-        getProjectConfig() { return Promise.resolve({}); },
-      });
-
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(pluginLoader, 'awaitPluginsLoaded')
-          .returns(Promise.resolve());
-
-      element = fixture('basic');
-      element.change = {};
-      element.changeNum = '42';
-      element.latestPatchNum = '2';
-      element.actions = {
-        '/': {
-          method: 'DELETE',
-          label: 'Delete Change',
-          title: 'Delete change X_X',
-          enabled: true,
-        },
-      };
-      sandbox.stub(element.$.confirmCherrypick.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
-      sandbox.stub(element.$.confirmMove.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
-
-      return element.reload();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('show-revision-actions event should fire', done => {
-      const spy = sinon.spy(element, '_sendShowRevisionActions');
-      element.reload();
-      flush(() => {
-        assert.isTrue(spy.called);
-        done();
-      });
-    });
-
-    test('primary and secondary actions split properly', () => {
-      // Submit should be the only primary action.
-      assert.equal(element._topLevelPrimaryActions.length, 1);
-      assert.equal(element._topLevelPrimaryActions[0].label, 'Submit');
-      assert.equal(element._topLevelSecondaryActions.length,
-          element._topLevelActions.length - 1);
-    });
-
-    test('revert submission action is skipped', () => {
-      assert.isFalse(element._allActionValues.includes(action =>
-        action.key === 'revert_submission'));
-    });
-
-    test('_shouldHideActions', () => {
-      assert.isTrue(element._shouldHideActions(undefined, true));
-      assert.isTrue(element._shouldHideActions({base: {}}, false));
-      assert.isFalse(element._shouldHideActions({base: ['test']}, false));
-    });
-
-    test('plugin revision actions', done => {
-      sandbox.stub(element.$.restAPI, 'getChangeActionURL').returns(
-          Promise.resolve('the-url'));
-      element.revisionActions = {
-        'plugin~action': {},
-      };
-      assert.isOk(element.revisionActions['plugin~action']);
-      flush(() => {
-        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
-            element.changeNum, element.latestPatchNum, '/plugin~action'));
-        assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
-        done();
-      });
-    });
-
-    test('plugin change actions', done => {
-      sandbox.stub(element.$.restAPI, 'getChangeActionURL').returns(
-          Promise.resolve('the-url'));
-      element.actions = {
-        'plugin~action': {},
-      };
-      assert.isOk(element.actions['plugin~action']);
-      flush(() => {
-        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
-            element.changeNum, null, '/plugin~action'));
-        assert.equal(element.actions['plugin~action'].__url, 'the-url');
-        done();
-      });
-    });
-
-    test('not supported actions are filtered out', () => {
-      element.revisionActions = {followup: {}};
-      assert.equal(element.querySelectorAll(
-          'section gr-button[data-action-type="revision"]').length, 0);
-    });
-
-    test('getActionDetails', () => {
-      element.revisionActions = Object.assign({
-        'plugin~action': {},
-      }, element.revisionActions);
-      assert.isUndefined(element.getActionDetails('rubbish'));
-      assert.strictEqual(element.revisionActions['plugin~action'],
-          element.getActionDetails('plugin~action'));
-      assert.strictEqual(element.revisionActions['rebase'],
-          element.getActionDetails('rebase'));
-    });
-
-    test('hide revision action', done => {
-      flush(() => {
-        const buttonEl = element.shadowRoot
-            .querySelector('[data-action-key="submit"]');
-        assert.isOk(buttonEl);
-        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
-        element.setActionHidden(element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT, true);
-        assert.lengthOf(element._hiddenActions, 1);
-        element.setActionHidden(element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT, true);
-        assert.lengthOf(element._hiddenActions, 1);
-        flush(() => {
-          const buttonEl = element.shadowRoot
-              .querySelector('[data-action-key="submit"]');
-          assert.isNotOk(buttonEl);
-
-          element.setActionHidden(element.ActionType.REVISION,
-              element.RevisionActions.SUBMIT, false);
-          flush(() => {
-            const buttonEl = element.shadowRoot
-                .querySelector('[data-action-key="submit"]');
-            assert.isOk(buttonEl);
-            assert.isFalse(buttonEl.hasAttribute('hidden'));
-            done();
-          });
-        });
-      });
-    });
-
-    test('buttons exist', done => {
-      element._loading = false;
-      flush(() => {
-        const buttonEls = dom(element.root)
-            .querySelectorAll('gr-button');
-        const menuItems = element.$.moreActions.items;
-
-        // Total button number is one greater than the number of total actions
-        // due to the existence of the overflow menu trigger.
-        assert.equal(buttonEls.length + menuItems.length,
-            element._allActionValues.length + 1);
-        assert.isFalse(element.hidden);
-        done();
-      });
-    });
-
-    test('delete buttons have explicit labels', done => {
-      flush(() => {
-        const deleteItems = element.$.moreActions.items
-            .filter(item => item.id.startsWith('delete'));
-        assert.equal(deleteItems.length, 1);
-        assert.notEqual(deleteItems[0].name);
-        assert.equal(deleteItems[0].name, 'Delete change');
-        done();
-      });
-    });
-
-    test('get revision object from change', () => {
-      const revObj = {_number: 2, foo: 'bar'};
-      const change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: revObj,
-        },
-      };
-      assert.deepEqual(element._getRevision(change, '2'), revObj);
-    });
-
-    test('_actionComparator sort order', () => {
-      const actions = [
-        {label: '123', __type: 'change', __key: 'review'},
-        {label: 'abc-ro', __type: 'revision'},
-        {label: 'abc', __type: 'change'},
-        {label: 'def', __type: 'change'},
-        {label: 'def-p', __type: 'change', __primary: true},
-      ];
-
-      const result = actions.slice();
-      result.reverse();
-      result.sort(element._actionComparator.bind(element));
-      assert.deepEqual(result, actions);
-    });
-
-    test('submit change', () => {
-      const showSpy = sandbox.spy(element, '_showActionDialog');
-      sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      sandbox.stub(element, 'fetchChangeUpdates',
-          () => Promise.resolve({isLatest: true}));
-      sandbox.stub(element.$.overlay, 'open').returns(Promise.resolve());
-      element.change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: {_number: 2},
-        },
-      };
-      element.latestPatchNum = '2';
-
-      const submitButton = element.shadowRoot
-          .querySelector('gr-button[data-action-key="submit"]');
-      assert.ok(submitButton);
-      MockInteractions.tap(submitButton);
-
-      flushAsynchronousOperations();
-      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
-    });
-
-    test('submit change, tap on icon', done => {
-      sandbox.stub(element.$.confirmSubmitDialog, 'resetFocus', done);
-      sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      sandbox.stub(element, 'fetchChangeUpdates',
-          () => Promise.resolve({isLatest: true}));
-      sandbox.stub(element.$.overlay, 'open').returns(Promise.resolve());
-      element.change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: {_number: 2},
-        },
-      };
-      element.latestPatchNum = '2';
-
-      const submitIcon =
-          element.shadowRoot
-              .querySelector('gr-button[data-action-key="submit"] iron-icon');
-      assert.ok(submitIcon);
-      MockInteractions.tap(submitIcon);
-    });
-
-    test('_handleSubmitConfirm', () => {
-      const fireStub = sandbox.stub(element, '_fireAction');
-      sandbox.stub(element, '_canSubmitChange').returns(true);
-      element._handleSubmitConfirm();
-      assert.isTrue(fireStub.calledOnce);
-      assert.deepEqual(fireStub.lastCall.args,
-          ['/submit', element.revisionActions.submit, true]);
-    });
-
-    test('_handleSubmitConfirm when not able to submit', () => {
-      const fireStub = sandbox.stub(element, '_fireAction');
-      sandbox.stub(element, '_canSubmitChange').returns(false);
-      element._handleSubmitConfirm();
-      assert.isFalse(fireStub.called);
-    });
-
-    test('submit change with plugin hook', done => {
-      sandbox.stub(element, '_canSubmitChange',
-          () => false);
-      const fireActionStub = sandbox.stub(element, '_fireAction');
-      flush(() => {
-        const submitButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="submit"]');
-        assert.ok(submitButton);
-        MockInteractions.tap(submitButton);
-        assert.equal(fireActionStub.callCount, 0);
-
-        done();
-      });
-    });
-
-    test('chain state', () => {
-      assert.equal(element._hasKnownChainState, false);
-      element.hasParent = true;
-      assert.equal(element._hasKnownChainState, true);
-      element.hasParent = false;
-    });
-
-    test('_calculateDisabled', () => {
-      let hasKnownChainState = false;
-      const action = {__key: 'rebase', enabled: true};
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), true);
-
-      action.__key = 'delete';
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-
-      action.__key = 'rebase';
-      hasKnownChainState = true;
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-
-      action.enabled = false;
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-    });
-
-    test('rebase change', done => {
-      const fireActionStub = sandbox.stub(element, '_fireAction');
-      const fetchChangesStub = sandbox.stub(element.$.confirmRebase,
-          'fetchRecentChanges').returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
-      flush(() => {
-        const rebaseButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebase"]');
-        MockInteractions.tap(rebaseButton);
-        const rebaseAction = {
-          __key: 'rebase',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Rebase',
-          method: 'POST',
-          title: 'Rebase onto tip of branch or parent change',
-        };
-        assert.isTrue(fetchChangesStub.called);
-        element._handleRebaseConfirm({detail: {base: '1234'}});
-        assert.deepEqual(fireActionStub.lastCall.args,
-            ['/rebase', rebaseAction, true, {base: '1234'}]);
-        done();
-      });
-    });
-
-    test(`rebase dialog gets recent changes each time it's opened`, done => {
-      const fetchChangesStub = sandbox.stub(element.$.confirmRebase,
-          'fetchRecentChanges').returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
-      const rebaseButton = element.shadowRoot
-          .querySelector('gr-button[data-action-key="rebase"]');
-      MockInteractions.tap(rebaseButton);
-      assert.isTrue(fetchChangesStub.calledOnce);
-
-      flush(() => {
-        element.$.confirmRebase.dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-        MockInteractions.tap(rebaseButton);
-        assert.isTrue(fetchChangesStub.calledTwice);
-        done();
-      });
-    });
-
-    test('two dialogs are not shown at the same time', done => {
-      element._hasKnownChainState = true;
-      flush(() => {
-        const rebaseButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebase"]');
-        assert.ok(rebaseButton);
-        MockInteractions.tap(rebaseButton);
-        flushAsynchronousOperations();
-        assert.isFalse(element.$.confirmRebase.hidden);
-        sandbox.stub(element.$.restAPI, 'getChanges')
-            .returns(Promise.resolve([]));
-        element._handleCherrypickTap();
-        flush(() => {
-          assert.isTrue(element.$.confirmRebase.hidden);
-          assert.isFalse(element.$.confirmCherrypick.hidden);
-          done();
-        });
-      });
-    });
-
-    test('fullscreen-overlay-opened hides content', () => {
-      sandbox.spy(element, '_handleHideBackgroundContent');
-      element.$.overlay.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-opened', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleHideBackgroundContent.called);
-      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-    });
-
-    test('fullscreen-overlay-closed shows content', () => {
-      sandbox.spy(element, '_handleShowBackgroundContent');
-      element.$.overlay.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-closed', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleShowBackgroundContent.called);
-      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
-    });
-
-    test('_setLabelValuesOnRevert', () => {
-      const labels = {'Foo': 1, 'Bar-Baz': -2};
-      const changeId = 1234;
-      sandbox.stub(element.$.jsAPI, 'getLabelValuesPostRevert').returns(labels);
-      const saveStub = sandbox.stub(element.$.restAPI, 'saveChangeReview')
-          .returns(Promise.resolve());
-      return element._setLabelValuesOnRevert(changeId).then(() => {
-        assert.isTrue(saveStub.calledOnce);
-        assert.equal(saveStub.lastCall.args[0], changeId);
-        assert.deepEqual(saveStub.lastCall.args[2], {labels});
-      });
-    });
-
-    suite('change edits', () => {
-      test('disableEdit', () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        element.set('disableEdit', true);
-        flushAsynchronousOperations();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('shows confirm dialog for delete edit', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-
-        const fireActionStub = sandbox.stub(element, '_fireAction');
-        element._handleDeleteEditTap();
-        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteEditDialog')
-                .shadowRoot
-                .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.equal(fireActionStub.lastCall.args[0], '/edit');
-      });
-
-      test('hide publishEdit and rebaseEdit if change is not open', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'MERGED'};
-        flushAsynchronousOperations();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-      });
-
-      test('edit patchset is loaded, needs rebase', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'NEW'};
-        element.editBasedOnCurrentPatchSet = false;
-        flushAsynchronousOperations();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit patchset is loaded, does not need rebase', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'NEW'};
-        element.editBasedOnCurrentPatchSet = true;
-        flushAsynchronousOperations();
-
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit mode is loaded, no edit patchset', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        flushAsynchronousOperations();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('normal patch set', () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        flushAsynchronousOperations();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit action', done => {
-        element.addEventListener('edit-tap', () => { done(); });
-        element.set('editMode', true);
-        element.change = {status: 'NEW'};
-        flushAsynchronousOperations();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-        element.change = {status: 'MERGED'};
-        flushAsynchronousOperations();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        element.change = {status: 'NEW'};
-        element.set('editMode', false);
-        flushAsynchronousOperations();
-
-        const editButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]');
-        assert.isOk(editButton);
-        MockInteractions.tap(editButton);
-      });
-    });
-
-    suite('cherry-pick', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sandbox.stub(element, '_fireAction');
-        sandbox.stub(window, 'alert');
-      });
-
-      test('works', () => {
-        element._handleCherrypickTap();
-        const action = {
-          __key: 'cherrypick',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Cherry pick',
-          method: 'POST',
-          title: 'Cherry pick change to a different branch',
-        };
-
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: '',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-        assert.equal(fireActionStub.callCount, 0);
-
-        element.$.confirmCherrypick.branch = 'master';
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: 'master',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-        assert.equal(fireActionStub.callCount, 0); // Still needs a message.
-
-        // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = 'OPEN';
-        element.$.confirmCherrypick.commitNum = '123';
-
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: 'master',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-
-        assert.equal(element.$.confirmCherrypick.shadowRoot.
-            querySelector('#messageInput').value, 'foo message');
-
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/cherrypick', action, true, {
-            destination: 'master',
-            base: null,
-            message: 'foo message',
-            allow_conflicts: false,
-          },
-        ]);
-      });
-
-      test('cherry pick even with conflicts', () => {
-        element._handleCherrypickTap();
-        const action = {
-          __key: 'cherrypick',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Cherry pick',
-          method: 'POST',
-          title: 'Cherry pick change to a different branch',
-        };
-
-        element.$.confirmCherrypick.branch = 'master';
-
-        // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = 'OPEN';
-        element.$.confirmCherrypick.commitNum = '123';
-
-        element._handleCherrypickConflictConfirm();
-
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/cherrypick', action, true, {
-            destination: 'master',
-            base: null,
-            message: 'foo message',
-            allow_conflicts: true,
-          },
-        ]);
-      });
-
-      test('branch name cleared when re-open cherrypick', () => {
-        const emptyBranchName = '';
-        element.$.confirmCherrypick.branch = 'master';
-
-        element._handleCherrypickTap();
-        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
-      });
-
-      suite('cherry pick topics', () => {
-        const changes = [
-          {
-            change_id: '12345678901234', topic: 'T', subject: 'random',
-            project: 'A',
-          },
-          {
-            change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
-            project: 'B',
-          },
-        ];
-        setup(done => {
-          sandbox.stub(element.$.restAPI, 'getChanges')
-              .returns(Promise.resolve(changes));
-          element._handleCherrypickTap();
-          flush(() => {
-            const radioButtons = element.$.confirmCherrypick.shadowRoot.
-                querySelectorAll(`input[name='cherryPickOptions']`);
-            assert.equal(radioButtons.length, 2);
-            MockInteractions.tap(radioButtons[1]);
-            flush(() => {
-              done();
-            });
-          });
-        });
-
-        test('cherry pick topic dialog is rendered', done => {
-          const dialog = element.$.confirmCherrypick;
-          flush(() => {
-            const changesTable = dialog.shadowRoot.querySelector('table');
-            const headers = Array.from(changesTable.querySelectorAll('th'));
-            const expectedHeadings = ['Change', 'Subject', 'Project',
-              'Status', ''];
-            const headings = headers.map(header => header.innerText);
-            assert.equal(headings.length, expectedHeadings.length);
-            for (let i = 0; i < headings.length; i++) {
-              assert.equal(headings[i].trim(), expectedHeadings[i]);
-            }
-            const changeRows = changesTable.querySelectorAll('tbody > tr');
-            const change = Array.from(changeRows[0].querySelectorAll('td'))
-                .map(e => e.innerText);
-            const expectedChange = ['1234567890', 'random', 'A',
-              'NOT STARTED', ''];
-            for (let i = 0; i < change.length; i++) {
-              assert.equal(change[i].trim(), expectedChange[i]);
-            }
-            done();
-          });
-        });
-
-        test('changes with duplicate project show an error', done => {
-          const dialog = element.$.confirmCherrypick;
-          const error = dialog.shadowRoot.querySelector('.error-message');
-          assert.equal(error.innerText, '');
-          dialog.updateChanges([
-            {
-              change_id: '12345678901234', topic: 'T', subject: 'random',
-              project: 'A',
-            },
-            {
-              change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
-              project: 'A',
-            },
-          ]);
-          flush(() => {
-            assert.equal(error.innerText, 'Two changes cannot be of the same'
-             + ' project');
-            done();
-          });
-        });
-      });
-    });
-
-    suite('move change', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sandbox.stub(element, '_fireAction');
-        sandbox.stub(window, 'alert');
-      });
-
-      test('works', () => {
-        element._handleMoveTap();
-
-        element._handleMoveConfirm();
-        assert.equal(fireActionStub.callCount, 0);
-
-        element.$.confirmMove.branch = 'master';
-        element._handleMoveConfirm();
-        assert.equal(fireActionStub.callCount, 1);
-      });
-
-      test('branch name cleared when re-open move', () => {
-        const emptyBranchName = '';
-        element.$.confirmMove.branch = 'master';
-
-        element._handleMoveTap();
-        assert.equal(element.$.confirmMove.branch, emptyBranchName);
-      });
-    });
-
-    test('custom actions', done => {
-      // Add a button with the same key as a server-based one to ensure
-      // collisions are taken care of.
-      const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
-      element.addEventListener(key + '-tap', e => {
-        assert.equal(e.detail.node.getAttribute('data-action-key'), key);
-        element.removeActionButton(key);
-        flush(() => {
-          assert.notOk(element.shadowRoot
-              .querySelector('[data-action-key="' + key + '"]'));
-          done();
-        });
-      });
-      flush(() => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('[data-action-key="' + key + '"]'));
-      });
-    });
-
-    test('_setLoadingOnButtonWithKey top-level', () => {
-      const key = 'rebase';
-      const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Rebasing...');
-
-      const button = element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]');
-      assert.isTrue(button.hasAttribute('loading'));
-      assert.isTrue(button.disabled);
-
-      assert.isOk(cleanup);
-      assert.isFunction(cleanup);
-      cleanup();
-
-      assert.isFalse(button.hasAttribute('loading'));
-      assert.isFalse(button.disabled);
-      assert.isNotOk(element._actionLoadingMessage);
-    });
-
-    test('_setLoadingOnButtonWithKey overflow menu', () => {
-      const key = 'cherrypick';
-      const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
-      assert.include(element._disabledMenuActions, 'cherrypick');
-      assert.isFunction(cleanup);
-
-      cleanup();
-
-      assert.notOk(element._actionLoadingMessage);
-      assert.notInclude(element._disabledMenuActions, 'cherrypick');
-    });
-
-    suite('abandon change', () => {
-      let alertStub;
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sandbox.stub(element, '_fireAction');
-        alertStub = sandbox.stub(window, 'alert');
-        element.actions = {
-          abandon: {
-            method: 'POST',
-            label: 'Abandon',
-            title: 'Abandon the change',
-            enabled: true,
-          },
-        };
-        return element.reload();
-      });
-
-      test('abandon change with message', done => {
-        const newAbandonMsg = 'Test Abandon Message';
-        element.$.confirmAbandonDialog.message = newAbandonMsg;
-        flush(() => {
-          const abandonButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key="abandon"]');
-          MockInteractions.tap(abandonButton);
-
-          assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
-          done();
-        });
-      });
-
-      test('abandon change with no message', done => {
-        flush(() => {
-          const abandonButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key="abandon"]');
-          MockInteractions.tap(abandonButton);
-
-          assert.isUndefined(element.$.confirmAbandonDialog.message);
-          done();
-        });
-      });
-
-      test('works', () => {
-        element.$.confirmAbandonDialog.message = 'original message';
-        const restoreButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key="abandon"]');
-        MockInteractions.tap(restoreButton);
-
-        element.$.confirmAbandonDialog.message = 'foo message';
-        element._handleAbandonDialogConfirm();
-        assert.notOk(alertStub.called);
-
-        const action = {
-          __key: 'abandon',
-          __type: 'change',
-          __primary: false,
-          enabled: true,
-          label: 'Abandon',
-          method: 'POST',
-          title: 'Abandon the change',
-        };
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/abandon', action, false, {
-            message: 'foo message',
-          }]);
-      });
-    });
-
-    suite('revert change', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sandbox.stub(element, '_fireAction');
-        element.commitMessage = 'random commit message';
-        element.change.current_revision = 'abcdef';
-        element.actions = {
-          revert: {
-            method: 'POST',
-            label: 'Revert',
-            title: 'Revert the change',
-            enabled: true,
-          },
-        };
-        return element.reload();
-      });
-
-      test('revert change with plugin hook', done => {
-        const newRevertMsg = 'Modified revert msg';
-        sandbox.stub(element.$.confirmRevertDialog, '_modifyRevertMsg',
-            () => newRevertMsg);
-        element.change = {
-          current_revision: 'abc1234',
-        };
-        sandbox.stub(element.$.restAPI, 'getChanges')
-            .returns(Promise.resolve([
-              {change_id: '12345678901234', topic: 'T', subject: 'random'},
-              {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
-            ]));
-        sandbox.stub(element.$.confirmRevertDialog,
-            '_populateRevertSubmissionMessage', () => 'original msg');
-        flush(() => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
-            done();
-          });
-        });
-      });
-
-      suite('revert change submitted together', () => {
-        let getChangesStub;
-        setup(() => {
-          element.change = {
-            submission_id: '199 0',
-            current_revision: '2000',
-          };
-          getChangesStub = sandbox.stub(element.$.restAPI, 'getChanges')
-              .returns(Promise.resolve([
-                {change_id: '12345678901234', topic: 'T', subject: 'random'},
-                {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
-              ]));
-        });
-
-        test('confirm revert dialog shows both options', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const revertSingleChangeLabel = confirmRevertDialog
-                .shadowRoot.querySelector('.revertSingleChange');
-            const revertSubmissionLabel = confirmRevertDialog.
-                shadowRoot.querySelector('.revertSubmission');
-            assert(revertSingleChangeLabel.innerText.trim() ===
-                'Revert single change');
-            assert(revertSubmissionLabel.innerText.trim() ===
-                'Revert entire submission (2 Changes)');
-            let expectedMsg = 'Revert submission 199 0' + '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
-              'Reverted Changes:' + '\n' +
-              '1234567890:random' + '\n' +
-              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            assert.equal(confirmRevertDialog._message, expectedMsg);
-            const radioInputs = confirmRevertDialog.shadowRoot
-                .querySelectorAll('input[name="revertOptions"]');
-            MockInteractions.tap(radioInputs[0]);
-            flush(() => {
-              expectedMsg = 'Revert "random commit message"\n\nThis reverts '
-               + 'commit 2000.\n\nReason'
-               + ' for revert: <INSERT REASONING HERE>\n';
-              assert.equal(confirmRevertDialog._message, expectedMsg);
-              done();
-            });
-          });
-        });
-
-        test('submit fails if message is not edited', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          MockInteractions.tap(revertButton);
-          const fireStub = sandbox.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                .querySelector('gr-dialog')
-                .shadowRoot.querySelector('#confirm');
-            MockInteractions.tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
-        });
-
-        test('message modification is retained on switching', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            const radioInputs = confirmRevertDialog.shadowRoot
-                .querySelectorAll('input[name="revertOptions"]');
-            const revertSubmissionMsg = 'Revert submission 199 0' + '\n\n' +
-            'Reason for revert: <INSERT REASONING HERE>' + '\n' +
-            'Reverted Changes:' + '\n' +
-            '1234567890:random' + '\n' +
-            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-            '\n';
-            const singleChangeMsg =
-            'Revert "random commit message"\n\nThis reverts '
-              + 'commit 2000.\n\nReason'
-              + ' for revert: <INSERT REASONING HERE>\n';
-            assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
-            const newRevertMsg = revertSubmissionMsg + 'random';
-            const newSingleChangeMsg = singleChangeMsg + 'random';
-            confirmRevertDialog._message = newRevertMsg;
-            MockInteractions.tap(radioInputs[0]);
-            flush(() => {
-              assert.equal(confirmRevertDialog._message, singleChangeMsg);
-              confirmRevertDialog._message = newSingleChangeMsg;
-              MockInteractions.tap(radioInputs[1]);
-              flush(() => {
-                assert.equal(confirmRevertDialog._message, newRevertMsg);
-                MockInteractions.tap(radioInputs[0]);
-                flush(() => {
-                  assert.equal(
-                      confirmRevertDialog._message,
-                      newSingleChangeMsg
-                  );
-                  done();
-                });
-              });
-            });
-          });
-        });
-      });
-
-      suite('revert single change', () => {
-        setup(() => {
-          element.change = {
-            submission_id: '199',
-            current_revision: '2000',
-          };
-          sandbox.stub(element.$.restAPI, 'getChanges')
-              .returns(Promise.resolve([
-                {change_id: '12345678901234', topic: 'T', subject: 'random'},
-              ]));
-        });
-
-        test('submit fails if message is not edited', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          MockInteractions.tap(revertButton);
-          const fireStub = sandbox.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                .querySelector('gr-dialog')
-                .shadowRoot.querySelector('#confirm');
-            MockInteractions.tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
-        });
-
-        test('confirm revert dialog shows no radio button', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const radioInputs = confirmRevertDialog.shadowRoot
-                .querySelectorAll('input[name="revertOptions"]');
-            assert.equal(radioInputs.length, 0);
-            const msg = 'Revert "random commit message"\n\n'
-              + 'This reverts commit 2000.\n\nReason '
-              + 'for revert: <INSERT REASONING HERE>\n';
-            assert.equal(confirmRevertDialog._message, msg);
-            const editedMsg = msg + 'hello';
-            confirmRevertDialog._message += 'hello';
-            const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                .querySelector('gr-dialog')
-                .shadowRoot.querySelector('#confirm');
-            MockInteractions.tap(confirmButton);
-            flush(() => {
-              assert.equal(fireActionStub.getCall(0).args[0], '/revert');
-              assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
-              assert.equal(fireActionStub.getCall(0).args[3].message,
-                  editedMsg);
-              done();
-            });
-          });
-        });
-      });
-    });
-
-    suite('mark change private', () => {
-      setup(() => {
-        const privateAction = {
-          __key: 'private',
-          __type: 'change',
-          __primary: false,
-          method: 'POST',
-          label: 'Mark private',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          private: privateAction,
-        };
-
-        element.change.is_private = false;
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        return element.reload();
-      });
-
-      test('make sure the mark private change button is not outside of the ' +
-           'overflow menu', done => {
-        flush(() => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="private"]'));
-          done();
-        });
-      });
-
-      test('private change', done => {
-        flush(() => {
-          assert.isOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private-change"]'));
-          element.setActionOverflow('change', 'private', false);
-          flushAsynchronousOperations();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="private"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private-change"]'));
-          done();
-        });
-      });
-    });
-
-    suite('unmark private change', () => {
-      setup(() => {
-        const unmarkPrivateAction = {
-          __key: 'private.delete',
-          __type: 'change',
-          __primary: false,
-          method: 'POST',
-          label: 'Unmark private',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          'private.delete': unmarkPrivateAction,
-        };
-
-        element.change.is_private = true;
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        return element.reload();
-      });
-
-      test('make sure the unmark private change button is not outside of the ' +
-           'overflow menu', done => {
-        flush(() => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="private.delete"]'));
-          done();
-        });
-      });
-
-      test('unmark the private change', done => {
-        flush(() => {
-          assert.isOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private.delete-change"]')
-          );
-          element.setActionOverflow('change', 'private.delete', false);
-          flushAsynchronousOperations();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="private.delete"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private.delete-change"]')
-          );
-          done();
-        });
-      });
-    });
-
-    suite('delete change', () => {
-      let fireActionStub;
-      let deleteAction;
-
-      setup(() => {
-        fireActionStub = sandbox.stub(element, '_fireAction');
-        element.change = {
-          current_revision: 'abc1234',
-        };
-        deleteAction = {
-          method: 'DELETE',
-          label: 'Delete Change',
-          title: 'Delete change X_X',
-          enabled: true,
-        };
-        element.actions = {
-          '/': deleteAction,
-        };
-      });
-
-      test('does not delete on action', () => {
-        element._handleDeleteTap();
-        assert.isFalse(fireActionStub.called);
-      });
-
-      test('shows confirm dialog', () => {
-        element._handleDeleteTap();
-        assert.isFalse(element.shadowRoot
-            .querySelector('#confirmDeleteDialog').hidden);
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteDialog')
-                .shadowRoot
-                .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-        assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
-      });
-
-      test('hides delete confirm on cancel', () => {
-        element._handleDeleteTap();
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteDialog')
-                .shadowRoot
-                .querySelector('gr-button:not([primary])'));
-        flushAsynchronousOperations();
-        assert.isTrue(element.shadowRoot
-            .querySelector('#confirmDeleteDialog').hidden);
-        assert.isFalse(fireActionStub.called);
-      });
-    });
-
-    suite('ignore change', () => {
-      setup(done => {
-        sandbox.stub(element, '_fireAction');
-
-        const IgnoreAction = {
-          __key: 'ignore',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Ignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          ignore: IgnoreAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('make sure the ignore button is not outside of the overflow menu',
-          () => {
-            assert.isNotOk(element.shadowRoot
-                .querySelector('[data-action-key="ignore"]'));
-          });
-
-      test('ignoring change', () => {
-        assert.isOk(element.$.moreActions.shadowRoot
-            .querySelector('span[data-id="ignore-change"]'));
-        element.setActionOverflow('change', 'ignore', false);
-        flushAsynchronousOperations();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="ignore"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="ignore-change"]'));
-      });
-    });
-
-    suite('unignore change', () => {
-      setup(done => {
-        sandbox.stub(element, '_fireAction');
-
-        const UnignoreAction = {
-          __key: 'unignore',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Unignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          unignore: UnignoreAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('unignore button is not outside of the overflow menu', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="unignore"]'));
-      });
-
-      test('unignoring change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unignore-change"]'));
-        element.setActionOverflow('change', 'unignore', false);
-        flushAsynchronousOperations();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="unignore"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unignore-change"]'));
-      });
-    });
-
-    suite('reviewed change', () => {
-      setup(done => {
-        sandbox.stub(element, '_fireAction');
-
-        const ReviewedAction = {
-          __key: 'reviewed',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Mark reviewed',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          reviewed: ReviewedAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('make sure the reviewed button is not outside of the overflow menu',
-          () => {
-            assert.isNotOk(element.shadowRoot
-                .querySelector('[data-action-key="reviewed"]'));
-          });
-
-      test('reviewing change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="reviewed-change"]'));
-        element.setActionOverflow('change', 'reviewed', false);
-        flushAsynchronousOperations();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="reviewed"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="reviewed-change"]'));
-      });
-    });
-
-    suite('unreviewed change', () => {
-      setup(done => {
-        sandbox.stub(element, '_fireAction');
-
-        const UnreviewedAction = {
-          __key: 'unreviewed',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Mark unreviewed',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          unreviewed: UnreviewedAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('unreviewed button not outside of the overflow menu', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="unreviewed"]'));
-      });
-
-      test('unreviewed change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unreviewed-change"]'));
-        element.setActionOverflow('change', 'unreviewed', false);
-        flushAsynchronousOperations();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="unreviewed"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unreviewed-change"]'));
-      });
-    });
-
-    suite('quick approve', () => {
-      setup(() => {
-        element.change = {
-          current_revision: 'abc1234',
-        };
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              values: {
-                '-1': '',
-                ' 0': '',
-                '+1': '',
-              },
-            },
-          },
-          permitted_labels: {
-            foo: ['-1', ' 0', '+1'],
-          },
-        };
-        flushAsynchronousOperations();
-      });
-
-      test('added when can approve', () => {
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNotNull(approveButton);
-      });
-
-      test('hide quick approve', () => {
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNotNull(approveButton);
-        assert.isFalse(element._hideQuickApproveAction);
-
-        // Assert approve button gets removed from list of buttons.
-        element.hideQuickApproveAction();
-        flushAsynchronousOperations();
-        const approveButtonUpdated =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButtonUpdated);
-        assert.isTrue(element._hideQuickApproveAction);
-      });
-
-      test('is first in list of secondary actions', () => {
-        const approveButton = element.$.secondaryActions
-            .querySelector('gr-button');
-        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
-      });
-
-      test('not added when already approved', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              approved: {},
-              values: {},
-            },
-          },
-          permitted_labels: {
-            foo: [' 0', '+1'],
-          },
-        };
-        flushAsynchronousOperations();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('not added when label not permitted', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {values: {}},
-          },
-          permitted_labels: {
-            bar: [],
-          },
-        };
-        flushAsynchronousOperations();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('approves when tapped', () => {
-        const fireActionStub = sandbox.stub(element, '_fireAction');
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']'));
-        flushAsynchronousOperations();
-        assert.isTrue(fireActionStub.called);
-        assert.isTrue(fireActionStub.calledWith('/review'));
-        const payload = fireActionStub.lastCall.args[3];
-        assert.deepEqual(payload.labels, {foo: '+1'});
-      });
-
-      test('not added when multiple labels are required', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {values: {}},
-            bar: {values: {}},
-          },
-          permitted_labels: {
-            foo: [' 0', '+1'],
-            bar: [' 0', '+1', '+2'],
-          },
-        };
-        flushAsynchronousOperations();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('button label for missing approval', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              values: {
-                ' 0': '',
-                '+1': '',
-              },
-            },
-            bar: {approved: {}, values: {}},
-          },
-          permitted_labels: {
-            foo: [' 0', '+1'],
-            bar: [' 0', '+1', '+2'],
-          },
-        };
-        flushAsynchronousOperations();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
-      });
-
-      test('no quick approve if score is not maximal for a label', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            bar: {
-              value: 1,
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            bar: [' 0', '+1'],
-          },
-        };
-        flushAsynchronousOperations();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('approving label with a non-max score', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            bar: {
-              value: 1,
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            bar: [' 0', '+1', '+2'],
-          },
-        };
-        flushAsynchronousOperations();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
-      });
-    });
-
-    test('adds download revision action', () => {
-      const handler = sandbox.stub();
-      element.addEventListener('download-tap', handler);
-      assert.ok(element.revisionActions.download);
-      element._handleDownloadTap();
-      flushAsynchronousOperations();
-
-      assert.isTrue(handler.called);
-    });
-
-    test('changing changeNum or patchNum does not reload', () => {
-      const reloadStub = sandbox.stub(element, 'reload');
-      element.changeNum = 123;
-      assert.isFalse(reloadStub.called);
-      element.latestPatchNum = 456;
-      assert.isFalse(reloadStub.called);
-    });
-
-    test('_toSentenceCase', () => {
-      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
-      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
-      assert.equal(element._toSentenceCase('b'), 'B');
-      assert.equal(element._toSentenceCase(''), '');
-      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
-    });
-
-    suite('setActionOverflow', () => {
-      test('move action from overflow', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="cherrypick"]'));
-        assert.strictEqual(
-            element.$.moreActions.items[0].id, 'cherrypick-revision');
-        element.setActionOverflow('revision', 'cherrypick', false);
-        flushAsynchronousOperations();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="cherrypick"]'));
-        assert.notEqual(
-            element.$.moreActions.items[0].id, 'cherrypick-revision');
-      });
-
-      test('move action to overflow', () => {
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="submit"]'));
-        element.setActionOverflow('revision', 'submit', true);
-        flushAsynchronousOperations();
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="submit"]'));
-        assert.strictEqual(
-            element.$.moreActions.items[3].id, 'submit-revision');
-      });
-
-      suite('_waitForChangeReachable', () => {
-        setup(() => {
-          sandbox.stub(element, 'async', fn => fn());
-        });
-
-        const makeGetChange = numTries => () => {
-          if (numTries === 1) {
-            return Promise.resolve({_number: 123});
-          } else {
-            numTries--;
-            return Promise.resolve(undefined);
-          }
-        };
-
-        test('succeed', () => {
-          sandbox.stub(element.$.restAPI, 'getChange', makeGetChange(5));
-          return element._waitForChangeReachable(123).then(success => {
-            assert.isTrue(success);
-          });
-        });
-
-        test('fail', () => {
-          sandbox.stub(element.$.restAPI, 'getChange', makeGetChange(6));
-          return element._waitForChangeReachable(123).then(success => {
-            assert.isFalse(success);
-          });
-        });
-      });
-    });
-
-    suite('_send', () => {
-      let cleanup;
-      let payload;
-      let onShowError;
-      let onShowAlert;
-      let getResponseObjectStub;
-
-      setup(() => {
-        cleanup = sinon.stub();
-        element.changeNum = 42;
-        element.change._number = 42;
-        element.latestPatchNum = 12;
-        payload = {foo: 'bar'};
-
-        onShowError = sinon.stub();
-        element.addEventListener('show-error', onShowError);
-        onShowAlert = sinon.stub();
-        element.addEventListener('show-alert', onShowAlert);
-      });
-
-      suite('happy path', () => {
-        let sendStub;
-        setup(() => {
-          sandbox.stub(element, 'fetchChangeUpdates')
-              .returns(Promise.resolve({isLatest: true}));
-          sendStub = sandbox.stub(element.$.restAPI, 'executeChangeAction')
-              .returns(Promise.resolve({}));
-          getResponseObjectStub = sandbox.stub(element.$.restAPI,
-              'getResponseObject');
-          sandbox.stub(GerritNav,
-              'navigateToChange').returns(Promise.resolve(true));
-          sandbox.stub(element, 'computeLatestPatchNum')
-              .returns(element.latestPatchNum);
-        });
-
-        test('change action', done => {
-          element
-              ._send('DELETE', payload, '/endpoint', false, cleanup)
-              .then(() => {
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.calledOnce);
-                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
-                    null, payload));
-                done();
-              });
-        });
-
-        suite('show revert submission dialog', () => {
-          setup(() => {
-            element.change.submission_id = '199';
-            element.change.current_revision = '2000';
-            sandbox.stub(element.$.restAPI, 'getChanges')
-                .returns(Promise.resolve([
-                  {change_id: '12345678901234', topic: 'T', subject: 'random'},
-                  {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
-                ]));
-          });
-
-          test('revert submission shows submissionId', done => {
-            const expectedMsg = 'Revert submission 199' + '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
-              'Reverted Changes:' + '\n' +
-              '1234567890: random' + '\n' +
-              '23456: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            const modifiedMsg = expectedMsg + 'abcd';
-            sandbox.stub(element.$.confirmRevertSubmissionDialog,
-                '_modifyRevertSubmissionMsg').returns(modifiedMsg);
-            element.showRevertSubmissionDialog();
-            flush(() => {
-              const msg = element.$.confirmRevertSubmissionDialog.message;
-              assert.equal(msg, modifiedMsg);
-              done();
-            });
-          });
-        });
-
-        suite('single changes revert', () => {
-          let navigateToSearchQueryStub;
-          setup(() => {
-            getResponseObjectStub
-                .returns(Promise.resolve({revert_changes: [
-                  {change_id: 12345},
-                ]}));
-            navigateToSearchQueryStub = sandbox.stub(GerritNav,
-                'navigateToSearchQuery');
-          });
-
-          test('revert submission single change', done => {
-            element._send('POST', {message: 'Revert submission'},
-                '/revert_submission', false, cleanup).then(res => {
-              element._handleResponse({__key: 'revert_submission'}, {}).
-                  then(() => {
-                    assert.isTrue(navigateToSearchQueryStub.called);
-                    done();
-                  });
-            });
-          });
-        });
-
-        suite('multiple changes revert', () => {
-          let showActionDialogStub;
-          let navigateToSearchQueryStub;
-          setup(() => {
-            getResponseObjectStub
-                .returns(Promise.resolve({revert_changes: [
-                  {change_id: 12345, topic: 'T'},
-                  {change_id: 23456, topic: 'T'},
-                ]}));
-            showActionDialogStub = sandbox.stub(element, '_showActionDialog');
-            navigateToSearchQueryStub = sandbox.stub(GerritNav,
-                'navigateToSearchQuery');
-          });
-
-          test('revert submission multiple change', done => {
-            element._send('POST', {message: 'Revert submission'},
-                '/revert_submission', false, cleanup).then(res => {
-              element._handleResponse({__key: 'revert_submission'}, {}).then(
-                  () => {
-                    assert.isFalse(showActionDialogStub.called);
-                    assert.isTrue(navigateToSearchQueryStub.calledWith(
-                        'topic: T'));
-                    done();
-                  });
-            });
-          });
-        });
-
-        test('revision action', done => {
-          element
-              ._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.calledOnce);
-                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
-                    12, payload));
-                done();
-              });
-        });
-      });
-
-      suite('failure modes', () => {
-        test('non-latest', () => {
-          sandbox.stub(element, 'fetchChangeUpdates')
-              .returns(Promise.resolve({isLatest: false}));
-          const sendStub = sandbox.stub(element.$.restAPI,
-              'executeChangeAction');
-
-          return element._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isTrue(onShowAlert.calledOnce);
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.calledOnce);
-                assert.isFalse(sendStub.called);
-              });
-        });
-
-        test('send fails', () => {
-          sandbox.stub(element, 'fetchChangeUpdates')
-              .returns(Promise.resolve({isLatest: true}));
-          const sendStub = sandbox.stub(element.$.restAPI,
-              'executeChangeAction',
-              (num, method, patchNum, endpoint, payload, onErr) => {
-                onErr();
-                return Promise.resolve(null);
-              });
-          const handleErrorStub = sandbox.stub(element, '_handleResponseError');
-
-          return element._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.called);
-                assert.isTrue(sendStub.calledOnce);
-                assert.isTrue(handleErrorStub.called);
-              });
-        });
-      });
-    });
-
-    test('_handleAction reports', () => {
-      sandbox.stub(element, '_fireAction');
-      const reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
-      element._handleAction('type', 'key');
-      assert.isTrue(reportStub.called);
-      assert.equal(reportStub.lastCall.args[0], 'type-key');
-    });
-  });
-
-  suite('getChangeRevisionActions returns only some actions', () => {
-    let element;
-    let sandbox;
-    let changeRevisionActions;
-
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getChangeRevisionActions() {
-          return Promise.resolve(changeRevisionActions);
-        },
-        send(method, url, payload) {
-          return Promise.reject(new Error('error'));
-        },
-        getProjectConfig() { return Promise.resolve({}); },
-      });
-
-      sandbox = sinon.sandbox.create();
-      sandbox.stub(pluginLoader, 'awaitPluginsLoaded')
-          .returns(Promise.resolve());
-
-      element = fixture('basic');
-      // getChangeRevisionActions is not called without
-      // set the following properies
-      element.change = {};
-      element.changeNum = '42';
-      element.latestPatchNum = '2';
-
-      sandbox.stub(element.$.confirmCherrypick.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
-      sandbox.stub(element.$.confirmMove.$.restAPI,
-          'getRepoBranches').returns(Promise.resolve([]));
-      return element.reload();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('confirmSubmitDialog and confirmRebase properties are changed', () => {
-      changeRevisionActions = {};
-      element.reload();
-      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
-      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
-    });
-
-    test('_computeRebaseOnCurrent', () => {
-      const rebaseAction = {
-        enabled: true,
-        label: 'Rebase',
-        method: 'POST',
-        title: 'Rebase onto tip of branch or parent change',
-      };
-
-      // When rebase is enabled initially, rebaseOnCurrent should be set to
-      // true.
-      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
-
-      delete rebaseAction.enabled;
-
-      // When rebase is not enabled initially, rebaseOnCurrent should be set to
-      // false.
-      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
new file mode 100644
index 0000000..f5d2080
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
@@ -0,0 +1,2060 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-actions.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {generateChange} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-change-actions');
+
+const CHERRY_PICK_TYPES = {
+  SINGLE_CHANGE: 1,
+  TOPIC: 2,
+};
+// TODO(dhruvsri): remove use of _populateRevertMessage as it's private
+suite('gr-change-actions tests', () => {
+  let element;
+
+  suite('basic tests', () => {
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getChangeRevisionActions() {
+          return Promise.resolve({
+            cherrypick: {
+              method: 'POST',
+              label: 'Cherry Pick',
+              title: 'Cherry pick change to a different branch',
+              enabled: true,
+            },
+            rebase: {
+              method: 'POST',
+              label: 'Rebase',
+              title: 'Rebase onto tip of branch or parent change',
+              enabled: true,
+            },
+            submit: {
+              method: 'POST',
+              label: 'Submit',
+              title: 'Submit patch set 2 into master',
+              enabled: true,
+            },
+            revert_submission: {
+              method: 'POST',
+              label: 'Revert submission',
+              title: 'Revert this submission',
+              enabled: true,
+            },
+          });
+        },
+        send(method, url, payload) {
+          if (method !== 'POST') {
+            return Promise.reject(new Error('bad method'));
+          }
+
+          if (url === '/changes/test~42/revisions/2/submit') {
+            return Promise.resolve({
+              ok: true,
+              text() { return Promise.resolve(')]}\'\n{}'); },
+            });
+          } else if (url === '/changes/test~42/revisions/2/rebase') {
+            return Promise.resolve({
+              ok: true,
+              text() { return Promise.resolve(')]}\'\n{}'); },
+            });
+          }
+
+          return Promise.reject(new Error('bad url'));
+        },
+        getProjectConfig() { return Promise.resolve({}); },
+      });
+
+      sinon.stub(pluginLoader, 'awaitPluginsLoaded')
+          .returns(Promise.resolve());
+
+      element = basicFixture.instantiate();
+      element.change = {};
+      element.changeNum = '42';
+      element.latestPatchNum = '2';
+      element.actions = {
+        '/': {
+          method: 'DELETE',
+          label: 'Delete Change',
+          title: 'Delete change X_X',
+          enabled: true,
+        },
+      };
+      sinon.stub(element.$.confirmCherrypick.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+      sinon.stub(element.$.confirmMove.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+
+      return element.reload();
+    });
+
+    test('show-revision-actions event should fire', done => {
+      const spy = sinon.spy(element, '_sendShowRevisionActions');
+      element.reload();
+      flush(() => {
+        assert.isTrue(spy.called);
+        done();
+      });
+    });
+
+    test('primary and secondary actions split properly', () => {
+      // Submit should be the only primary action.
+      assert.equal(element._topLevelPrimaryActions.length, 1);
+      assert.equal(element._topLevelPrimaryActions[0].label, 'Submit');
+      assert.equal(element._topLevelSecondaryActions.length,
+          element._topLevelActions.length - 1);
+    });
+
+    test('revert submission action is skipped', () => {
+      assert.equal(element._allActionValues.filter(action =>
+        action.__key === 'submit').length, 1);
+      assert.equal(element._allActionValues.filter(action =>
+        action.__key === 'revert_submission').length, 0);
+    });
+
+    test('_shouldHideActions', () => {
+      assert.isTrue(element._shouldHideActions(undefined, true));
+      assert.isTrue(element._shouldHideActions({base: {}}, false));
+      assert.isFalse(element._shouldHideActions({base: ['test']}, false));
+    });
+
+    test('plugin revision actions', done => {
+      sinon.stub(element.$.restAPI, 'getChangeActionURL').returns(
+          Promise.resolve('the-url'));
+      element.revisionActions = {
+        'plugin~action': {},
+      };
+      assert.isOk(element.revisionActions['plugin~action']);
+      flush(() => {
+        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+            element.changeNum, element.latestPatchNum, '/plugin~action'));
+        assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
+        done();
+      });
+    });
+
+    test('plugin change actions', done => {
+      sinon.stub(element.$.restAPI, 'getChangeActionURL').returns(
+          Promise.resolve('the-url'));
+      element.actions = {
+        'plugin~action': {},
+      };
+      assert.isOk(element.actions['plugin~action']);
+      flush(() => {
+        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+            element.changeNum, null, '/plugin~action'));
+        assert.equal(element.actions['plugin~action'].__url, 'the-url');
+        done();
+      });
+    });
+
+    test('not supported actions are filtered out', () => {
+      element.revisionActions = {followup: {}};
+      assert.equal(element.querySelectorAll(
+          'section gr-button[data-action-type="revision"]').length, 0);
+    });
+
+    test('getActionDetails', () => {
+      element.revisionActions = {
+        'plugin~action': {},
+        ...element.revisionActions,
+      };
+      assert.isUndefined(element.getActionDetails('rubbish'));
+      assert.strictEqual(element.revisionActions['plugin~action'],
+          element.getActionDetails('plugin~action'));
+      assert.strictEqual(element.revisionActions['rebase'],
+          element.getActionDetails('rebase'));
+    });
+
+    test('hide revision action', done => {
+      flush(() => {
+        const buttonEl = element.shadowRoot
+            .querySelector('[data-action-key="submit"]');
+        assert.isOk(buttonEl);
+        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
+        element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, true);
+        assert.lengthOf(element._hiddenActions, 1);
+        element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, true);
+        assert.lengthOf(element._hiddenActions, 1);
+        flush(() => {
+          const buttonEl = element.shadowRoot
+              .querySelector('[data-action-key="submit"]');
+          assert.isNotOk(buttonEl);
+
+          element.setActionHidden(element.ActionType.REVISION,
+              element.RevisionActions.SUBMIT, false);
+          flush(() => {
+            const buttonEl = element.shadowRoot
+                .querySelector('[data-action-key="submit"]');
+            assert.isOk(buttonEl);
+            assert.isFalse(buttonEl.hasAttribute('hidden'));
+            done();
+          });
+        });
+      });
+    });
+
+    test('buttons exist', done => {
+      element._loading = false;
+      flush(() => {
+        const buttonEls = dom(element.root)
+            .querySelectorAll('gr-button');
+        const menuItems = element.$.moreActions.items;
+
+        // Total button number is one greater than the number of total actions
+        // due to the existence of the overflow menu trigger.
+        assert.equal(buttonEls.length + menuItems.length,
+            element._allActionValues.length + 1);
+        assert.isFalse(element.hidden);
+        done();
+      });
+    });
+
+    test('delete buttons have explicit labels', done => {
+      flush(() => {
+        const deleteItems = element.$.moreActions.items
+            .filter(item => item.id.startsWith('delete'));
+        assert.equal(deleteItems.length, 1);
+        assert.notEqual(deleteItems[0].name);
+        assert.equal(deleteItems[0].name, 'Delete change');
+        done();
+      });
+    });
+
+    test('get revision object from change', () => {
+      const revObj = {_number: 2, foo: 'bar'};
+      const change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: revObj,
+        },
+      };
+      assert.deepEqual(element._getRevision(change, '2'), revObj);
+    });
+
+    test('_actionComparator sort order', () => {
+      const actions = [
+        {label: '123', __type: 'change', __key: 'review'},
+        {label: 'abc-ro', __type: 'revision'},
+        {label: 'abc', __type: 'change'},
+        {label: 'def', __type: 'change'},
+        {label: 'def-p', __type: 'change', __primary: true},
+      ];
+
+      const result = actions.slice();
+      result.reverse();
+      result.sort(element._actionComparator.bind(element));
+      assert.deepEqual(result, actions);
+    });
+
+    test('submit change', () => {
+      const showSpy = sinon.spy(element, '_showActionDialog');
+      sinon.stub(element.$.restAPI, 'getFromProjectLookup')
+          .returns(Promise.resolve('test'));
+      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      element.change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {_number: 2},
+        },
+      };
+      element.latestPatchNum = '2';
+
+      const submitButton = element.shadowRoot
+          .querySelector('gr-button[data-action-key="submit"]');
+      assert.ok(submitButton);
+      MockInteractions.tap(submitButton);
+
+      flushAsynchronousOperations();
+      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
+    });
+
+    test('submit change, tap on icon', done => {
+      sinon.stub(element.$.confirmSubmitDialog, 'resetFocus').callsFake( done);
+      sinon.stub(element.$.restAPI, 'getFromProjectLookup')
+          .returns(Promise.resolve('test'));
+      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      element.change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {_number: 2},
+        },
+      };
+      element.latestPatchNum = '2';
+
+      const submitIcon =
+          element.shadowRoot
+              .querySelector('gr-button[data-action-key="submit"] iron-icon');
+      assert.ok(submitIcon);
+      MockInteractions.tap(submitIcon);
+    });
+
+    test('_handleSubmitConfirm', () => {
+      const fireStub = sinon.stub(element, '_fireAction');
+      sinon.stub(element, '_canSubmitChange').returns(true);
+      element._handleSubmitConfirm();
+      assert.isTrue(fireStub.calledOnce);
+      assert.deepEqual(fireStub.lastCall.args,
+          ['/submit', element.revisionActions.submit, true]);
+    });
+
+    test('_handleSubmitConfirm when not able to submit', () => {
+      const fireStub = sinon.stub(element, '_fireAction');
+      sinon.stub(element, '_canSubmitChange').returns(false);
+      element._handleSubmitConfirm();
+      assert.isFalse(fireStub.called);
+    });
+
+    test('submit change with plugin hook', done => {
+      sinon.stub(element, '_canSubmitChange').callsFake(
+          () => false);
+      const fireActionStub = sinon.stub(element, '_fireAction');
+      flush(() => {
+        const submitButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="submit"]');
+        assert.ok(submitButton);
+        MockInteractions.tap(submitButton);
+        assert.equal(fireActionStub.callCount, 0);
+
+        done();
+      });
+    });
+
+    test('chain state', () => {
+      assert.equal(element._hasKnownChainState, false);
+      element.hasParent = true;
+      assert.equal(element._hasKnownChainState, true);
+      element.hasParent = false;
+    });
+
+    test('_calculateDisabled', () => {
+      let hasKnownChainState = false;
+      const action = {__key: 'rebase', enabled: true};
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), true);
+
+      action.__key = 'delete';
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), false);
+
+      action.__key = 'rebase';
+      hasKnownChainState = true;
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), false);
+
+      action.enabled = false;
+      assert.equal(
+          element._calculateDisabled(action, hasKnownChainState), false);
+    });
+
+    test('rebase change', done => {
+      const fireActionStub = sinon.stub(element, '_fireAction');
+      const fetchChangesStub = sinon.stub(element.$.confirmRebase,
+          'fetchRecentChanges').returns(Promise.resolve([]));
+      element._hasKnownChainState = true;
+      flush(() => {
+        const rebaseButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebase"]');
+        MockInteractions.tap(rebaseButton);
+        const rebaseAction = {
+          __key: 'rebase',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Rebase',
+          method: 'POST',
+          title: 'Rebase onto tip of branch or parent change',
+        };
+        assert.isTrue(fetchChangesStub.called);
+        element._handleRebaseConfirm({detail: {base: '1234'}});
+        assert.deepEqual(fireActionStub.lastCall.args,
+            ['/rebase', rebaseAction, true, {base: '1234'}]);
+        done();
+      });
+    });
+
+    test('rebase change fires reload event', done => {
+      const eventStub = sinon.stub(element, 'dispatchEvent');
+      sinon.stub(element.$.restAPI, 'getResponseObject').returns(
+          Promise.resolve({}));
+      element._handleResponse({__key: 'rebase'}, {});
+      flush(() => {
+        assert.isTrue(eventStub.called);
+        assert.equal(eventStub.lastCall.args[0].type, 'reload');
+        done();
+      });
+    });
+
+    test(`rebase dialog gets recent changes each time it's opened`, done => {
+      const fetchChangesStub = sinon.stub(element.$.confirmRebase,
+          'fetchRecentChanges').returns(Promise.resolve([]));
+      element._hasKnownChainState = true;
+      const rebaseButton = element.shadowRoot
+          .querySelector('gr-button[data-action-key="rebase"]');
+      MockInteractions.tap(rebaseButton);
+      assert.isTrue(fetchChangesStub.calledOnce);
+
+      flush(() => {
+        element.$.confirmRebase.dispatchEvent(
+            new CustomEvent('cancel', {
+              composed: true, bubbles: true,
+            }));
+        MockInteractions.tap(rebaseButton);
+        assert.isTrue(fetchChangesStub.calledTwice);
+        done();
+      });
+    });
+
+    test('two dialogs are not shown at the same time', done => {
+      element._hasKnownChainState = true;
+      flush(() => {
+        const rebaseButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebase"]');
+        assert.ok(rebaseButton);
+        MockInteractions.tap(rebaseButton);
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.confirmRebase.hidden);
+        sinon.stub(element.$.restAPI, 'getChanges')
+            .returns(Promise.resolve([]));
+        element._handleCherrypickTap();
+        flush(() => {
+          assert.isTrue(element.$.confirmRebase.hidden);
+          assert.isFalse(element.$.confirmCherrypick.hidden);
+          done();
+        });
+      });
+    });
+
+    test('fullscreen-overlay-opened hides content', () => {
+      sinon.spy(element, '_handleHideBackgroundContent');
+      element.$.overlay.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-opened', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleHideBackgroundContent.called);
+      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('fullscreen-overlay-closed shows content', () => {
+      sinon.spy(element, '_handleShowBackgroundContent');
+      element.$.overlay.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-closed', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleShowBackgroundContent.called);
+      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('_setLabelValuesOnRevert', () => {
+      const labels = {'Foo': 1, 'Bar-Baz': -2};
+      const changeId = 1234;
+      sinon.stub(element.$.jsAPI, 'getLabelValuesPostRevert').returns(labels);
+      const saveStub = sinon.stub(element.$.restAPI, 'saveChangeReview')
+          .returns(Promise.resolve());
+      return element._setLabelValuesOnRevert(changeId).then(() => {
+        assert.isTrue(saveStub.calledOnce);
+        assert.equal(saveStub.lastCall.args[0], changeId);
+        assert.deepEqual(saveStub.lastCall.args[2], {labels});
+      });
+    });
+
+    suite('change edits', () => {
+      test('disableEdit', () => {
+        element.set('editMode', false);
+        element.set('editPatchsetLoaded', false);
+        element.change = {status: 'NEW'};
+        element.set('disableEdit', true);
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('shows confirm dialog for delete edit', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+
+        const fireActionStub = sinon.stub(element, '_fireAction');
+        element._handleDeleteEditTap();
+        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
+        MockInteractions.tap(
+            element.shadowRoot
+                .querySelector('#confirmDeleteEditDialog')
+                .shadowRoot
+                .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.equal(fireActionStub.lastCall.args[0], '/edit');
+      });
+
+      test('hide publishEdit and rebaseEdit if change is not open', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {status: 'MERGED'};
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+      });
+
+      test('edit patchset is loaded, needs rebase', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {status: 'NEW'};
+        element.editBasedOnCurrentPatchSet = false;
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit patchset is loaded, does not need rebase', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {status: 'NEW'};
+        element.editBasedOnCurrentPatchSet = true;
+        flushAsynchronousOperations();
+
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit mode is loaded, no edit patchset', () => {
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', false);
+        element.change = {status: 'NEW'};
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('normal patch set', () => {
+        element.set('editMode', false);
+        element.set('editPatchsetLoaded', false);
+        element.change = {status: 'NEW'};
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="deleteEdit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit action', done => {
+        element.addEventListener('edit-tap', () => { done(); });
+        element.set('editMode', true);
+        element.change = {status: 'NEW'};
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        assert.isOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="stopEdit"]'));
+        element.change = {status: 'MERGED'};
+        flushAsynchronousOperations();
+
+        assert.isNotOk(element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]'));
+        element.change = {status: 'NEW'};
+        element.set('editMode', false);
+        flushAsynchronousOperations();
+
+        const editButton = element.shadowRoot
+            .querySelector('gr-button[data-action-key="edit"]');
+        assert.isOk(editButton);
+        MockInteractions.tap(editButton);
+      });
+    });
+
+    suite('cherry-pick', () => {
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        sinon.stub(window, 'alert');
+      });
+
+      test('works', () => {
+        element._handleCherrypickTap();
+        const action = {
+          __key: 'cherrypick',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Cherry pick',
+          method: 'POST',
+          title: 'Cherry pick change to a different branch',
+        };
+
+        element._handleCherrypickConfirm({
+          detail: {
+            branch: '',
+            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
+          },
+        });
+        assert.equal(fireActionStub.callCount, 0);
+
+        element.$.confirmCherrypick.branch = 'master';
+        element._handleCherrypickConfirm({
+          detail: {
+            branch: 'master',
+            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
+          },
+        });
+        assert.equal(fireActionStub.callCount, 0); // Still needs a message.
+
+        // Add attributes that are used to determine the message.
+        element.$.confirmCherrypick.commitMessage = 'foo message';
+        element.$.confirmCherrypick.changeStatus = 'OPEN';
+        element.$.confirmCherrypick.commitNum = '123';
+
+        element._handleCherrypickConfirm({
+          detail: {
+            branch: 'master',
+            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
+          },
+        });
+
+        assert.equal(element.$.confirmCherrypick.shadowRoot.
+            querySelector('#messageInput').value, 'foo message');
+
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/cherrypick', action, true, {
+            destination: 'master',
+            base: null,
+            message: 'foo message',
+            allow_conflicts: false,
+          },
+        ]);
+      });
+
+      test('cherry pick even with conflicts', () => {
+        element._handleCherrypickTap();
+        const action = {
+          __key: 'cherrypick',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Cherry pick',
+          method: 'POST',
+          title: 'Cherry pick change to a different branch',
+        };
+
+        element.$.confirmCherrypick.branch = 'master';
+
+        // Add attributes that are used to determine the message.
+        element.$.confirmCherrypick.commitMessage = 'foo message';
+        element.$.confirmCherrypick.changeStatus = 'OPEN';
+        element.$.confirmCherrypick.commitNum = '123';
+
+        element._handleCherrypickConflictConfirm();
+
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/cherrypick', action, true, {
+            destination: 'master',
+            base: null,
+            message: 'foo message',
+            allow_conflicts: true,
+          },
+        ]);
+      });
+
+      test('branch name cleared when re-open cherrypick', () => {
+        const emptyBranchName = '';
+        element.$.confirmCherrypick.branch = 'master';
+
+        element._handleCherrypickTap();
+        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
+      });
+
+      suite('cherry pick topics', () => {
+        const changes = [
+          {
+            change_id: '12345678901234', topic: 'T', subject: 'random',
+            project: 'A',
+          },
+          {
+            change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
+            project: 'B',
+          },
+        ];
+        setup(done => {
+          sinon.stub(element.$.restAPI, 'getChanges')
+              .returns(Promise.resolve(changes));
+          element._handleCherrypickTap();
+          flush(() => {
+            const radioButtons = element.$.confirmCherrypick.shadowRoot.
+                querySelectorAll(`input[name='cherryPickOptions']`);
+            assert.equal(radioButtons.length, 2);
+            MockInteractions.tap(radioButtons[1]);
+            flush(() => {
+              done();
+            });
+          });
+        });
+
+        test('cherry pick topic dialog is rendered', done => {
+          const dialog = element.$.confirmCherrypick;
+          flush(() => {
+            const changesTable = dialog.shadowRoot.querySelector('table');
+            const headers = Array.from(changesTable.querySelectorAll('th'));
+            const expectedHeadings = ['Change', 'Subject', 'Project',
+              'Status', ''];
+            const headings = headers.map(header => header.innerText);
+            assert.equal(headings.length, expectedHeadings.length);
+            for (let i = 0; i < headings.length; i++) {
+              assert.equal(headings[i].trim(), expectedHeadings[i]);
+            }
+            const changeRows = changesTable.querySelectorAll('tbody > tr');
+            const change = Array.from(changeRows[0].querySelectorAll('td'))
+                .map(e => e.innerText);
+            const expectedChange = ['1234567890', 'random', 'A',
+              'NOT STARTED', ''];
+            for (let i = 0; i < change.length; i++) {
+              assert.equal(change[i].trim(), expectedChange[i]);
+            }
+            done();
+          });
+        });
+
+        test('changes with duplicate project show an error', done => {
+          const dialog = element.$.confirmCherrypick;
+          const error = dialog.shadowRoot.querySelector('.error-message');
+          assert.equal(error.innerText, '');
+          dialog.updateChanges([
+            {
+              change_id: '12345678901234', topic: 'T', subject: 'random',
+              project: 'A',
+            },
+            {
+              change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
+              project: 'A',
+            },
+          ]);
+          flush(() => {
+            assert.equal(error.innerText, 'Two changes cannot be of the same'
+             + ' project');
+            done();
+          });
+        });
+      });
+    });
+
+    suite('move change', () => {
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        sinon.stub(window, 'alert');
+      });
+
+      test('works', () => {
+        element._handleMoveTap();
+
+        element._handleMoveConfirm();
+        assert.equal(fireActionStub.callCount, 0);
+
+        element.$.confirmMove.branch = 'master';
+        element._handleMoveConfirm();
+        assert.equal(fireActionStub.callCount, 1);
+      });
+
+      test('branch name cleared when re-open move', () => {
+        const emptyBranchName = '';
+        element.$.confirmMove.branch = 'master';
+
+        element._handleMoveTap();
+        assert.equal(element.$.confirmMove.branch, emptyBranchName);
+      });
+    });
+
+    test('custom actions', done => {
+      // Add a button with the same key as a server-based one to ensure
+      // collisions are taken care of.
+      const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
+      element.addEventListener(key + '-tap', e => {
+        assert.equal(e.detail.node.getAttribute('data-action-key'), key);
+        element.removeActionButton(key);
+        flush(() => {
+          assert.notOk(element.shadowRoot
+              .querySelector('[data-action-key="' + key + '"]'));
+          done();
+        });
+      });
+      flush(() => {
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]'));
+      });
+    });
+
+    test('_setLoadingOnButtonWithKey top-level', () => {
+      const key = 'rebase';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
+      assert.equal(element._actionLoadingMessage, 'Rebasing...');
+
+      const button = element.shadowRoot
+          .querySelector('[data-action-key="' + key + '"]');
+      assert.isTrue(button.hasAttribute('loading'));
+      assert.isTrue(button.disabled);
+
+      assert.isOk(cleanup);
+      assert.isFunction(cleanup);
+      cleanup();
+
+      assert.isFalse(button.hasAttribute('loading'));
+      assert.isFalse(button.disabled);
+      assert.isNotOk(element._actionLoadingMessage);
+    });
+
+    test('_setLoadingOnButtonWithKey overflow menu', () => {
+      const key = 'cherrypick';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
+      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
+      assert.include(element._disabledMenuActions, 'cherrypick');
+      assert.isFunction(cleanup);
+
+      cleanup();
+
+      assert.notOk(element._actionLoadingMessage);
+      assert.notInclude(element._disabledMenuActions, 'cherrypick');
+    });
+
+    suite('abandon change', () => {
+      let alertStub;
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        alertStub = sinon.stub(window, 'alert');
+        element.actions = {
+          abandon: {
+            method: 'POST',
+            label: 'Abandon',
+            title: 'Abandon the change',
+            enabled: true,
+          },
+        };
+        return element.reload();
+      });
+
+      test('abandon change with message', done => {
+        const newAbandonMsg = 'Test Abandon Message';
+        element.$.confirmAbandonDialog.message = newAbandonMsg;
+        flush(() => {
+          const abandonButton =
+              element.shadowRoot
+                  .querySelector('gr-button[data-action-key="abandon"]');
+          MockInteractions.tap(abandonButton);
+
+          assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
+          done();
+        });
+      });
+
+      test('abandon change with no message', done => {
+        flush(() => {
+          const abandonButton =
+              element.shadowRoot
+                  .querySelector('gr-button[data-action-key="abandon"]');
+          MockInteractions.tap(abandonButton);
+
+          assert.isUndefined(element.$.confirmAbandonDialog.message);
+          done();
+        });
+      });
+
+      test('works', () => {
+        element.$.confirmAbandonDialog.message = 'original message';
+        const restoreButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key="abandon"]');
+        MockInteractions.tap(restoreButton);
+
+        element.$.confirmAbandonDialog.message = 'foo message';
+        element._handleAbandonDialogConfirm();
+        assert.notOk(alertStub.called);
+
+        const action = {
+          __key: 'abandon',
+          __type: 'change',
+          __primary: false,
+          enabled: true,
+          label: 'Abandon',
+          method: 'POST',
+          title: 'Abandon the change',
+        };
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/abandon', action, false, {
+            message: 'foo message',
+          }]);
+      });
+    });
+
+    suite('revert change', () => {
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        element.commitMessage = 'random commit message';
+        element.change.current_revision = 'abcdef';
+        element.actions = {
+          revert: {
+            method: 'POST',
+            label: 'Revert',
+            title: 'Revert the change',
+            enabled: true,
+          },
+        };
+        return element.reload();
+      });
+
+      test('revert change with plugin hook', done => {
+        const newRevertMsg = 'Modified revert msg';
+        sinon.stub(element.$.confirmRevertDialog, '_modifyRevertMsg').callsFake(
+            () => newRevertMsg);
+        element.change = {
+          current_revision: 'abc1234',
+        };
+        sinon.stub(element.$.restAPI, 'getChanges')
+            .returns(Promise.resolve([
+              {change_id: '12345678901234', topic: 'T', subject: 'random'},
+              {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
+            ]));
+        sinon.stub(element.$.confirmRevertDialog,
+            '_populateRevertSubmissionMessage').callsFake(() => 'original msg');
+        flush(() => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
+            done();
+          });
+        });
+      });
+
+      suite('revert change submitted together', () => {
+        let getChangesStub;
+        setup(() => {
+          element.change = {
+            submission_id: '199 0',
+            current_revision: '2000',
+          };
+          getChangesStub = sinon.stub(element.$.restAPI, 'getChanges')
+              .returns(Promise.resolve([
+                {change_id: '12345678901234', topic: 'T', subject: 'random'},
+                {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
+              ]));
+        });
+
+        test('confirm revert dialog shows both options', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
+            const confirmRevertDialog = element.$.confirmRevertDialog;
+            const revertSingleChangeLabel = confirmRevertDialog
+                .shadowRoot.querySelector('.revertSingleChange');
+            const revertSubmissionLabel = confirmRevertDialog.
+                shadowRoot.querySelector('.revertSubmission');
+            assert(revertSingleChangeLabel.innerText.trim() ===
+                'Revert single change');
+            assert(revertSubmissionLabel.innerText.trim() ===
+                'Revert entire submission (2 Changes)');
+            let expectedMsg = 'Revert submission 199 0' + '\n\n' +
+              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
+              'Reverted Changes:' + '\n' +
+              '1234567890:random' + '\n' +
+              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+              '\n';
+            assert.equal(confirmRevertDialog._message, expectedMsg);
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            MockInteractions.tap(radioInputs[0]);
+            flush(() => {
+              expectedMsg = 'Revert "random commit message"\n\nThis reverts '
+               + 'commit 2000.\n\nReason'
+               + ' for revert: <INSERT REASONING HERE>\n';
+              assert.equal(confirmRevertDialog._message, expectedMsg);
+              done();
+            });
+          });
+        });
+
+        test('submit fails if message is not edited', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          MockInteractions.tap(revertButton);
+          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
+          flush(() => {
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.isTrue(confirmRevertDialog._showErrorMessage);
+              assert.isFalse(fireStub.called);
+              done();
+            });
+          });
+        });
+
+        test('message modification is retained on switching', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            const revertSubmissionMsg = 'Revert submission 199 0' + '\n\n' +
+            'Reason for revert: <INSERT REASONING HERE>' + '\n' +
+            'Reverted Changes:' + '\n' +
+            '1234567890:random' + '\n' +
+            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+            '\n';
+            const singleChangeMsg =
+            'Revert "random commit message"\n\nThis reverts '
+              + 'commit 2000.\n\nReason'
+              + ' for revert: <INSERT REASONING HERE>\n';
+            assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
+            const newRevertMsg = revertSubmissionMsg + 'random';
+            const newSingleChangeMsg = singleChangeMsg + 'random';
+            confirmRevertDialog._message = newRevertMsg;
+            MockInteractions.tap(radioInputs[0]);
+            flush(() => {
+              assert.equal(confirmRevertDialog._message, singleChangeMsg);
+              confirmRevertDialog._message = newSingleChangeMsg;
+              MockInteractions.tap(radioInputs[1]);
+              flush(() => {
+                assert.equal(confirmRevertDialog._message, newRevertMsg);
+                MockInteractions.tap(radioInputs[0]);
+                flush(() => {
+                  assert.equal(
+                      confirmRevertDialog._message,
+                      newSingleChangeMsg
+                  );
+                  done();
+                });
+              });
+            });
+          });
+        });
+      });
+
+      suite('revert single change', () => {
+        setup(() => {
+          element.change = {
+            submission_id: '199',
+            current_revision: '2000',
+          };
+          sinon.stub(element.$.restAPI, 'getChanges')
+              .returns(Promise.resolve([
+                {change_id: '12345678901234', topic: 'T', subject: 'random'},
+              ]));
+        });
+
+        test('submit fails if message is not edited', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          MockInteractions.tap(revertButton);
+          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
+          flush(() => {
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.isTrue(confirmRevertDialog._showErrorMessage);
+              assert.isFalse(fireStub.called);
+              done();
+            });
+          });
+        });
+
+        test('confirm revert dialog shows no radio button', done => {
+          const revertButton = element.shadowRoot
+              .querySelector('gr-button[data-action-key="revert"]');
+          MockInteractions.tap(revertButton);
+          flush(() => {
+            const confirmRevertDialog = element.$.confirmRevertDialog;
+            const radioInputs = confirmRevertDialog.shadowRoot
+                .querySelectorAll('input[name="revertOptions"]');
+            assert.equal(radioInputs.length, 0);
+            const msg = 'Revert "random commit message"\n\n'
+              + 'This reverts commit 2000.\n\nReason '
+              + 'for revert: <INSERT REASONING HERE>\n';
+            assert.equal(confirmRevertDialog._message, msg);
+            const editedMsg = msg + 'hello';
+            confirmRevertDialog._message += 'hello';
+            const confirmButton = element.$.confirmRevertDialog.shadowRoot
+                .querySelector('gr-dialog')
+                .shadowRoot.querySelector('#confirm');
+            MockInteractions.tap(confirmButton);
+            flush(() => {
+              assert.equal(fireActionStub.getCall(0).args[0], '/revert');
+              assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
+              assert.equal(fireActionStub.getCall(0).args[3].message,
+                  editedMsg);
+              done();
+            });
+          });
+        });
+      });
+    });
+
+    suite('mark change private', () => {
+      setup(() => {
+        const privateAction = {
+          __key: 'private',
+          __type: 'change',
+          __primary: false,
+          method: 'POST',
+          label: 'Mark private',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          private: privateAction,
+        };
+
+        element.change.is_private = false;
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        return element.reload();
+      });
+
+      test('make sure the mark private change button is not outside of the ' +
+           'overflow menu', done => {
+        flush(() => {
+          assert.isNotOk(element.shadowRoot
+              .querySelector('[data-action-key="private"]'));
+          done();
+        });
+      });
+
+      test('private change', done => {
+        flush(() => {
+          assert.isOk(
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private-change"]'));
+          element.setActionOverflow('change', 'private', false);
+          flushAsynchronousOperations();
+          assert.isOk(element.shadowRoot
+              .querySelector('[data-action-key="private"]'));
+          assert.isNotOk(
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private-change"]'));
+          done();
+        });
+      });
+    });
+
+    suite('unmark private change', () => {
+      setup(() => {
+        const unmarkPrivateAction = {
+          __key: 'private.delete',
+          __type: 'change',
+          __primary: false,
+          method: 'POST',
+          label: 'Unmark private',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          'private.delete': unmarkPrivateAction,
+        };
+
+        element.change.is_private = true;
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        return element.reload();
+      });
+
+      test('make sure the unmark private change button is not outside of the ' +
+           'overflow menu', done => {
+        flush(() => {
+          assert.isNotOk(element.shadowRoot
+              .querySelector('[data-action-key="private.delete"]'));
+          done();
+        });
+      });
+
+      test('unmark the private change', done => {
+        flush(() => {
+          assert.isOk(
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private.delete-change"]')
+          );
+          element.setActionOverflow('change', 'private.delete', false);
+          flushAsynchronousOperations();
+          assert.isOk(element.shadowRoot
+              .querySelector('[data-action-key="private.delete"]'));
+          assert.isNotOk(
+              element.$.moreActions.shadowRoot
+                  .querySelector('span[data-id="private.delete-change"]')
+          );
+          done();
+        });
+      });
+    });
+
+    suite('delete change', () => {
+      let fireActionStub;
+      let deleteAction;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        element.change = {
+          current_revision: 'abc1234',
+        };
+        deleteAction = {
+          method: 'DELETE',
+          label: 'Delete Change',
+          title: 'Delete change X_X',
+          enabled: true,
+        };
+        element.actions = {
+          '/': deleteAction,
+        };
+      });
+
+      test('does not delete on action', () => {
+        element._handleDeleteTap();
+        assert.isFalse(fireActionStub.called);
+      });
+
+      test('shows confirm dialog', () => {
+        element._handleDeleteTap();
+        assert.isFalse(element.shadowRoot
+            .querySelector('#confirmDeleteDialog').hidden);
+        MockInteractions.tap(
+            element.shadowRoot
+                .querySelector('#confirmDeleteDialog')
+                .shadowRoot
+                .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+        assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
+      });
+
+      test('hides delete confirm on cancel', () => {
+        element._handleDeleteTap();
+        MockInteractions.tap(
+            element.shadowRoot
+                .querySelector('#confirmDeleteDialog')
+                .shadowRoot
+                .querySelector('gr-button:not([primary])'));
+        flushAsynchronousOperations();
+        assert.isTrue(element.shadowRoot
+            .querySelector('#confirmDeleteDialog').hidden);
+        assert.isFalse(fireActionStub.called);
+      });
+    });
+
+    suite('ignore change', () => {
+      setup(done => {
+        sinon.stub(element, '_fireAction');
+
+        const IgnoreAction = {
+          __key: 'ignore',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Ignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          ignore: IgnoreAction,
+        };
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('make sure the ignore button is not outside of the overflow menu',
+          () => {
+            assert.isNotOk(element.shadowRoot
+                .querySelector('[data-action-key="ignore"]'));
+          });
+
+      test('ignoring change', () => {
+        assert.isOk(element.$.moreActions.shadowRoot
+            .querySelector('span[data-id="ignore-change"]'));
+        element.setActionOverflow('change', 'ignore', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="ignore"]'));
+        assert.isNotOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="ignore-change"]'));
+      });
+    });
+
+    suite('unignore change', () => {
+      setup(done => {
+        sinon.stub(element, '_fireAction');
+
+        const UnignoreAction = {
+          __key: 'unignore',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Unignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          unignore: UnignoreAction,
+        };
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('unignore button is not outside of the overflow menu', () => {
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="unignore"]'));
+      });
+
+      test('unignoring change', () => {
+        assert.isOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unignore-change"]'));
+        element.setActionOverflow('change', 'unignore', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="unignore"]'));
+        assert.isNotOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unignore-change"]'));
+      });
+    });
+
+    suite('reviewed change', () => {
+      setup(done => {
+        sinon.stub(element, '_fireAction');
+
+        const ReviewedAction = {
+          __key: 'reviewed',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Mark reviewed',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          reviewed: ReviewedAction,
+        };
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('action is enabled', () => {
+        assert.equal(element._allActionValues.filter(action =>
+          action.__key === 'reviewed').length, 1);
+      });
+
+      test('action is skipped when attention set is enabled', () => {
+        element._config = {
+          change: {enable_attention_set: true},
+        };
+        assert.equal(element._allActionValues.filter(action =>
+          action.__key === 'reviewed').length, 0);
+      });
+
+      test('make sure the reviewed button is not outside of the overflow menu',
+          () => {
+            assert.isNotOk(element.shadowRoot
+                .querySelector('[data-action-key="reviewed"]'));
+          });
+
+      test('reviewing change', () => {
+        assert.isOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="reviewed-change"]'));
+        element.setActionOverflow('change', 'reviewed', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="reviewed"]'));
+        assert.isNotOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="reviewed-change"]'));
+      });
+    });
+
+    suite('unreviewed change', () => {
+      setup(done => {
+        sinon.stub(element, '_fireAction');
+
+        const UnreviewedAction = {
+          __key: 'unreviewed',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Mark unreviewed',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          unreviewed: UnreviewedAction,
+        };
+
+        element.changeNum = '2';
+        element.latestPatchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('unreviewed button not outside of the overflow menu', () => {
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="unreviewed"]'));
+      });
+
+      test('unreviewed change', () => {
+        assert.isOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unreviewed-change"]'));
+        element.setActionOverflow('change', 'unreviewed', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="unreviewed"]'));
+        assert.isNotOk(
+            element.$.moreActions.shadowRoot
+                .querySelector('span[data-id="unreviewed-change"]'));
+      });
+    });
+
+    suite('quick approve', () => {
+      setup(() => {
+        element.change = {
+          current_revision: 'abc1234',
+        };
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {
+              values: {
+                '-1': '',
+                ' 0': '',
+                '+1': '',
+              },
+            },
+          },
+          permitted_labels: {
+            foo: ['-1', ' 0', '+1'],
+          },
+        };
+        flushAsynchronousOperations();
+      });
+
+      test('added when can approve', () => {
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNotNull(approveButton);
+      });
+
+      test('hide quick approve', () => {
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNotNull(approveButton);
+        assert.isFalse(element._hideQuickApproveAction);
+
+        // Assert approve button gets removed from list of buttons.
+        element.hideQuickApproveAction();
+        flushAsynchronousOperations();
+        const approveButtonUpdated =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButtonUpdated);
+        assert.isTrue(element._hideQuickApproveAction);
+      });
+
+      test('is first in list of secondary actions', () => {
+        const approveButton = element.$.secondaryActions
+            .querySelector('gr-button');
+        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
+      });
+
+      test('not added when already approved', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {
+              approved: {},
+              values: {},
+            },
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+          },
+        };
+        flushAsynchronousOperations();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('not added when label not permitted', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {values: {}},
+          },
+          permitted_labels: {
+            bar: [],
+          },
+        };
+        flushAsynchronousOperations();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('approves when tapped', () => {
+        const fireActionStub = sinon.stub(element, '_fireAction');
+        MockInteractions.tap(
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']'));
+        flushAsynchronousOperations();
+        assert.isTrue(fireActionStub.called);
+        assert.isTrue(fireActionStub.calledWith('/review'));
+        const payload = fireActionStub.lastCall.args[3];
+        assert.deepEqual(payload.labels, {foo: '+1'});
+      });
+
+      test('not added when multiple labels are required', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {values: {}},
+            bar: {values: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flushAsynchronousOperations();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('button label for missing approval', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            foo: {
+              values: {
+                ' 0': '',
+                '+1': '',
+              },
+            },
+            bar: {approved: {}, values: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flushAsynchronousOperations();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
+      });
+
+      test('no quick approve if score is not maximal for a label', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1'],
+          },
+        };
+        flushAsynchronousOperations();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.isNull(approveButton);
+      });
+
+      test('approving label with a non-max score', () => {
+        element.change = {
+          current_revision: 'abc1234',
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        flushAsynchronousOperations();
+        const approveButton =
+            element.shadowRoot
+                .querySelector('gr-button[data-action-key=\'review\']');
+        assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
+      });
+    });
+
+    test('adds download revision action', () => {
+      const handler = sinon.stub();
+      element.addEventListener('download-tap', handler);
+      assert.ok(element.revisionActions.download);
+      element._handleDownloadTap();
+      flushAsynchronousOperations();
+
+      assert.isTrue(handler.called);
+    });
+
+    test('changing changeNum or patchNum does not reload', () => {
+      const reloadStub = sinon.stub(element, 'reload');
+      element.changeNum = 123;
+      assert.isFalse(reloadStub.called);
+      element.latestPatchNum = 456;
+      assert.isFalse(reloadStub.called);
+    });
+
+    test('_toSentenceCase', () => {
+      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
+      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
+      assert.equal(element._toSentenceCase('b'), 'B');
+      assert.equal(element._toSentenceCase(''), '');
+      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
+    });
+
+    suite('setActionOverflow', () => {
+      test('move action from overflow', () => {
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="cherrypick"]'));
+        assert.strictEqual(
+            element.$.moreActions.items[0].id, 'cherrypick-revision');
+        element.setActionOverflow('revision', 'cherrypick', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="cherrypick"]'));
+        assert.notEqual(
+            element.$.moreActions.items[0].id, 'cherrypick-revision');
+      });
+
+      test('move action to overflow', () => {
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="submit"]'));
+        element.setActionOverflow('revision', 'submit', true);
+        flushAsynchronousOperations();
+        assert.isNotOk(element.shadowRoot
+            .querySelector('[data-action-key="submit"]'));
+        assert.strictEqual(
+            element.$.moreActions.items[3].id, 'submit-revision');
+      });
+
+      suite('_waitForChangeReachable', () => {
+        setup(() => {
+          sinon.stub(element, 'async').callsFake( fn => fn());
+        });
+
+        const makeGetChange = numTries => () => {
+          if (numTries === 1) {
+            return Promise.resolve({_number: 123});
+          } else {
+            numTries--;
+            return Promise.resolve(undefined);
+          }
+        };
+
+        test('succeed', () => {
+          sinon.stub(element.$.restAPI, 'getChange')
+              .callsFake( makeGetChange(5));
+          return element._waitForChangeReachable(123).then(success => {
+            assert.isTrue(success);
+          });
+        });
+
+        test('fail', () => {
+          sinon.stub(element.$.restAPI, 'getChange')
+              .callsFake( makeGetChange(6));
+          return element._waitForChangeReachable(123).then(success => {
+            assert.isFalse(success);
+          });
+        });
+      });
+    });
+
+    suite('_send', () => {
+      let cleanup;
+      let payload;
+      let onShowError;
+      let onShowAlert;
+      let getResponseObjectStub;
+
+      setup(() => {
+        cleanup = sinon.stub();
+        element.changeNum = 42;
+        element.change._number = 42;
+        element.latestPatchNum = 12;
+        element.change = generateChange({
+          revisionsCount: element.latestPatchNum,
+          messagesCount: 1,
+        });
+        payload = {foo: 'bar'};
+
+        onShowError = sinon.stub();
+        element.addEventListener('show-error', onShowError);
+        onShowAlert = sinon.stub();
+        element.addEventListener('show-alert', onShowAlert);
+      });
+
+      suite('happy path', () => {
+        let sendStub;
+        setup(() => {
+          sinon.stub(element.$.restAPI, 'getChangeDetail')
+              .returns(Promise.resolve(
+                  generateChange({
+                    // element has latest info
+                    revisionsCount: element.latestPatchNum,
+                    messagesCount: 1,
+                  })));
+          sendStub = sinon.stub(element.$.restAPI, 'executeChangeAction')
+              .returns(Promise.resolve({}));
+          getResponseObjectStub = sinon.stub(element.$.restAPI,
+              'getResponseObject');
+          sinon.stub(GerritNav,
+              'navigateToChange').returns(Promise.resolve(true));
+        });
+
+        test('change action', done => {
+          element
+              ._send('DELETE', payload, '/endpoint', false, cleanup)
+              .then(() => {
+                assert.isFalse(onShowError.called);
+                assert.isTrue(cleanup.calledOnce);
+                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
+                    null, payload));
+                done();
+              });
+        });
+
+        suite('show revert submission dialog', () => {
+          setup(() => {
+            element.change.submission_id = '199';
+            element.change.current_revision = '2000';
+            sinon.stub(element.$.restAPI, 'getChanges')
+                .returns(Promise.resolve([
+                  {change_id: '12345678901234', topic: 'T', subject: 'random'},
+                  {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
+                ]));
+          });
+
+          test('revert submission shows submissionId', done => {
+            const expectedMsg = 'Revert submission 199' + '\n\n' +
+              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
+              'Reverted Changes:' + '\n' +
+              '1234567890: random' + '\n' +
+              '23456: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+              '\n';
+            const modifiedMsg = expectedMsg + 'abcd';
+            sinon.stub(element.$.confirmRevertSubmissionDialog,
+                '_modifyRevertSubmissionMsg').returns(modifiedMsg);
+            element.showRevertSubmissionDialog();
+            flush(() => {
+              const msg = element.$.confirmRevertSubmissionDialog.message;
+              assert.equal(msg, modifiedMsg);
+              done();
+            });
+          });
+        });
+
+        suite('single changes revert', () => {
+          let navigateToSearchQueryStub;
+          setup(() => {
+            getResponseObjectStub
+                .returns(Promise.resolve({revert_changes: [
+                  {change_id: 12345},
+                ]}));
+            navigateToSearchQueryStub = sinon.stub(GerritNav,
+                'navigateToSearchQuery');
+          });
+
+          test('revert submission single change', done => {
+            element._send('POST', {message: 'Revert submission'},
+                '/revert_submission', false, cleanup).then(res => {
+              element._handleResponse({__key: 'revert_submission'}, {}).
+                  then(() => {
+                    assert.isTrue(navigateToSearchQueryStub.called);
+                    done();
+                  });
+            });
+          });
+        });
+
+        suite('multiple changes revert', () => {
+          let showActionDialogStub;
+          let navigateToSearchQueryStub;
+          setup(() => {
+            getResponseObjectStub
+                .returns(Promise.resolve({revert_changes: [
+                  {change_id: 12345, topic: 'T'},
+                  {change_id: 23456, topic: 'T'},
+                ]}));
+            showActionDialogStub = sinon.stub(element, '_showActionDialog');
+            navigateToSearchQueryStub = sinon.stub(GerritNav,
+                'navigateToSearchQuery');
+          });
+
+          test('revert submission multiple change', done => {
+            element._send('POST', {message: 'Revert submission'},
+                '/revert_submission', false, cleanup).then(res => {
+              element._handleResponse({__key: 'revert_submission'}, {}).then(
+                  () => {
+                    assert.isFalse(showActionDialogStub.called);
+                    assert.isTrue(navigateToSearchQueryStub.calledWith(
+                        'topic: T'));
+                    done();
+                  });
+            });
+          });
+        });
+
+        test('revision action', done => {
+          element
+              ._send('DELETE', payload, '/endpoint', true, cleanup)
+              .then(() => {
+                assert.isFalse(onShowError.called);
+                assert.isTrue(cleanup.calledOnce);
+                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
+                    12, payload));
+                done();
+              });
+        });
+      });
+
+      suite('failure modes', () => {
+        test('non-latest', () => {
+          sinon.stub(element.$.restAPI, 'getChangeDetail')
+              .returns(Promise.resolve(
+                  generateChange({
+                    // new patchset was uploaded
+                    revisionsCount: element.latestPatchNum + 1,
+                    messagesCount: 1,
+                  })));
+          const sendStub = sinon.stub(element.$.restAPI,
+              'executeChangeAction');
+
+          return element._send('DELETE', payload, '/endpoint', true, cleanup)
+              .then(() => {
+                assert.isTrue(onShowAlert.calledOnce);
+                assert.isFalse(onShowError.called);
+                assert.isTrue(cleanup.calledOnce);
+                assert.isFalse(sendStub.called);
+              });
+        });
+
+        test('send fails', () => {
+          sinon.stub(element.$.restAPI, 'getChangeDetail')
+              .returns(Promise.resolve(
+                  generateChange({
+                    // element has latest info
+                    revisionsCount: element.latestPatchNum,
+                    messagesCount: 1,
+                  })));
+          const sendStub = sinon.stub(element.$.restAPI,
+              'executeChangeAction').callsFake(
+              (num, method, patchNum, endpoint, payload, onErr) => {
+                onErr();
+                return Promise.resolve(null);
+              });
+          const handleErrorStub = sinon.stub(element, '_handleResponseError');
+
+          return element._send('DELETE', payload, '/endpoint', true, cleanup)
+              .then(() => {
+                assert.isFalse(onShowError.called);
+                assert.isTrue(cleanup.called);
+                assert.isTrue(sendStub.calledOnce);
+                assert.isTrue(handleErrorStub.called);
+              });
+        });
+      });
+    });
+
+    test('_handleAction reports', () => {
+      sinon.stub(element, '_fireAction');
+      const reportStub = sinon.stub(element.reporting, 'reportInteraction');
+      element._handleAction('type', 'key');
+      assert.isTrue(reportStub.called);
+      assert.equal(reportStub.lastCall.args[0], 'type-key');
+    });
+  });
+
+  suite('getChangeRevisionActions returns only some actions', () => {
+    let element;
+
+    let changeRevisionActions;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getChangeRevisionActions() {
+          return Promise.resolve(changeRevisionActions);
+        },
+        send(method, url, payload) {
+          return Promise.reject(new Error('error'));
+        },
+        getProjectConfig() { return Promise.resolve({}); },
+      });
+
+      sinon.stub(pluginLoader, 'awaitPluginsLoaded')
+          .returns(Promise.resolve());
+
+      element = basicFixture.instantiate();
+      // getChangeRevisionActions is not called without
+      // set the following properies
+      element.change = {};
+      element.changeNum = '42';
+      element.latestPatchNum = '2';
+
+      sinon.stub(element.$.confirmCherrypick.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+      sinon.stub(element.$.confirmMove.$.restAPI,
+          'getRepoBranches').returns(Promise.resolve([]));
+      return element.reload();
+    });
+
+    test('confirmSubmitDialog and confirmRebase properties are changed', () => {
+      changeRevisionActions = {};
+      element.reload();
+      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
+      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
+    });
+
+    test('_computeRebaseOnCurrent', () => {
+      const rebaseAction = {
+        enabled: true,
+        label: 'Rebase',
+        method: 'POST',
+        title: 'Rebase onto tip of branch or parent change',
+      };
+
+      // When rebase is enabled initially, rebaseOnCurrent should be set to
+      // true.
+      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
+
+      delete rebaseAction.enabled;
+
+      // When rebase is not enabled initially, rebaseOnCurrent should be set to
+      // false.
+      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
deleted file mode 100644
index d08f529..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
+++ /dev/null
@@ -1,183 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-change-metadata</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="element">
-  <template>
-    <gr-change-metadata mutable="true"></gr-change-metadata>
-  </template>
-</test-fixture>
-
-<test-fixture id="plugin-host">
-  <template>
-    <gr-plugin-host></gr-plugin-host>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../plugins/gr-plugin-host/gr-plugin-host.js';
-import './gr-change-metadata.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-change-metadata integration tests', () => {
-  let sandbox;
-  let element;
-
-  const sectionSelectors = [
-    'section.strategy',
-    'section.topic',
-  ];
-
-  const labels = {
-    CI: {
-      all: [
-        {value: 1, name: 'user 2', _account_id: 1},
-        {value: 2, name: 'user '},
-      ],
-      values: {
-        ' 0': 'Don\'t submit as-is',
-        '+1': 'No score',
-        '+2': 'Looks good to me',
-      },
-    },
-  };
-
-  const getStyle = function(selector, name) {
-    return window.getComputedStyle(
-        dom(element.root).querySelector(selector))[name];
-  };
-
-  function createElement() {
-    const element = fixture('element');
-    element.change = {labels, status: 'NEW'};
-    element.revision = {};
-    return element;
-  }
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-      deleteVote() { return Promise.resolve({ok: true}); },
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-    resetPlugins();
-  });
-
-  suite('by default', () => {
-    setup(done => {
-      element = createElement();
-      flush(done);
-    });
-
-    for (const sectionSelector of sectionSelectors) {
-      test(sectionSelector + ' does not have display: none', () => {
-        assert.notEqual(getStyle(sectionSelector, 'display'), 'none');
-      });
-    }
-  });
-
-  suite('with plugin style', () => {
-    setup(done => {
-      resetPlugins();
-      const pluginHost = fixture('plugin-host');
-      pluginHost.config = {
-        plugin: {
-          js_resource_paths: [],
-          html_resource_paths: [
-            new URL('test/plugin.html?' + Math.random(),
-                window.location.href).toString(),
-          ],
-        },
-      };
-      element = createElement();
-      const importSpy = sandbox.spy(element.$.externalStyle, '_import');
-      pluginLoader.awaitPluginsLoaded().then(() => {
-        Promise.all(importSpy.returnValues).then(() => {
-          flush(done);
-        });
-      });
-    });
-
-    for (const sectionSelector of sectionSelectors) {
-      test(sectionSelector + ' may have display: none', () => {
-        assert.equal(getStyle(sectionSelector, 'display'), 'none');
-      });
-    }
-  });
-
-  suite('label updates', () => {
-    let plugin;
-
-    setup(() => {
-      pluginApi.install(p => plugin = p, '0.1',
-          new URL('test/plugin.html?' + Math.random(),
-              window.location.href).toString());
-      sandbox.stub(pluginLoader, 'arePluginsLoaded').returns(true);
-      pluginLoader.loadPlugins([]);
-      element = createElement();
-    });
-
-    test('labels changed callback', done => {
-      let callCount = 0;
-      const labelChangeSpy = sandbox.spy(arg => {
-        callCount++;
-        if (callCount === 1) {
-          assert.deepEqual(arg, labels);
-          assert.equal(arg.CI.all.length, 2);
-          element.set(['change', 'labels'], {
-            CI: {
-              all: [
-                {value: 1, name: 'user 2', _account_id: 1},
-              ],
-              values: {
-                ' 0': 'Don\'t submit as-is',
-                '+1': 'No score',
-                '+2': 'Looks good to me',
-              },
-            },
-          });
-        } else if (callCount === 2) {
-          assert.equal(arg.CI.all.length, 1);
-          done();
-        }
-      });
-
-      plugin.changeMetadata().onLabelsChanged(labelChangeSpy);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
new file mode 100644
index 0000000..87e100c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
@@ -0,0 +1,185 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import './gr-change-metadata.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
+const testHtmlPlugin = document.createElement('dom-module');
+testHtmlPlugin.innerHTML = `
+    <template>
+      <style>
+        html {
+          --change-metadata-assignee: {
+            display: none;
+          }
+          --change-metadata-label-status: {
+            display: none;
+          }
+          --change-metadata-strategy: {
+            display: none;
+          }
+          --change-metadata-topic: {
+            display: none;
+          }
+        }
+      </style>
+    </template>
+  `;
+testHtmlPlugin.register('my-plugin-style');
+
+const basicFixture = fixtureFromTemplate(
+    html`<gr-change-metadata mutable="true"></gr-change-metadata>`
+);
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-metadata integration tests', () => {
+  let element;
+
+  const sectionSelectors = [
+    'section.strategy',
+    'section.topic',
+  ];
+
+  const labels = {
+    CI: {
+      all: [
+        {value: 1, name: 'user 2', _account_id: 1},
+        {value: 2, name: 'user '},
+      ],
+      values: {
+        ' 0': 'Don\'t submit as-is',
+        '+1': 'No score',
+        '+2': 'Looks good to me',
+      },
+    },
+  };
+
+  const getStyle = function(selector, name) {
+    return window.getComputedStyle(
+        dom(element.root).querySelector(selector))[name];
+  };
+
+  function createElement() {
+    const element = basicFixture.instantiate();
+    element.change = {labels, status: 'NEW'};
+    element.revision = {};
+    return element;
+  }
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+      deleteVote() { return Promise.resolve({ok: true}); },
+    });
+  });
+
+  teardown(() => {
+    resetPlugins();
+  });
+
+  suite('by default', () => {
+    setup(done => {
+      element = createElement();
+      flush(done);
+    });
+
+    for (const sectionSelector of sectionSelectors) {
+      test(sectionSelector + ' does not have display: none', () => {
+        assert.notEqual(getStyle(sectionSelector, 'display'), 'none');
+      });
+    }
+  });
+
+  suite('with plugin style', () => {
+    setup(done => {
+      resetPlugins();
+      pluginApi.install(plugin => {
+        plugin.registerStyleModule('change-metadata', 'my-plugin-style');
+      }, undefined, 'http://test.com/plugins/style.js');
+      element = createElement();
+      pluginLoader.loadPlugins([]);
+      pluginLoader.awaitPluginsLoaded().then(() => {
+        flush(done);
+      });
+    });
+
+    teardown(() => {
+      document.body.querySelectorAll('custom-style')
+          .forEach(style => style.remove());
+    });
+
+    for (const sectionSelector of sectionSelectors) {
+      test('section.strategy may have display: none', () => {
+        assert.equal(getStyle(sectionSelector, 'display'), 'none');
+      });
+    }
+  });
+
+  suite('label updates', () => {
+    let plugin;
+
+    setup(() => {
+      pluginApi.install(p => {
+        plugin = p;
+        plugin.registerStyleModule('change-metadata', 'my-plugin-style');
+      }, undefined, 'http://test.com/plugins/style.js');
+      sinon.stub(pluginLoader, 'arePluginsLoaded').returns(true);
+      pluginLoader.loadPlugins([]);
+      element = createElement();
+    });
+
+    teardown(() => {
+      document.body.querySelectorAll('custom-style')
+          .forEach(style => style.remove());
+    });
+
+    test('labels changed callback', done => {
+      let callCount = 0;
+      const labelChangeSpy = sinon.spy(arg => {
+        callCount++;
+        if (callCount === 1) {
+          assert.deepEqual(arg, labels);
+          assert.equal(arg.CI.all.length, 2);
+          element.set(['change', 'labels'], {
+            CI: {
+              all: [
+                {value: 1, name: 'user 2', _account_id: 1},
+              ],
+              values: {
+                ' 0': 'Don\'t submit as-is',
+                '+1': 'No score',
+                '+2': 'Looks good to me',
+              },
+            },
+          });
+        } else if (callCount === 2) {
+          assert.equal(arg.CI.all.length, 1);
+          done();
+        }
+      });
+
+      plugin.changeMetadata().onLabelsChanged(labelChangeSpy);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index 7d4e878..ca0205c 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../../../styles/gr-change-metadata-shared-styles.js';
 import '../../../styles/gr-change-view-integration-shared-styles.js';
@@ -24,7 +22,6 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
 import '../../plugins/gr-external-style/gr-external-style.js';
 import '../../shared/gr-account-chip/gr-account-chip.js';
-import '../../shared/gr-account-link/gr-account-link.js';
 import '../../shared/gr-date-formatter/gr-date-formatter.js';
 import '../../shared/gr-editable-label/gr-editable-label.js';
 import '../../shared/gr-icons/gr-icons.js';
@@ -37,14 +34,14 @@
 import '../gr-reviewer-list/gr-reviewer-list.js';
 import '../../shared/gr-account-list/gr-account-list.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-change-metadata_html.js';
-import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {ChangeStatus} from '../../../constants/constants.js';
+import {changeIsOpen} from '../../../utils/change-util.js';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -78,13 +75,10 @@
 };
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrChangeMetadata extends mixinBehaviors( [
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrChangeMetadata extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-change-metadata'; }
@@ -178,7 +172,7 @@
   }
 
   _labelsChanged(labels) {
-    this.labels = Object.assign({}, labels) || null;
+    this.labels = ({...labels}) || null;
   }
 
   _changeChanged(change) {
@@ -204,7 +198,7 @@
   }
 
   _computeHideStrategy(change) {
-    return !this.changeIsOpen(change);
+    return !changeIsOpen(change);
   }
 
   /**
@@ -323,7 +317,7 @@
   }
 
   _computeShowRequirements(change) {
-    if (change.status !== this.ChangeStatus.NEW) {
+    if (change.status !== ChangeStatus.NEW) {
       // TODO(maximeg) change this to display the stored
       // requirements, once it is implemented server-side.
       return false;
@@ -399,7 +393,7 @@
   _computeBranchUrl(project, branch) {
     if (!this.change || !this.change.status) return '';
     return GerritNav.getUrlForBranch(branch, project,
-        this.change.status == this.ChangeStatus.NEW ? 'open' :
+        this.change.status == ChangeStatus.NEW ? 'open' :
           this.change.status.toLowerCase());
   }
 
@@ -462,7 +456,7 @@
    *
    * @param {!Object} change
    * @param {string} role One of the values from _CHANGE_ROLE
-   * @return {Object|null} either an accound or null.
+   * @return {Object|null} either an account or null.
    */
   _getNonOwnerRole(change, role) {
     if (!change || !change.current_revision ||
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
deleted file mode 100644
index 1b18412..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.js
+++ /dev/null
@@ -1,365 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="gr-change-metadata-shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: table;
-      --account-max-length: 20ch;
-    }
-    gr-change-requirements {
-      --requirements-horizontal-padding: var(--metadata-horizontal-padding);
-    }
-    gr-editable-label {
-      max-width: 9em;
-    }
-    .webLink {
-      display: block;
-    }
-    /* CSS Mixins should be applied last. */
-    section.assignee {
-      @apply --change-metadata-assignee;
-    }
-    section.strategy {
-      @apply --change-metadata-strategy;
-    }
-    section.topic {
-      @apply --change-metadata-topic;
-    }
-    gr-account-chip[disabled],
-    gr-linked-chip[disabled] {
-      opacity: 0;
-      pointer-events: none;
-    }
-    .hashtagChip {
-      margin-bottom: var(--spacing-m);
-    }
-    #externalStyle {
-      display: block;
-    }
-    .parentList.merge {
-      list-style-type: decimal;
-      padding-left: var(--spacing-l);
-    }
-    .parentList gr-commit-info {
-      display: inline-block;
-    }
-    .hideDisplay,
-    #parentNotCurrentMessage {
-      display: none;
-    }
-    .icon {
-      margin: -3px 0;
-    }
-    .icon.help,
-    .icon.notTrusted {
-      color: #ffa62f;
-    }
-    .icon.invalid {
-      color: var(--vote-text-color-disliked);
-    }
-    .icon.trusted {
-      color: var(--vote-text-color-recommended);
-    }
-    .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
-      --arrow-color: #ffa62f;
-      display: inline-block;
-    }
-    .separatedSection {
-      margin-top: var(--spacing-l);
-      padding: var(--spacing-m) 0;
-    }
-    .hashtag gr-linked-chip,
-    .topic gr-linked-chip {
-      --linked-chip-text-color: var(--link-color);
-    }
-    gr-reviewer-list {
-      max-width: 200px;
-    }
-  </style>
-  <gr-external-style id="externalStyle" name="change-metadata">
-    <section>
-      <span class="title">Updated</span>
-      <span class="value">
-        <gr-date-formatter
-          has-tooltip=""
-          date-str="[[change.updated]]"
-        ></gr-date-formatter>
-      </span>
-    </section>
-    <section>
-      <span class="title">Owner</span>
-      <span class="value">
-        <gr-account-link account="[[change.owner]]"></gr-account-link>
-        <template is="dom-if" if="[[_pushCertificateValidation]]">
-          <gr-tooltip-content
-            has-tooltip=""
-            title$="[[_pushCertificateValidation.message]]"
-          >
-            <iron-icon
-              class$="icon [[_pushCertificateValidation.class]]"
-              icon="[[_pushCertificateValidation.icon]]"
-            >
-            </iron-icon>
-          </gr-tooltip-content>
-        </template>
-      </span>
-    </section>
-    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
-      <span class="title">Uploader</span>
-      <span class="value">
-        <gr-account-link
-          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
-        ></gr-account-link>
-      </span>
-    </section>
-    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
-      <span class="title">Author</span>
-      <span class="value">
-        <gr-account-link
-          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
-        ></gr-account-link>
-      </span>
-    </section>
-    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
-      <span class="title">Committer</span>
-      <span class="value">
-        <gr-account-link
-          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
-        ></gr-account-link>
-      </span>
-    </section>
-    <template is="dom-if" if="[[_isAssigneeEnabled(serverConfig)]]">
-      <section class="assignee">
-        <span class="title">Assignee</span>
-        <span class="value">
-          <gr-account-list
-            id="assigneeValue"
-            placeholder="Set assignee..."
-            max-count="1"
-            skip-suggest-on-empty=""
-            accounts="{{_assignee}}"
-            readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
-            suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
-          >
-          </gr-account-list>
-        </span>
-      </section>
-    </template>
-    <section>
-      <span class="title">Reviewers</span>
-      <span class="value">
-        <gr-reviewer-list
-          change="{{change}}"
-          mutable="[[_mutable]]"
-          reviewers-only=""
-          server-config="[[serverConfig]]"
-        ></gr-reviewer-list>
-      </span>
-    </section>
-    <section>
-      <span class="title">CC</span>
-      <span class="value">
-        <gr-reviewer-list
-          change="{{change}}"
-          mutable="[[_mutable]]"
-          ccs-only=""
-          server-config="[[serverConfig]]"
-        ></gr-reviewer-list>
-      </span>
-    </section>
-    <template
-      is="dom-if"
-      if="[[_computeShowRepoBranchTogether(change.project, change.branch)]]"
-    >
-      <section>
-        <span class="title">Repo | Branch</span>
-        <span class="value">
-          <a href$="[[_computeProjectUrl(change.project)]]"
-            >[[change.project]]</a
-          >
-          |
-          <a href$="[[_computeBranchUrl(change.project, change.branch)]]"
-            >[[change.branch]]</a
-          >
-        </span>
-      </section>
-    </template>
-    <template
-      is="dom-if"
-      if="[[!_computeShowRepoBranchTogether(change.project, change.branch)]]"
-    >
-      <section>
-        <span class="title">Repo</span>
-        <span class="value">
-          <a href$="[[_computeProjectUrl(change.project)]]">
-            <gr-limited-text
-              limit="40"
-              text="[[change.project]]"
-            ></gr-limited-text>
-          </a>
-        </span>
-      </section>
-      <section>
-        <span class="title">Branch</span>
-        <span class="value">
-          <a href$="[[_computeBranchUrl(change.project, change.branch)]]">
-            <gr-limited-text
-              limit="40"
-              text="[[change.branch]]"
-            ></gr-limited-text>
-          </a>
-        </span>
-      </section>
-    </template>
-    <section>
-      <span class="title">[[_computeParentsLabel(_currentParents)]]</span>
-      <span class="value">
-        <ol
-          class$="[[_computeParentListClass(_currentParents, parentIsCurrent)]]"
-        >
-          <template is="dom-repeat" items="[[_currentParents]]" as="parent">
-            <li>
-              <gr-commit-info
-                change="[[change]]"
-                commit-info="[[parent]]"
-                server-config="[[serverConfig]]"
-              ></gr-commit-info>
-              <gr-tooltip-content
-                id="parentNotCurrentMessage"
-                has-tooltip=""
-                show-icon=""
-                title$="[[_notCurrentMessage]]"
-              ></gr-tooltip-content>
-            </li>
-          </template>
-        </ol>
-      </span>
-    </section>
-    <section class="topic">
-      <span class="title">Topic</span>
-      <span class="value">
-        <template is="dom-if" if="[[_showTopicChip(change.*, _settingTopic)]]">
-          <gr-linked-chip
-            text="[[change.topic]]"
-            limit="40"
-            href="[[_computeTopicUrl(change.topic)]]"
-            removable="[[!_topicReadOnly]]"
-            on-remove="_handleTopicRemoved"
-          ></gr-linked-chip>
-        </template>
-        <template is="dom-if" if="[[_showAddTopic(change.*, _settingTopic)]]">
-          <gr-editable-label
-            class="topicEditableLabel"
-            label-text="Add a topic"
-            value="[[change.topic]]"
-            max-length="1024"
-            placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
-            read-only="[[_topicReadOnly]]"
-            on-changed="_handleTopicChanged"
-          ></gr-editable-label>
-        </template>
-      </span>
-    </section>
-    <template is="dom-if" if="[[_showCherryPickOf(change.*)]]">
-      <section>
-        <span class="title">Cherry pick of</span>
-        <span class="value">
-          <a
-            href$="[[_computeCherryPickOfUrl(change.cherry_pick_of_change, change.cherry_pick_of_patch_set, change.project)]]"
-          >
-            <gr-limited-text
-              text="[[change.cherry_pick_of_change]],[[change.cherry_pick_of_patch_set]]"
-              limit="40"
-            >
-            </gr-limited-text>
-          </a>
-        </span>
-      </section>
-    </template>
-    <section
-      class="strategy"
-      hidden$="[[_computeHideStrategy(change)]]"
-      hidden=""
-    >
-      <span class="title">Strategy</span>
-      <span class="value">[[_computeStrategy(change)]]</span>
-    </section>
-    <section class="hashtag">
-      <span class="title">Hashtags</span>
-      <span class="value">
-        <template is="dom-repeat" items="[[change.hashtags]]">
-          <gr-linked-chip
-            class="hashtagChip"
-            text="[[item]]"
-            href="[[_computeHashtagUrl(item)]]"
-            removable="[[!_hashtagReadOnly]]"
-            on-remove="_handleHashtagRemoved"
-          >
-          </gr-linked-chip>
-        </template>
-        <template is="dom-if" if="[[!_hashtagReadOnly]]">
-          <gr-editable-label
-            uppercase=""
-            label-text="Add a hashtag"
-            value="{{_newHashtag}}"
-            placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
-            read-only="[[_hashtagReadOnly]]"
-            on-changed="_handleHashtagChanged"
-          ></gr-editable-label>
-        </template>
-      </span>
-    </section>
-    <div class="separatedSection">
-      <gr-change-requirements
-        change="{{change}}"
-        account="[[account]]"
-        mutable="[[_mutable]]"
-      ></gr-change-requirements>
-    </div>
-    <section
-      id="webLinks"
-      hidden$="[[!_computeWebLinks(commitInfo, serverConfig)]]"
-    >
-      <span class="title">Links</span>
-      <span class="value">
-        <template
-          is="dom-repeat"
-          items="[[_computeWebLinks(commitInfo, serverConfig)]]"
-          as="link"
-        >
-          <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
-            [[link.name]]
-          </a>
-        </template>
-      </span>
-    </section>
-    <gr-endpoint-decorator name="change-metadata-item">
-      <gr-endpoint-param name="labels" value="[[labels]]"></gr-endpoint-param>
-      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      <gr-endpoint-param
-        name="revision"
-        value="[[revision]]"
-      ></gr-endpoint-param>
-    </gr-endpoint-decorator>
-  </gr-external-style>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
new file mode 100644
index 0000000..8a262d9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -0,0 +1,373 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="gr-change-metadata-shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="shared-styles">
+    :host {
+      display: table;
+      --account-max-length: 20ch;
+    }
+    gr-change-requirements {
+      --requirements-horizontal-padding: var(--metadata-horizontal-padding);
+    }
+    gr-editable-label {
+      max-width: 9em;
+    }
+    .webLink {
+      display: block;
+    }
+    /* CSS Mixins should be applied last. */
+    section.assignee {
+      @apply --change-metadata-assignee;
+    }
+    section.strategy {
+      @apply --change-metadata-strategy;
+    }
+    section.topic {
+      @apply --change-metadata-topic;
+    }
+    gr-account-chip[disabled],
+    gr-linked-chip[disabled] {
+      opacity: 0;
+      pointer-events: none;
+    }
+    .hashtagChip {
+      margin-bottom: var(--spacing-m);
+    }
+    #externalStyle {
+      display: block;
+    }
+    .parentList.merge {
+      list-style-type: decimal;
+      padding-left: var(--spacing-l);
+    }
+    .parentList gr-commit-info {
+      display: inline-block;
+    }
+    .hideDisplay,
+    #parentNotCurrentMessage {
+      display: none;
+    }
+    .icon {
+      margin: -3px 0;
+    }
+    .icon.help,
+    .icon.notTrusted {
+      color: #ffa62f;
+    }
+    .icon.invalid {
+      color: var(--negative-red-text-color);
+    }
+    .icon.trusted {
+      color: var(--positive-green-text-color);
+    }
+    .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
+      --arrow-color: #ffa62f;
+      display: inline-block;
+    }
+    .separatedSection {
+      margin-top: var(--spacing-l);
+      padding: var(--spacing-m) 0;
+    }
+    .hashtag gr-linked-chip,
+    .topic gr-linked-chip {
+      --linked-chip-text-color: var(--link-color);
+    }
+    gr-reviewer-list {
+      max-width: 200px;
+    }
+  </style>
+  <gr-external-style id="externalStyle" name="change-metadata">
+    <section>
+      <span class="title">Updated</span>
+      <span class="value">
+        <gr-date-formatter
+          has-tooltip=""
+          date-str="[[change.updated]]"
+        ></gr-date-formatter>
+      </span>
+    </section>
+    <section>
+      <span class="title">Owner</span>
+      <span class="value">
+        <gr-account-chip
+          account="[[change.owner]]"
+          change="[[change]]"
+          highlight-attention
+        ></gr-account-chip>
+        <template is="dom-if" if="[[_pushCertificateValidation]]">
+          <gr-tooltip-content
+            has-tooltip=""
+            title$="[[_pushCertificateValidation.message]]"
+          >
+            <iron-icon
+              class$="icon [[_pushCertificateValidation.class]]"
+              icon="[[_pushCertificateValidation.icon]]"
+            >
+            </iron-icon>
+          </gr-tooltip-content>
+        </template>
+      </span>
+    </section>
+    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
+      <span class="title">Uploader</span>
+      <span class="value">
+        <gr-account-chip
+          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
+          change="[[change]]"
+          highlight-attention
+        ></gr-account-chip>
+      </span>
+    </section>
+    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
+      <span class="title">Author</span>
+      <span class="value">
+        <gr-account-chip
+          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
+          change="[[change]]"
+        ></gr-account-chip>
+      </span>
+    </section>
+    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
+      <span class="title">Committer</span>
+      <span class="value">
+        <gr-account-chip
+          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
+          change="[[change]]"
+        ></gr-account-chip>
+      </span>
+    </section>
+    <template is="dom-if" if="[[_isAssigneeEnabled(serverConfig)]]">
+      <section class="assignee">
+        <span class="title">Assignee</span>
+        <span class="value">
+          <gr-account-list
+            id="assigneeValue"
+            placeholder="Set assignee..."
+            max-count="1"
+            skip-suggest-on-empty=""
+            accounts="{{_assignee}}"
+            readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
+            suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
+          >
+          </gr-account-list>
+        </span>
+      </section>
+    </template>
+    <section>
+      <span class="title">Reviewers</span>
+      <span class="value">
+        <gr-reviewer-list
+          change="{{change}}"
+          mutable="[[_mutable]]"
+          reviewers-only=""
+          server-config="[[serverConfig]]"
+        ></gr-reviewer-list>
+      </span>
+    </section>
+    <section>
+      <span class="title">CC</span>
+      <span class="value">
+        <gr-reviewer-list
+          change="{{change}}"
+          mutable="[[_mutable]]"
+          ccs-only=""
+          server-config="[[serverConfig]]"
+        ></gr-reviewer-list>
+      </span>
+    </section>
+    <template
+      is="dom-if"
+      if="[[_computeShowRepoBranchTogether(change.project, change.branch)]]"
+    >
+      <section>
+        <span class="title">Repo | Branch</span>
+        <span class="value">
+          <a href$="[[_computeProjectUrl(change.project)]]"
+            >[[change.project]]</a
+          >
+          |
+          <a href$="[[_computeBranchUrl(change.project, change.branch)]]"
+            >[[change.branch]]</a
+          >
+        </span>
+      </section>
+    </template>
+    <template
+      is="dom-if"
+      if="[[!_computeShowRepoBranchTogether(change.project, change.branch)]]"
+    >
+      <section>
+        <span class="title">Repo</span>
+        <span class="value">
+          <a href$="[[_computeProjectUrl(change.project)]]">
+            <gr-limited-text
+              limit="40"
+              text="[[change.project]]"
+            ></gr-limited-text>
+          </a>
+        </span>
+      </section>
+      <section>
+        <span class="title">Branch</span>
+        <span class="value">
+          <a href$="[[_computeBranchUrl(change.project, change.branch)]]">
+            <gr-limited-text
+              limit="40"
+              text="[[change.branch]]"
+            ></gr-limited-text>
+          </a>
+        </span>
+      </section>
+    </template>
+    <section>
+      <span class="title">[[_computeParentsLabel(_currentParents)]]</span>
+      <span class="value">
+        <ol
+          class$="[[_computeParentListClass(_currentParents, parentIsCurrent)]]"
+        >
+          <template is="dom-repeat" items="[[_currentParents]]" as="parent">
+            <li>
+              <gr-commit-info
+                change="[[change]]"
+                commit-info="[[parent]]"
+                server-config="[[serverConfig]]"
+              ></gr-commit-info>
+              <gr-tooltip-content
+                id="parentNotCurrentMessage"
+                has-tooltip=""
+                show-icon=""
+                title$="[[_notCurrentMessage]]"
+              ></gr-tooltip-content>
+            </li>
+          </template>
+        </ol>
+      </span>
+    </section>
+    <section class="topic">
+      <span class="title">Topic</span>
+      <span class="value">
+        <template is="dom-if" if="[[_showTopicChip(change.*, _settingTopic)]]">
+          <gr-linked-chip
+            text="[[change.topic]]"
+            limit="40"
+            href="[[_computeTopicUrl(change.topic)]]"
+            removable="[[!_topicReadOnly]]"
+            on-remove="_handleTopicRemoved"
+          ></gr-linked-chip>
+        </template>
+        <template is="dom-if" if="[[_showAddTopic(change.*, _settingTopic)]]">
+          <gr-editable-label
+            class="topicEditableLabel"
+            label-text="Add a topic"
+            value="[[change.topic]]"
+            max-length="1024"
+            placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
+            read-only="[[_topicReadOnly]]"
+            on-changed="_handleTopicChanged"
+          ></gr-editable-label>
+        </template>
+      </span>
+    </section>
+    <template is="dom-if" if="[[_showCherryPickOf(change.*)]]">
+      <section>
+        <span class="title">Cherry pick of</span>
+        <span class="value">
+          <a
+            href$="[[_computeCherryPickOfUrl(change.cherry_pick_of_change, change.cherry_pick_of_patch_set, change.project)]]"
+          >
+            <gr-limited-text
+              text="[[change.cherry_pick_of_change]],[[change.cherry_pick_of_patch_set]]"
+              limit="40"
+            >
+            </gr-limited-text>
+          </a>
+        </span>
+      </section>
+    </template>
+    <section
+      class="strategy"
+      hidden$="[[_computeHideStrategy(change)]]"
+      hidden=""
+    >
+      <span class="title">Strategy</span>
+      <span class="value">[[_computeStrategy(change)]]</span>
+    </section>
+    <section class="hashtag">
+      <span class="title">Hashtags</span>
+      <span class="value">
+        <template is="dom-repeat" items="[[change.hashtags]]">
+          <gr-linked-chip
+            class="hashtagChip"
+            text="[[item]]"
+            href="[[_computeHashtagUrl(item)]]"
+            removable="[[!_hashtagReadOnly]]"
+            on-remove="_handleHashtagRemoved"
+          >
+          </gr-linked-chip>
+        </template>
+        <template is="dom-if" if="[[!_hashtagReadOnly]]">
+          <gr-editable-label
+            uppercase=""
+            label-text="Add a hashtag"
+            value="{{_newHashtag}}"
+            placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
+            read-only="[[_hashtagReadOnly]]"
+            on-changed="_handleHashtagChanged"
+          ></gr-editable-label>
+        </template>
+      </span>
+    </section>
+    <div class="separatedSection">
+      <gr-change-requirements
+        change="{{change}}"
+        account="[[account]]"
+        mutable="[[_mutable]]"
+      ></gr-change-requirements>
+    </div>
+    <section
+      id="webLinks"
+      hidden$="[[!_computeWebLinks(commitInfo, serverConfig)]]"
+    >
+      <span class="title">Links</span>
+      <span class="value">
+        <template
+          is="dom-repeat"
+          items="[[_computeWebLinks(commitInfo, serverConfig)]]"
+          as="link"
+        >
+          <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
+            [[link.name]]
+          </a>
+        </template>
+      </span>
+    </section>
+    <gr-endpoint-decorator name="change-metadata-item">
+      <gr-endpoint-param name="labels" value="[[labels]]"></gr-endpoint-param>
+      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+      <gr-endpoint-param
+        name="revision"
+        value="[[revision]]"
+      ></gr-endpoint-param>
+    </gr-endpoint-decorator>
+  </gr-external-style>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
deleted file mode 100644
index 8e780d7..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ /dev/null
@@ -1,800 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-change-metadata</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-metadata></gr-change-metadata>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../core/gr-router/gr-router.js';
-import './gr-change-metadata.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-change-metadata tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-endpoint-decorator', {
-      _import: sandbox.stub().returns(Promise.resolve()),
-    });
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
-
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('computed fields', () => {
-    assert.isFalse(element._computeHideStrategy({status: 'NEW'}));
-    assert.isTrue(element._computeHideStrategy({status: 'MERGED'}));
-    assert.isTrue(element._computeHideStrategy({status: 'ABANDONED'}));
-    assert.equal(element._computeStrategy({submit_type: 'CHERRY_PICK'}),
-        'Cherry Pick');
-    assert.equal(element._computeStrategy({submit_type: 'REBASE_ALWAYS'}),
-        'Rebase Always');
-  });
-
-  test('computed fields requirements', () => {
-    assert.isFalse(element._computeShowRequirements({status: 'MERGED'}));
-    assert.isFalse(element._computeShowRequirements({status: 'ABANDONED'}));
-
-    // No labels and no requirements: submit status is useless
-    assert.isFalse(element._computeShowRequirements({
-      status: 'NEW',
-      labels: {},
-    }));
-
-    // Work in Progress: submit status should be present
-    assert.isTrue(element._computeShowRequirements({
-      status: 'NEW',
-      labels: {},
-      work_in_progress: true,
-    }));
-
-    // We have at least one reason to display Submit Status
-    assert.isTrue(element._computeShowRequirements({
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: false,
-        },
-      },
-      requirements: [],
-    }));
-    assert.isTrue(element._computeShowRequirements({
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    }));
-  });
-
-  test('show strategy for open change', () => {
-    element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
-    flushAsynchronousOperations();
-    const strategy = element.shadowRoot
-        .querySelector('.strategy');
-    assert.ok(strategy);
-    assert.isFalse(strategy.hasAttribute('hidden'));
-    assert.equal(strategy.children[1].innerHTML, 'Cherry Pick');
-  });
-
-  test('hide strategy for closed change', () => {
-    element.change = {status: 'MERGED', labels: {}};
-    flushAsynchronousOperations();
-    assert.isTrue(element.shadowRoot
-        .querySelector('.strategy').hasAttribute('hidden'));
-  });
-
-  test('weblinks use GerritNav interface', () => {
-    const weblinksStub = sandbox.stub(GerritNav, '_generateWeblinks')
-        .returns([{name: 'stubb', url: '#s'}]);
-    element.commitInfo = {};
-    element.serverConfig = {};
-    flushAsynchronousOperations();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(weblinksStub.called);
-    assert.isFalse(webLinks.hasAttribute('hidden'));
-    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-  });
-
-  test('weblinks hidden when no weblinks', () => {
-    element.commitInfo = {};
-    element.serverConfig = {};
-    flushAsynchronousOperations();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(webLinks.hasAttribute('hidden'));
-  });
-
-  test('weblinks hidden when only gitiles weblink', () => {
-    element.commitInfo = {web_links: [{name: 'gitiles', url: '#'}]};
-    element.serverConfig = {};
-    flushAsynchronousOperations();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(webLinks.hasAttribute('hidden'));
-    assert.equal(element._computeWebLinks(element.commitInfo), null);
-  });
-
-  test('weblinks hidden when sole weblink is set as primary', () => {
-    const browser = 'browser';
-    element.commitInfo = {web_links: [{name: browser, url: '#'}]};
-    element.serverConfig = {
-      gerrit: {
-        primary_weblink_name: browser,
-      },
-    };
-    flushAsynchronousOperations();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(webLinks.hasAttribute('hidden'));
-  });
-
-  test('weblinks are visible when other weblinks', () => {
-    const router = document.createElement('gr-router');
-    sandbox.stub(GerritNav, '_generateWeblinks',
-        router._generateWeblinks.bind(router));
-
-    element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
-    flushAsynchronousOperations();
-    const webLinks = element.$.webLinks;
-    assert.isFalse(webLinks.hasAttribute('hidden'));
-    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-    // With two non-gitiles weblinks, there are two returned.
-    element.commitInfo = {
-      web_links: [{name: 'test', url: '#'}, {name: 'test2', url: '#'}]};
-    assert.equal(element._computeWebLinks(element.commitInfo).length, 2);
-  });
-
-  test('weblinks are visible when gitiles and other weblinks', () => {
-    const router = document.createElement('gr-router');
-    sandbox.stub(GerritNav, '_generateWeblinks',
-        router._generateWeblinks.bind(router));
-
-    element.commitInfo = {
-      web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
-    flushAsynchronousOperations();
-    const webLinks = element.$.webLinks;
-    assert.isFalse(webLinks.hasAttribute('hidden'));
-    // Only the non-gitiles weblink is returned.
-    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
-  });
-
-  suite('_getNonOwnerRole', () => {
-    let change;
-
-    setup(() => {
-      change = {
-        owner: {
-          email: 'abc@def',
-          _account_id: 1019328,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-            uploader: {
-              email: 'ghi@def',
-              _account_id: 1011123,
-            },
-            commit: {
-              author: {email: 'jkl@def'},
-              committer: {email: 'ghi@def'},
-            },
-          },
-        },
-        current_revision: 'rev1',
-      };
-    });
-
-    suite('role=uploader', () => {
-      test('_getNonOwnerRole for uploader', () => {
-        assert.deepEqual(
-            element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
-            {email: 'ghi@def', _account_id: 1011123});
-      });
-
-      test('_getNonOwnerRole that it does not return uploader', () => {
-        // Set the uploader email to be the same as the owner.
-        change.revisions.rev1.uploader._account_id = 1019328;
-        assert.isNull(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.UPLOADER));
-      });
-
-      test('_getNonOwnerRole null for uploader with no current rev', () => {
-        delete change.current_revision;
-        assert.isNull(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.UPLOADER));
-      });
-
-      test('_computeShowRoleClass show uploader', () => {
-        assert.equal(element._computeShowRoleClass(
-            change, element._CHANGE_ROLE.UPLOADER), '');
-      });
-
-      test('_computeShowRoleClass hide uploader', () => {
-        // Set the uploader email to be the same as the owner.
-        change.revisions.rev1.uploader._account_id = 1019328;
-        assert.equal(element._computeShowRoleClass(change,
-            element._CHANGE_ROLE.UPLOADER), 'hideDisplay');
-      });
-    });
-
-    suite('role=committer', () => {
-      test('_getNonOwnerRole for committer', () => {
-        assert.deepEqual(
-            element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
-            {email: 'ghi@def'});
-      });
-
-      test('_getNonOwnerRole that it does not return committer', () => {
-        // Set the committer email to be the same as the owner.
-        change.revisions.rev1.commit.committer.email = 'abc@def';
-        assert.isNull(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.COMMITTER));
-      });
-
-      test('_getNonOwnerRole null for committer with no current rev', () => {
-        delete change.current_revision;
-        assert.isNull(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.COMMITTER));
-      });
-
-      test('_getNonOwnerRole null for committer with no commit', () => {
-        delete change.revisions.rev1.commit;
-        assert.isNull(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.COMMITTER));
-      });
-
-      test('_getNonOwnerRole null for committer with no committer', () => {
-        delete change.revisions.rev1.commit.committer;
-        assert.isNull(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.COMMITTER));
-      });
-    });
-
-    suite('role=author', () => {
-      test('_getNonOwnerRole for author', () => {
-        assert.deepEqual(
-            element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
-            {email: 'jkl@def'});
-      });
-
-      test('_getNonOwnerRole that it does not return author', () => {
-        // Set the author email to be the same as the owner.
-        change.revisions.rev1.commit.author.email = 'abc@def';
-        assert.isNull(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.AUTHOR));
-      });
-
-      test('_getNonOwnerRole null for author with no current rev', () => {
-        delete change.current_revision;
-        assert.isNull(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.AUTHOR));
-      });
-
-      test('_getNonOwnerRole null for author with no commit', () => {
-        delete change.revisions.rev1.commit;
-        assert.isNull(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.AUTHOR));
-      });
-
-      test('_getNonOwnerRole null for author with no author', () => {
-        delete change.revisions.rev1.commit.author;
-        assert.isNull(element._getNonOwnerRole(change,
-            element._CHANGE_ROLE.AUTHOR));
-      });
-    });
-  });
-
-  test('Push Certificate Validation test BAD', () => {
-    const serverConfig = {
-      receive: {
-        enable_signed_push: true,
-      },
-    };
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      owner: {
-        _account_id: 1019328,
-      },
-      revisions: {
-        rev1: {
-          _number: 1,
-          push_certificate: {
-            key: {
-              status: 'BAD',
-              problems: [
-                'No public keys found for key ID E5E20E52',
-              ],
-            },
-          },
-        },
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: true,
-    };
-    const result =
-        element._computePushCertificateValidation(serverConfig, change);
-    assert.equal(result.message,
-        'Push certificate is invalid:\n' +
-        'No public keys found for key ID E5E20E52');
-    assert.equal(result.icon, 'gr-icons:close');
-    assert.equal(result.class, 'invalid');
-  });
-
-  test('Push Certificate Validation test TRUSTED', () => {
-    const serverConfig = {
-      receive: {
-        enable_signed_push: true,
-      },
-    };
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      owner: {
-        _account_id: 1019328,
-      },
-      revisions: {
-        rev1: {
-          _number: 1,
-          push_certificate: {
-            key: {
-              status: 'TRUSTED',
-            },
-          },
-        },
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: true,
-    };
-    const result =
-        element._computePushCertificateValidation(serverConfig, change);
-    assert.equal(result.message,
-        'Push certificate is valid and key is trusted');
-    assert.equal(result.icon, 'gr-icons:check');
-    assert.equal(result.class, 'trusted');
-  });
-
-  test('Push Certificate Validation is missing test', () => {
-    const serverConfig = {
-      receive: {
-        enable_signed_push: true,
-      },
-    };
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      owner: {
-        _account_id: 1019328,
-      },
-      revisions: {
-        rev1: {
-          _number: 1,
-        },
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: true,
-    };
-    const result =
-        element._computePushCertificateValidation(serverConfig, change);
-    assert.equal(result.message,
-        'This patch set was created without a push certificate');
-    assert.equal(result.icon, 'gr-icons:help');
-    assert.equal(result.class, 'help');
-  });
-
-  test('_computeParents', () => {
-    const parents = [{commit: '123', subject: 'abc'}];
-    const revision = {commit: {parents}};
-    assert.deepEqual(element._computeParents({}, {}), []);
-    assert.equal(element._computeParents(null, revision), parents);
-    const change = current_revision => {
-      return {current_revision, revisions: {456: revision}};
-    };
-    assert.deepEqual(element._computeParents(change(null), null), []);
-    const change_bad_revision = change('789');
-    assert.deepEqual(element._computeParents(change_bad_revision, {}), []);
-    const change_no_commit = {current_revision: '456', revisions: {456: {}}};
-    assert.deepEqual(element._computeParents(change_no_commit, null), []);
-    const change_good = change('456');
-    assert.equal(element._computeParents(change_good, null), parents);
-  });
-
-  test('_currentParents', () => {
-    const revision = parent => {
-      return {commit: {parents: [{commit: parent, subject: 'abc'}]}};
-    };
-    element.change = {
-      current_revision: '456',
-      revisions: {456: revision('111')},
-    };
-    element.revision = revision('222');
-    assert.equal(element._currentParents[0].commit, '222');
-    element.revision = revision('333');
-    assert.equal(element._currentParents[0].commit, '333');
-    element.revision = null;
-    assert.equal(element._currentParents[0].commit, '111');
-    element.change = {current_revision: null};
-    assert.deepEqual(element._currentParents, []);
-  });
-
-  test('_computeParentsLabel', () => {
-    const parent = {commit: 'abc123', subject: 'My parent commit'};
-    assert.equal(element._computeParentsLabel([parent]), 'Parent');
-    assert.equal(element._computeParentsLabel([parent, parent]),
-        'Parents');
-  });
-
-  test('_computeParentListClass', () => {
-    const parent = {commit: 'abc123', subject: 'My parent commit'};
-    assert.equal(element._computeParentListClass([parent], true),
-        'parentList nonMerge current');
-    assert.equal(element._computeParentListClass([parent], false),
-        'parentList nonMerge notCurrent');
-    assert.equal(element._computeParentListClass([parent, parent], false),
-        'parentList merge notCurrent');
-    assert.equal(element._computeParentListClass([parent, parent], true),
-        'parentList merge current');
-  });
-
-  test('_showAddTopic', () => {
-    assert.isTrue(element._showAddTopic(null, false));
-    assert.isTrue(element._showAddTopic({base: {topic: null}}, false));
-    assert.isFalse(element._showAddTopic({base: {topic: null}}, true));
-    assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, true));
-    assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, false));
-  });
-
-  test('_showTopicChip', () => {
-    assert.isFalse(element._showTopicChip(null, false));
-    assert.isFalse(element._showTopicChip({base: {topic: null}}, false));
-    assert.isFalse(element._showTopicChip({base: {topic: null}}, true));
-    assert.isFalse(element._showTopicChip({base: {topic: 'foo'}}, true));
-    assert.isTrue(element._showTopicChip({base: {topic: 'foo'}}, false));
-  });
-
-  test('_showCherryPickOf', () => {
-    assert.isFalse(element._showCherryPickOf(null));
-    assert.isFalse(element._showCherryPickOf({
-      base: {
-        cherry_pick_of_change: null,
-        cherry_pick_of_patch_set: null,
-      },
-    }));
-    assert.isTrue(element._showCherryPickOf({
-      base: {
-        cherry_pick_of_change: 123,
-        cherry_pick_of_patch_set: 1,
-      },
-    }));
-  });
-
-  suite('Topic removal', () => {
-    let change;
-    setup(() => {
-      change = {
-        _number: 'the number',
-        actions: {
-          topic: {enabled: false},
-        },
-        change_id: 'the id',
-        topic: 'the topic',
-        status: 'NEW',
-        submit_type: 'CHERRY_PICK',
-        labels: {
-          test: {
-            all: [{_account_id: 1, name: 'bojack', value: 1}],
-            default_value: 0,
-            values: [],
-          },
-        },
-        removable_reviewers: [],
-      };
-    });
-
-    test('_computeTopicReadOnly', () => {
-      let mutable = false;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
-      mutable = true;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
-      change.actions.topic.enabled = true;
-      assert.isFalse(element._computeTopicReadOnly(mutable, change));
-      mutable = false;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
-    });
-
-    test('topic read only hides delete button', () => {
-      element.account = {};
-      element.change = change;
-      flushAsynchronousOperations();
-      const button = element.shadowRoot
-          .querySelector('gr-linked-chip').shadowRoot
-          .querySelector('gr-button');
-      assert.isTrue(button.hasAttribute('hidden'));
-    });
-
-    test('topic not read only does not hide delete button', () => {
-      element.account = {test: true};
-      change.actions.topic.enabled = true;
-      element.change = change;
-      flushAsynchronousOperations();
-      const button = element.shadowRoot
-          .querySelector('gr-linked-chip').shadowRoot
-          .querySelector('gr-button');
-      assert.isFalse(button.hasAttribute('hidden'));
-    });
-  });
-
-  suite('Hashtag removal', () => {
-    let change;
-    setup(() => {
-      change = {
-        _number: 'the number',
-        actions: {
-          hashtags: {enabled: false},
-        },
-        change_id: 'the id',
-        hashtags: ['test-hashtag'],
-        status: 'NEW',
-        submit_type: 'CHERRY_PICK',
-        labels: {
-          test: {
-            all: [{_account_id: 1, name: 'bojack', value: 1}],
-            default_value: 0,
-            values: [],
-          },
-        },
-        removable_reviewers: [],
-      };
-    });
-
-    test('_computeHashtagReadOnly', () => {
-      flushAsynchronousOperations();
-      let mutable = false;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-      mutable = true;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-      change.actions.hashtags.enabled = true;
-      assert.isFalse(element._computeHashtagReadOnly(mutable, change));
-      mutable = false;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-    });
-
-    test('hashtag read only hides delete button', () => {
-      flushAsynchronousOperations();
-      element.account = {};
-      element.change = change;
-      flushAsynchronousOperations();
-      const button = element.shadowRoot
-          .querySelector('gr-linked-chip').shadowRoot
-          .querySelector('gr-button');
-      assert.isTrue(button.hasAttribute('hidden'));
-    });
-
-    test('hashtag not read only does not hide delete button', () => {
-      flushAsynchronousOperations();
-      element.account = {test: true};
-      change.actions.hashtags.enabled = true;
-      element.change = change;
-      flushAsynchronousOperations();
-      const button = element.shadowRoot
-          .querySelector('gr-linked-chip').shadowRoot
-          .querySelector('gr-button');
-      assert.isFalse(button.hasAttribute('hidden'));
-    });
-  });
-
-  suite('remove reviewer votes', () => {
-    setup(() => {
-      sandbox.stub(element, '_computeTopicReadOnly').returns(true);
-      element.change = {
-        _number: 42,
-        change_id: 'the id',
-        actions: [],
-        topic: 'the topic',
-        status: 'NEW',
-        submit_type: 'CHERRY_PICK',
-        labels: {
-          test: {
-            all: [{_account_id: 1, name: 'bojack', value: 1}],
-            default_value: 0,
-            values: [],
-          },
-        },
-        removable_reviewers: [],
-      };
-      flushAsynchronousOperations();
-    });
-
-    suite('assignee field', () => {
-      const dummyAccount = {
-        _account_id: 1,
-        name: 'bojack',
-      };
-      const change = {
-        actions: {
-          assignee: {enabled: false},
-        },
-        assignee: dummyAccount,
-      };
-      let deleteStub;
-      let setStub;
-
-      setup(() => {
-        deleteStub = sandbox.stub(element.$.restAPI, 'deleteAssignee');
-        setStub = sandbox.stub(element.$.restAPI, 'setAssignee');
-        element.serverConfig = {
-          change: {
-            enable_assignee: true,
-          },
-        };
-      });
-
-      test('changing change recomputes _assignee', () => {
-        assert.isFalse(!!element._assignee.length);
-        const change = element.change;
-        change.assignee = dummyAccount;
-        element._changeChanged(change);
-        assert.deepEqual(element._assignee[0], dummyAccount);
-      });
-
-      test('modifying _assignee calls API', () => {
-        assert.isFalse(!!element._assignee.length);
-        element.set('_assignee', [dummyAccount]);
-        assert.isTrue(setStub.calledOnce);
-        assert.deepEqual(element.change.assignee, dummyAccount);
-        element.set('_assignee', [dummyAccount]);
-        assert.isTrue(setStub.calledOnce);
-        element.set('_assignee', []);
-        assert.isTrue(deleteStub.calledOnce);
-        assert.equal(element.change.assignee, undefined);
-        element.set('_assignee', []);
-        assert.isTrue(deleteStub.calledOnce);
-      });
-
-      test('_computeAssigneeReadOnly', () => {
-        let mutable = false;
-        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-        mutable = true;
-        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-        change.actions.assignee.enabled = true;
-        assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
-        mutable = false;
-        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
-      });
-    });
-
-    test('changing topic', () => {
-      const newTopic = 'the new topic';
-      sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
-          Promise.resolve(newTopic));
-      element._handleTopicChanged({}, newTopic);
-      const topicChangedSpy = sandbox.spy();
-      element.addEventListener('topic-changed', topicChangedSpy);
-      assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
-          42, newTopic));
-      return element.$.restAPI.setChangeTopic.lastCall.returnValue
-          .then(() => {
-            assert.equal(element.change.topic, newTopic);
-            assert.isTrue(topicChangedSpy.called);
-          });
-    });
-
-    test('topic removal', () => {
-      sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
-          Promise.resolve());
-      const chip = element.shadowRoot
-          .querySelector('gr-linked-chip');
-      const remove = chip.$.remove;
-      const topicChangedSpy = sandbox.spy();
-      element.addEventListener('topic-changed', topicChangedSpy);
-      MockInteractions.tap(remove);
-      assert.isTrue(chip.disabled);
-      assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
-          42, null));
-      return element.$.restAPI.setChangeTopic.lastCall.returnValue
-          .then(() => {
-            assert.isFalse(chip.disabled);
-            assert.equal(element.change.topic, '');
-            assert.isTrue(topicChangedSpy.called);
-          });
-    });
-
-    test('changing hashtag', () => {
-      flushAsynchronousOperations();
-      element._newHashtag = 'new hashtag';
-      const newHashtag = ['new hashtag'];
-      sandbox.stub(element.$.restAPI, 'setChangeHashtag').returns(
-          Promise.resolve(newHashtag));
-      element._handleHashtagChanged({}, 'new hashtag');
-      assert.isTrue(element.$.restAPI.setChangeHashtag.calledWith(
-          42, {add: ['new hashtag']}));
-      return element.$.restAPI.setChangeHashtag.lastCall.returnValue
-          .then(() => {
-            assert.equal(element.change.hashtags, newHashtag);
-          });
-    });
-  });
-
-  test('editTopic', () => {
-    element.account = {test: true};
-    element.change = {actions: {topic: {enabled: true}}};
-    flushAsynchronousOperations();
-
-    const label = element.shadowRoot
-        .querySelector('.topicEditableLabel');
-    assert.ok(label);
-    sandbox.stub(label, 'open');
-    element.editTopic();
-    flushAsynchronousOperations();
-
-    assert.isTrue(label.open.called);
-  });
-
-  suite('plugin endpoints', () => {
-    test('endpoint params', done => {
-      element.change = {labels: {}};
-      element.revision = {};
-      let hookEl;
-      let plugin;
-      pluginApi.install(
-          p => {
-            plugin = p;
-            plugin.hook('change-metadata-item').getLastAttached()
-                .then(el => hookEl = el);
-          },
-          '0.1',
-          'http://some/plugins/url.html');
-      pluginLoader.loadPlugins([]);
-      flush(() => {
-        assert.strictEqual(hookEl.plugin, plugin);
-        assert.strictEqual(hookEl.change, element.change);
-        assert.strictEqual(hookEl.revision, element.revision);
-        done();
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
new file mode 100644
index 0000000..8ec3121
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
@@ -0,0 +1,775 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../core/gr-router/gr-router.js';
+import './gr-change-metadata.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
+const basicFixture = fixtureFromElement('gr-change-metadata');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-metadata tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+
+    element = basicFixture.instantiate();
+  });
+
+  test('computed fields', () => {
+    assert.isFalse(element._computeHideStrategy({status: 'NEW'}));
+    assert.isTrue(element._computeHideStrategy({status: 'MERGED'}));
+    assert.isTrue(element._computeHideStrategy({status: 'ABANDONED'}));
+    assert.equal(element._computeStrategy({submit_type: 'CHERRY_PICK'}),
+        'Cherry Pick');
+    assert.equal(element._computeStrategy({submit_type: 'REBASE_ALWAYS'}),
+        'Rebase Always');
+  });
+
+  test('computed fields requirements', () => {
+    assert.isFalse(element._computeShowRequirements({status: 'MERGED'}));
+    assert.isFalse(element._computeShowRequirements({status: 'ABANDONED'}));
+
+    // No labels and no requirements: submit status is useless
+    assert.isFalse(element._computeShowRequirements({
+      status: 'NEW',
+      labels: {},
+    }));
+
+    // Work in Progress: submit status should be present
+    assert.isTrue(element._computeShowRequirements({
+      status: 'NEW',
+      labels: {},
+      work_in_progress: true,
+    }));
+
+    // We have at least one reason to display Submit Status
+    assert.isTrue(element._computeShowRequirements({
+      status: 'NEW',
+      labels: {
+        Verified: {
+          approved: false,
+        },
+      },
+      requirements: [],
+    }));
+    assert.isTrue(element._computeShowRequirements({
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'OK',
+      }],
+    }));
+  });
+
+  test('show strategy for open change', () => {
+    element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
+    flushAsynchronousOperations();
+    const strategy = element.shadowRoot
+        .querySelector('.strategy');
+    assert.ok(strategy);
+    assert.isFalse(strategy.hasAttribute('hidden'));
+    assert.equal(strategy.children[1].innerHTML, 'Cherry Pick');
+  });
+
+  test('hide strategy for closed change', () => {
+    element.change = {status: 'MERGED', labels: {}};
+    flushAsynchronousOperations();
+    assert.isTrue(element.shadowRoot
+        .querySelector('.strategy').hasAttribute('hidden'));
+  });
+
+  test('weblinks use GerritNav interface', () => {
+    const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
+        .returns([{name: 'stubb', url: '#s'}]);
+    element.commitInfo = {};
+    element.serverConfig = {};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(weblinksStub.called);
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
+  });
+
+  test('weblinks hidden when no weblinks', () => {
+    element.commitInfo = {};
+    element.serverConfig = {};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+  });
+
+  test('weblinks hidden when only gitiles weblink', () => {
+    element.commitInfo = {web_links: [{name: 'gitiles', url: '#'}]};
+    element.serverConfig = {};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo), null);
+  });
+
+  test('weblinks hidden when sole weblink is set as primary', () => {
+    const browser = 'browser';
+    element.commitInfo = {web_links: [{name: browser, url: '#'}]};
+    element.serverConfig = {
+      gerrit: {
+        primary_weblink_name: browser,
+      },
+    };
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isTrue(webLinks.hasAttribute('hidden'));
+  });
+
+  test('weblinks are visible when other weblinks', () => {
+    const router = document.createElement('gr-router');
+    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
+        router._generateWeblinks.bind(router));
+
+    element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
+    // With two non-gitiles weblinks, there are two returned.
+    element.commitInfo = {
+      web_links: [{name: 'test', url: '#'}, {name: 'test2', url: '#'}]};
+    assert.equal(element._computeWebLinks(element.commitInfo).length, 2);
+  });
+
+  test('weblinks are visible when gitiles and other weblinks', () => {
+    const router = document.createElement('gr-router');
+    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
+        router._generateWeblinks.bind(router));
+
+    element.commitInfo = {
+      web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
+    flushAsynchronousOperations();
+    const webLinks = element.$.webLinks;
+    assert.isFalse(webLinks.hasAttribute('hidden'));
+    // Only the non-gitiles weblink is returned.
+    assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
+  });
+
+  suite('_getNonOwnerRole', () => {
+    let change;
+
+    setup(() => {
+      change = {
+        owner: {
+          email: 'abc@def',
+          _account_id: 1019328,
+        },
+        revisions: {
+          rev1: {
+            _number: 1,
+            uploader: {
+              email: 'ghi@def',
+              _account_id: 1011123,
+            },
+            commit: {
+              author: {email: 'jkl@def'},
+              committer: {email: 'ghi@def'},
+            },
+          },
+        },
+        current_revision: 'rev1',
+      };
+    });
+
+    suite('role=uploader', () => {
+      test('_getNonOwnerRole for uploader', () => {
+        assert.deepEqual(
+            element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
+            {email: 'ghi@def', _account_id: 1011123});
+      });
+
+      test('_getNonOwnerRole that it does not return uploader', () => {
+        // Set the uploader email to be the same as the owner.
+        change.revisions.rev1.uploader._account_id = 1019328;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.UPLOADER));
+      });
+
+      test('_getNonOwnerRole null for uploader with no current rev', () => {
+        delete change.current_revision;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.UPLOADER));
+      });
+
+      test('_computeShowRoleClass show uploader', () => {
+        assert.equal(element._computeShowRoleClass(
+            change, element._CHANGE_ROLE.UPLOADER), '');
+      });
+
+      test('_computeShowRoleClass hide uploader', () => {
+        // Set the uploader email to be the same as the owner.
+        change.revisions.rev1.uploader._account_id = 1019328;
+        assert.equal(element._computeShowRoleClass(change,
+            element._CHANGE_ROLE.UPLOADER), 'hideDisplay');
+      });
+    });
+
+    suite('role=committer', () => {
+      test('_getNonOwnerRole for committer', () => {
+        assert.deepEqual(
+            element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
+            {email: 'ghi@def'});
+      });
+
+      test('_getNonOwnerRole that it does not return committer', () => {
+        // Set the committer email to be the same as the owner.
+        change.revisions.rev1.commit.committer.email = 'abc@def';
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
+      });
+
+      test('_getNonOwnerRole null for committer with no current rev', () => {
+        delete change.current_revision;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
+      });
+
+      test('_getNonOwnerRole null for committer with no commit', () => {
+        delete change.revisions.rev1.commit;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
+      });
+
+      test('_getNonOwnerRole null for committer with no committer', () => {
+        delete change.revisions.rev1.commit.committer;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.COMMITTER));
+      });
+    });
+
+    suite('role=author', () => {
+      test('_getNonOwnerRole for author', () => {
+        assert.deepEqual(
+            element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
+            {email: 'jkl@def'});
+      });
+
+      test('_getNonOwnerRole that it does not return author', () => {
+        // Set the author email to be the same as the owner.
+        change.revisions.rev1.commit.author.email = 'abc@def';
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
+      });
+
+      test('_getNonOwnerRole null for author with no current rev', () => {
+        delete change.current_revision;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
+      });
+
+      test('_getNonOwnerRole null for author with no commit', () => {
+        delete change.revisions.rev1.commit;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
+      });
+
+      test('_getNonOwnerRole null for author with no author', () => {
+        delete change.revisions.rev1.commit.author;
+        assert.isNull(element._getNonOwnerRole(change,
+            element._CHANGE_ROLE.AUTHOR));
+      });
+    });
+  });
+
+  test('Push Certificate Validation test BAD', () => {
+    const serverConfig = {
+      receive: {
+        enable_signed_push: true,
+      },
+    };
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      owner: {
+        _account_id: 1019328,
+      },
+      revisions: {
+        rev1: {
+          _number: 1,
+          push_certificate: {
+            key: {
+              status: 'BAD',
+              problems: [
+                'No public keys found for key ID E5E20E52',
+              ],
+            },
+          },
+        },
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: true,
+    };
+    const result =
+        element._computePushCertificateValidation(serverConfig, change);
+    assert.equal(result.message,
+        'Push certificate is invalid:\n' +
+        'No public keys found for key ID E5E20E52');
+    assert.equal(result.icon, 'gr-icons:close');
+    assert.equal(result.class, 'invalid');
+  });
+
+  test('Push Certificate Validation test TRUSTED', () => {
+    const serverConfig = {
+      receive: {
+        enable_signed_push: true,
+      },
+    };
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      owner: {
+        _account_id: 1019328,
+      },
+      revisions: {
+        rev1: {
+          _number: 1,
+          push_certificate: {
+            key: {
+              status: 'TRUSTED',
+            },
+          },
+        },
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: true,
+    };
+    const result =
+        element._computePushCertificateValidation(serverConfig, change);
+    assert.equal(result.message,
+        'Push certificate is valid and key is trusted');
+    assert.equal(result.icon, 'gr-icons:check');
+    assert.equal(result.class, 'trusted');
+  });
+
+  test('Push Certificate Validation is missing test', () => {
+    const serverConfig = {
+      receive: {
+        enable_signed_push: true,
+      },
+    };
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      owner: {
+        _account_id: 1019328,
+      },
+      revisions: {
+        rev1: {
+          _number: 1,
+        },
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: true,
+    };
+    const result =
+        element._computePushCertificateValidation(serverConfig, change);
+    assert.equal(result.message,
+        'This patch set was created without a push certificate');
+    assert.equal(result.icon, 'gr-icons:help');
+    assert.equal(result.class, 'help');
+  });
+
+  test('_computeParents', () => {
+    const parents = [{commit: '123', subject: 'abc'}];
+    const revision = {commit: {parents}};
+    assert.deepEqual(element._computeParents({}, {}), []);
+    assert.equal(element._computeParents(null, revision), parents);
+    const change = current_revision => {
+      return {current_revision, revisions: {456: revision}};
+    };
+    assert.deepEqual(element._computeParents(change(null), null), []);
+    const change_bad_revision = change('789');
+    assert.deepEqual(element._computeParents(change_bad_revision, {}), []);
+    const change_no_commit = {current_revision: '456', revisions: {456: {}}};
+    assert.deepEqual(element._computeParents(change_no_commit, null), []);
+    const change_good = change('456');
+    assert.equal(element._computeParents(change_good, null), parents);
+  });
+
+  test('_currentParents', () => {
+    const revision = parent => {
+      return {commit: {parents: [{commit: parent, subject: 'abc'}]}};
+    };
+    element.change = {
+      current_revision: '456',
+      revisions: {456: revision('111')},
+    };
+    element.revision = revision('222');
+    assert.equal(element._currentParents[0].commit, '222');
+    element.revision = revision('333');
+    assert.equal(element._currentParents[0].commit, '333');
+    element.revision = null;
+    assert.equal(element._currentParents[0].commit, '111');
+    element.change = {current_revision: null};
+    assert.deepEqual(element._currentParents, []);
+  });
+
+  test('_computeParentsLabel', () => {
+    const parent = {commit: 'abc123', subject: 'My parent commit'};
+    assert.equal(element._computeParentsLabel([parent]), 'Parent');
+    assert.equal(element._computeParentsLabel([parent, parent]),
+        'Parents');
+  });
+
+  test('_computeParentListClass', () => {
+    const parent = {commit: 'abc123', subject: 'My parent commit'};
+    assert.equal(element._computeParentListClass([parent], true),
+        'parentList nonMerge current');
+    assert.equal(element._computeParentListClass([parent], false),
+        'parentList nonMerge notCurrent');
+    assert.equal(element._computeParentListClass([parent, parent], false),
+        'parentList merge notCurrent');
+    assert.equal(element._computeParentListClass([parent, parent], true),
+        'parentList merge current');
+  });
+
+  test('_showAddTopic', () => {
+    assert.isTrue(element._showAddTopic(null, false));
+    assert.isTrue(element._showAddTopic({base: {topic: null}}, false));
+    assert.isFalse(element._showAddTopic({base: {topic: null}}, true));
+    assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, true));
+    assert.isFalse(element._showAddTopic({base: {topic: 'foo'}}, false));
+  });
+
+  test('_showTopicChip', () => {
+    assert.isFalse(element._showTopicChip(null, false));
+    assert.isFalse(element._showTopicChip({base: {topic: null}}, false));
+    assert.isFalse(element._showTopicChip({base: {topic: null}}, true));
+    assert.isFalse(element._showTopicChip({base: {topic: 'foo'}}, true));
+    assert.isTrue(element._showTopicChip({base: {topic: 'foo'}}, false));
+  });
+
+  test('_showCherryPickOf', () => {
+    assert.isFalse(element._showCherryPickOf(null));
+    assert.isFalse(element._showCherryPickOf({
+      base: {
+        cherry_pick_of_change: null,
+        cherry_pick_of_patch_set: null,
+      },
+    }));
+    assert.isTrue(element._showCherryPickOf({
+      base: {
+        cherry_pick_of_change: 123,
+        cherry_pick_of_patch_set: 1,
+      },
+    }));
+  });
+
+  suite('Topic removal', () => {
+    let change;
+    setup(() => {
+      change = {
+        _number: 'the number',
+        actions: {
+          topic: {enabled: false},
+        },
+        change_id: 'the id',
+        topic: 'the topic',
+        status: 'NEW',
+        submit_type: 'CHERRY_PICK',
+        labels: {
+          test: {
+            all: [{_account_id: 1, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: [],
+          },
+        },
+        removable_reviewers: [],
+      };
+    });
+
+    test('_computeTopicReadOnly', () => {
+      let mutable = false;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      mutable = true;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      change.actions.topic.enabled = true;
+      assert.isFalse(element._computeTopicReadOnly(mutable, change));
+      mutable = false;
+      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+    });
+
+    test('topic read only hides delete button', () => {
+      element.account = {};
+      element.change = change;
+      flushAsynchronousOperations();
+      const button = element.shadowRoot
+          .querySelector('gr-linked-chip').shadowRoot
+          .querySelector('gr-button');
+      assert.isTrue(button.hasAttribute('hidden'));
+    });
+
+    test('topic not read only does not hide delete button', () => {
+      element.account = {test: true};
+      change.actions.topic.enabled = true;
+      element.change = change;
+      flushAsynchronousOperations();
+      const button = element.shadowRoot
+          .querySelector('gr-linked-chip').shadowRoot
+          .querySelector('gr-button');
+      assert.isFalse(button.hasAttribute('hidden'));
+    });
+  });
+
+  suite('Hashtag removal', () => {
+    let change;
+    setup(() => {
+      change = {
+        _number: 'the number',
+        actions: {
+          hashtags: {enabled: false},
+        },
+        change_id: 'the id',
+        hashtags: ['test-hashtag'],
+        status: 'NEW',
+        submit_type: 'CHERRY_PICK',
+        labels: {
+          test: {
+            all: [{_account_id: 1, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: [],
+          },
+        },
+        removable_reviewers: [],
+      };
+    });
+
+    test('_computeHashtagReadOnly', () => {
+      flushAsynchronousOperations();
+      let mutable = false;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      mutable = true;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      change.actions.hashtags.enabled = true;
+      assert.isFalse(element._computeHashtagReadOnly(mutable, change));
+      mutable = false;
+      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+    });
+
+    test('hashtag read only hides delete button', () => {
+      flushAsynchronousOperations();
+      element.account = {};
+      element.change = change;
+      flushAsynchronousOperations();
+      const button = element.shadowRoot
+          .querySelector('gr-linked-chip').shadowRoot
+          .querySelector('gr-button');
+      assert.isTrue(button.hasAttribute('hidden'));
+    });
+
+    test('hashtag not read only does not hide delete button', () => {
+      flushAsynchronousOperations();
+      element.account = {test: true};
+      change.actions.hashtags.enabled = true;
+      element.change = change;
+      flushAsynchronousOperations();
+      const button = element.shadowRoot
+          .querySelector('gr-linked-chip').shadowRoot
+          .querySelector('gr-button');
+      assert.isFalse(button.hasAttribute('hidden'));
+    });
+  });
+
+  suite('remove reviewer votes', () => {
+    setup(() => {
+      sinon.stub(element, '_computeTopicReadOnly').returns(true);
+      element.change = {
+        _number: 42,
+        change_id: 'the id',
+        actions: [],
+        topic: 'the topic',
+        status: 'NEW',
+        submit_type: 'CHERRY_PICK',
+        labels: {
+          test: {
+            all: [{_account_id: 1, name: 'bojack', value: 1}],
+            default_value: 0,
+            values: [],
+          },
+        },
+        removable_reviewers: [],
+      };
+      flushAsynchronousOperations();
+    });
+
+    suite('assignee field', () => {
+      const dummyAccount = {
+        _account_id: 1,
+        name: 'bojack',
+      };
+      const change = {
+        actions: {
+          assignee: {enabled: false},
+        },
+        assignee: dummyAccount,
+      };
+      let deleteStub;
+      let setStub;
+
+      setup(() => {
+        deleteStub = sinon.stub(element.$.restAPI, 'deleteAssignee');
+        setStub = sinon.stub(element.$.restAPI, 'setAssignee');
+        element.serverConfig = {
+          change: {
+            enable_assignee: true,
+          },
+        };
+      });
+
+      test('changing change recomputes _assignee', () => {
+        assert.isFalse(!!element._assignee.length);
+        const change = element.change;
+        change.assignee = dummyAccount;
+        element._changeChanged(change);
+        assert.deepEqual(element._assignee[0], dummyAccount);
+      });
+
+      test('modifying _assignee calls API', () => {
+        assert.isFalse(!!element._assignee.length);
+        element.set('_assignee', [dummyAccount]);
+        assert.isTrue(setStub.calledOnce);
+        assert.deepEqual(element.change.assignee, dummyAccount);
+        element.set('_assignee', [dummyAccount]);
+        assert.isTrue(setStub.calledOnce);
+        element.set('_assignee', []);
+        assert.isTrue(deleteStub.calledOnce);
+        assert.equal(element.change.assignee, undefined);
+        element.set('_assignee', []);
+        assert.isTrue(deleteStub.calledOnce);
+      });
+
+      test('_computeAssigneeReadOnly', () => {
+        let mutable = false;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+        mutable = true;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+        change.actions.assignee.enabled = true;
+        assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
+        mutable = false;
+        assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+      });
+    });
+
+    test('changing topic', () => {
+      const newTopic = 'the new topic';
+      sinon.stub(element.$.restAPI, 'setChangeTopic').returns(
+          Promise.resolve(newTopic));
+      element._handleTopicChanged({}, newTopic);
+      const topicChangedSpy = sinon.spy();
+      element.addEventListener('topic-changed', topicChangedSpy);
+      assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
+          42, newTopic));
+      return element.$.restAPI.setChangeTopic.lastCall.returnValue
+          .then(() => {
+            assert.equal(element.change.topic, newTopic);
+            assert.isTrue(topicChangedSpy.called);
+          });
+    });
+
+    test('topic removal', () => {
+      sinon.stub(element.$.restAPI, 'setChangeTopic').returns(
+          Promise.resolve());
+      const chip = element.shadowRoot
+          .querySelector('gr-linked-chip');
+      const remove = chip.$.remove;
+      const topicChangedSpy = sinon.spy();
+      element.addEventListener('topic-changed', topicChangedSpy);
+      MockInteractions.tap(remove);
+      assert.isTrue(chip.disabled);
+      assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
+          42, null));
+      return element.$.restAPI.setChangeTopic.lastCall.returnValue
+          .then(() => {
+            assert.isFalse(chip.disabled);
+            assert.equal(element.change.topic, '');
+            assert.isTrue(topicChangedSpy.called);
+          });
+    });
+
+    test('changing hashtag', () => {
+      flushAsynchronousOperations();
+      element._newHashtag = 'new hashtag';
+      const newHashtag = ['new hashtag'];
+      sinon.stub(element.$.restAPI, 'setChangeHashtag').returns(
+          Promise.resolve(newHashtag));
+      element._handleHashtagChanged({}, 'new hashtag');
+      assert.isTrue(element.$.restAPI.setChangeHashtag.calledWith(
+          42, {add: ['new hashtag']}));
+      return element.$.restAPI.setChangeHashtag.lastCall.returnValue
+          .then(() => {
+            assert.equal(element.change.hashtags, newHashtag);
+          });
+    });
+  });
+
+  test('editTopic', () => {
+    element.account = {test: true};
+    element.change = {actions: {topic: {enabled: true}}};
+    flushAsynchronousOperations();
+
+    const label = element.shadowRoot
+        .querySelector('.topicEditableLabel');
+    assert.ok(label);
+    sinon.stub(label, 'open');
+    element.editTopic();
+    flushAsynchronousOperations();
+
+    assert.isTrue(label.open.called);
+  });
+
+  suite('plugin endpoints', () => {
+    test('endpoint params', done => {
+      element.change = {labels: {}};
+      element.revision = {};
+      let hookEl;
+      let plugin;
+      pluginApi.install(
+          p => {
+            plugin = p;
+            plugin.hook('change-metadata-item').getLastAttached()
+                .then(el => hookEl = el);
+          },
+          '0.1',
+          'http://some/plugins/url.html');
+      pluginLoader.loadPlugins([]);
+      flush(() => {
+        assert.strictEqual(hookEl.plugin, plugin);
+        assert.strictEqual(hookEl.change, element.change);
+        assert.strictEqual(hookEl.revision, element.revision);
+        done();
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html b/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
deleted file mode 100644
index b3aa98f..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
+++ /dev/null
@@ -1,28 +0,0 @@
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.registerStyleModule('change-metadata', 'my-plugin-style');
-    });
-  </script>
-</dom-module>
-
-<dom-module id="my-plugin-style">
-  <template>
-    <style>
-      html {
-        --change-metadata-assignee: {
-          display: none;
-        }
-        --change-metadata-label-status: {
-          display: none;
-        }
-        --change-metadata-strategy: {
-          display: none;
-        }
-        --change-metadata-topic: {
-          display: none;
-        }
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
index d301813..8dfee81 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
@@ -14,29 +14,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-icons/gr-icons.js';
 import '../../shared/gr-label/gr-label.js';
 import '../../shared/gr-label-info/gr-label-info.js';
 import '../../shared/gr-limited-text/gr-limited-text.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-change-requirements_html.js';
-import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrChangeRequirements extends mixinBehaviors( [
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrChangeRequirements extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-change-requirements'; }
@@ -93,6 +86,7 @@
     }
     if (change.work_in_progress) {
       _requirements.push({
+        type: 'wip',
         fallback_text: 'Work-in-progress',
         tooltip: 'Change must not be in \'Work in Progress\' state.',
       });
@@ -106,7 +100,7 @@
   }
 
   _computeRequirementIcon(requirementStatus) {
-    return requirementStatus ? 'gr-icons:check' : 'gr-icons:hourglass';
+    return requirementStatus ? 'gr-icons:check' : 'gr-icons:schedule';
   }
 
   _computeLabels(labelsRecord) {
@@ -134,7 +128,7 @@
   _computeLabelIcon(labelInfo) {
     if (labelInfo.approved) { return 'gr-icons:check'; }
     if (labelInfo.rejected) { return 'gr-icons:close'; }
-    return 'gr-icons:hourglass';
+    return 'gr-icons:schedule';
   }
 
   /**
@@ -167,6 +161,10 @@
   _handleShowHide(e) {
     this._showOptionalLabels = !this._showOptionalLabels;
   }
+
+  _computeSubmitRequirementEndpoint(item) {
+    return `submit-requirement-item-${item.type}`;
+  }
 }
 
 customElements.define(GrChangeRequirements.is, GrChangeRequirements);
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js
deleted file mode 100644
index 0da31de..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.js
+++ /dev/null
@@ -1,175 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: table;
-      width: 100%;
-    }
-    .status {
-      color: #ffa62f;
-      display: inline-block;
-      text-align: center;
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-    }
-    .approved.status {
-      color: var(--vote-text-color-recommended);
-    }
-    .rejected.status {
-      color: var(--vote-text-color-disliked);
-    }
-    iron-icon {
-      color: inherit;
-    }
-    .status iron-icon {
-      vertical-align: top;
-    }
-    section {
-      display: table-row;
-    }
-    .show-hide {
-      float: right;
-    }
-    .title {
-      min-width: 10em;
-      padding: var(--spacing-s) var(--spacing-m) 0
-        var(--requirements-horizontal-padding);
-    }
-    .value {
-      padding: var(--spacing-s) 0 0 0;
-    }
-    .title,
-    .value {
-      display: table-cell;
-      vertical-align: top;
-    }
-    .hidden {
-      display: none;
-    }
-    .showHide {
-      cursor: pointer;
-    }
-    .showHide .title {
-      padding-bottom: var(--spacing-m);
-      padding-top: var(--spacing-l);
-    }
-    .showHide .value {
-      padding-top: 0;
-      vertical-align: middle;
-    }
-    .showHide iron-icon {
-      color: var(--deemphasized-text-color);
-      float: right;
-    }
-    .spacer {
-      height: var(--spacing-m);
-    }
-  </style>
-  <template is="dom-repeat" items="[[_requirements]]">
-    <section>
-      <div class="title requirement">
-        <span class$="status [[item.style]]">
-          <iron-icon
-            class="icon"
-            icon="[[_computeRequirementIcon(item.satisfied)]]"
-          ></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="40"
-          text="[[item.fallback_text]]"
-        ></gr-limited-text>
-      </div>
-    </section>
-  </template>
-  <template is="dom-repeat" items="[[_requiredLabels]]">
-    <section>
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="40"
-          text="[[item.label]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.label]]"
-          label-info="[[item.labelInfo]]"
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section class="spacer"></section>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"
-  ></section>
-  <section
-    show-bottom-border$="[[_showOptionalLabels]]"
-    on-click="_handleShowHide"
-    class$="showHide [[_computeShowOptional(_optionalLabels.*)]]"
-  >
-    <div class="title">Other labels</div>
-    <div class="value">
-      <iron-icon
-        id="showHide"
-        icon="[[_computeShowHideIcon(_showOptionalLabels)]]"
-      >
-      </iron-icon>
-    </div>
-  </section>
-  <template is="dom-repeat" items="[[_optionalLabels]]">
-    <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <template is="dom-if" if="[[item.icon]]">
-            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-          </template>
-          <template is="dom-if" if="[[!item.icon]]">
-            <span>[[_computeLabelValue(item.labelInfo.value)]]</span>
-          </template>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="40"
-          text="[[item.label]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.label]]"
-          label-info="[[item.labelInfo]]"
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"
-  ></section>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
new file mode 100644
index 0000000..fc2346a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
@@ -0,0 +1,189 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: table;
+      width: 100%;
+    }
+    .status {
+      color: #ffa62f;
+      display: inline-block;
+      text-align: center;
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+    }
+    .approved.status {
+      color: var(--positive-green-text-color);
+    }
+    .rejected.status {
+      color: var(--negative-red-text-color);
+    }
+    iron-icon {
+      color: inherit;
+    }
+    .status iron-icon {
+      vertical-align: top;
+    }
+    gr-endpoint-decorator.submit-requirement-endpoints,
+    section {
+      display: table-row;
+    }
+    .show-hide {
+      float: right;
+    }
+    .title {
+      min-width: 10em;
+      padding: var(--spacing-s) var(--spacing-m) 0
+        var(--requirements-horizontal-padding);
+    }
+    .value {
+      padding: var(--spacing-s) 0 0 0;
+    }
+    .title,
+    .value {
+      display: table-cell;
+      vertical-align: top;
+    }
+    .hidden {
+      display: none;
+    }
+    .showHide {
+      cursor: pointer;
+    }
+    .showHide .title {
+      padding-bottom: var(--spacing-m);
+      padding-top: var(--spacing-l);
+    }
+    .showHide .value {
+      padding-top: 0;
+      vertical-align: middle;
+    }
+    .showHide iron-icon {
+      color: var(--deemphasized-text-color);
+      float: right;
+    }
+    .spacer {
+      height: var(--spacing-m);
+    }
+    gr-endpoint-param {
+      display: none;
+    }
+  </style>
+  <template is="dom-repeat" items="[[_requirements]]">
+    <gr-endpoint-decorator
+      class="submit-requirement-endpoints"
+      name$="[[_computeSubmitRequirementEndpoint(item)]]"
+    >
+      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+      <gr-endpoint-param name="requirement" value="[[item]]">
+      </gr-endpoint-param>
+      <div class="title requirement">
+        <span class$="status [[item.style]]">
+          <iron-icon
+            class="icon"
+            icon="[[_computeRequirementIcon(item.satisfied)]]"
+          ></iron-icon>
+        </span>
+        <gr-limited-text
+          class="name"
+          limit="40"
+          tooltip="[[item.tooltip]]"
+          text="[[item.fallback_text]]"
+        ></gr-limited-text>
+      </div>
+      <div class="value">
+        <gr-endpoint-slot name="value"></gr-endpoint-slot>
+      </div>
+    </gr-endpoint-decorator>
+  </template>
+  <template is="dom-repeat" items="[[_requiredLabels]]">
+    <section>
+      <div class="title">
+        <span class$="status [[item.style]]">
+          <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
+        </span>
+        <gr-limited-text
+          class="name"
+          limit="40"
+          text="[[item.label]]"
+        ></gr-limited-text>
+      </div>
+      <div class="value">
+        <gr-label-info
+          change="{{change}}"
+          account="[[account]]"
+          mutable="[[mutable]]"
+          label="[[item.label]]"
+          label-info="[[item.labelInfo]]"
+        ></gr-label-info>
+      </div>
+    </section>
+  </template>
+  <section class="spacer"></section>
+  <section
+    class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"
+  ></section>
+  <section
+    show-bottom-border$="[[_showOptionalLabels]]"
+    on-click="_handleShowHide"
+    class$="showHide [[_computeShowOptional(_optionalLabels.*)]]"
+  >
+    <div class="title">Other labels</div>
+    <div class="value">
+      <iron-icon
+        id="showHide"
+        icon="[[_computeShowHideIcon(_showOptionalLabels)]]"
+      >
+      </iron-icon>
+    </div>
+  </section>
+  <template is="dom-repeat" items="[[_optionalLabels]]">
+    <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
+      <div class="title">
+        <span class$="status [[item.style]]">
+          <template is="dom-if" if="[[item.icon]]">
+            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
+          </template>
+          <template is="dom-if" if="[[!item.icon]]">
+            <span>[[_computeLabelValue(item.labelInfo.value)]]</span>
+          </template>
+        </span>
+        <gr-limited-text
+          class="name"
+          limit="40"
+          text="[[item.label]]"
+        ></gr-limited-text>
+      </div>
+      <div class="value">
+        <gr-label-info
+          change="{{change}}"
+          account="[[account]]"
+          mutable="[[mutable]]"
+          label="[[item.label]]"
+          label-info="[[item.labelInfo]]"
+        ></gr-label-info>
+      </div>
+    </section>
+  </template>
+  <section
+    class$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"
+  ></section>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
deleted file mode 100644
index e100f91..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.html
+++ /dev/null
@@ -1,236 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-change-requirements</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-requirements></gr-change-requirements>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-change-requirements.js';
-import {isHidden} from '../../../test/test-utils.js';
-suite('gr-change-metadata tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('requirements computed fields', () => {
-    assert.isTrue(element._computeShowWip({work_in_progress: true}));
-    assert.isFalse(element._computeShowWip({work_in_progress: false}));
-
-    assert.equal(element._computeRequirementClass(true), 'approved');
-    assert.equal(element._computeRequirementClass(false), '');
-
-    assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
-    assert.equal(element._computeRequirementIcon(false),
-        'gr-icons:hourglass');
-  });
-
-  test('label computed fields', () => {
-    assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
-    assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
-    assert.equal(element._computeLabelIcon({}), 'gr-icons:hourglass');
-
-    assert.equal(element._computeLabelClass({approved: []}), 'approved');
-    assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
-    assert.equal(element._computeLabelClass({}), '');
-    assert.equal(element._computeLabelClass({value: 0}), '');
-
-    assert.equal(element._computeLabelValue(1), '+1');
-    assert.equal(element._computeLabelValue(-1), '-1');
-    assert.equal(element._computeLabelValue(0), '0');
-  });
-
-  test('_computeLabels', () => {
-    assert.equal(element._optionalLabels.length, 0);
-    assert.equal(element._requiredLabels.length, 0);
-    element._computeLabels({base: {
-      test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        value: 1,
-      },
-      opt_test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        optional: true,
-      },
-    }});
-    assert.equal(element._optionalLabels.length, 1);
-    assert.equal(element._requiredLabels.length, 1);
-
-    assert.equal(element._optionalLabels[0].label, 'opt_test');
-    assert.equal(element._optionalLabels[0].icon, 'gr-icons:hourglass');
-    assert.equal(element._optionalLabels[0].style, '');
-    assert.ok(element._optionalLabels[0].labelInfo);
-  });
-
-  test('optional show/hide', () => {
-    element._optionalLabels = [{label: 'test'}];
-    flushAsynchronousOperations();
-
-    assert.ok(element.shadowRoot
-        .querySelector('section.optional'));
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.showHide'));
-    flushAsynchronousOperations();
-
-    assert.isFalse(element._showOptionalLabels);
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('section.optional')));
-  });
-
-  test('properly converts satisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: [],
-        },
-      },
-      requirements: [],
-    };
-    flushAsynchronousOperations();
-
-    assert.ok(element.shadowRoot
-        .querySelector('.approved'));
-    assert.ok(element.shadowRoot
-        .querySelector('.name'));
-    assert.equal(element.shadowRoot
-        .querySelector('.name').text, 'Verified');
-  });
-
-  test('properly converts unsatisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: false,
-        },
-      },
-    };
-    flushAsynchronousOperations();
-
-    const name = element.shadowRoot
-        .querySelector('.name');
-    assert.ok(name);
-    assert.isFalse(name.hasAttribute('hidden'));
-    assert.equal(name.text, 'Verified');
-  });
-
-  test('properly displays Work In Progress', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [],
-      work_in_progress: true,
-    };
-    flushAsynchronousOperations();
-
-    const changeIsWip = element.shadowRoot
-        .querySelector('.title');
-    assert.ok(changeIsWip);
-  });
-
-  test('properly displays a satisfied requirement', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flushAsynchronousOperations();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.isFalse(requirement.hasAttribute('hidden'));
-    assert.ok(requirement.querySelector('.approved'));
-    assert.equal(requirement.querySelector('.name').text,
-        'Resolve all comments');
-  });
-
-  test('satisfied class is applied with OK', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flushAsynchronousOperations();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.ok(requirement.querySelector('.approved'));
-  });
-
-  test('satisfied class is not applied with NOT_READY', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'NOT_READY',
-      }],
-    };
-    flushAsynchronousOperations();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-
-  test('satisfied class is not applied with RULE_ERROR', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'RULE_ERROR',
-      }],
-    };
-    flushAsynchronousOperations();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
new file mode 100644
index 0000000..d8a90fc
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
@@ -0,0 +1,222 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-requirements.js';
+import {isHidden} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-change-requirements');
+
+suite('gr-change-metadata tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('requirements computed fields', () => {
+    assert.isTrue(element._computeShowWip({work_in_progress: true}));
+    assert.isFalse(element._computeShowWip({work_in_progress: false}));
+
+    assert.equal(element._computeRequirementClass(true), 'approved');
+    assert.equal(element._computeRequirementClass(false), '');
+
+    assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
+    assert.equal(element._computeRequirementIcon(false),
+        'gr-icons:schedule');
+  });
+
+  test('label computed fields', () => {
+    assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
+    assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
+    assert.equal(element._computeLabelIcon({}), 'gr-icons:schedule');
+
+    assert.equal(element._computeLabelClass({approved: []}), 'approved');
+    assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
+    assert.equal(element._computeLabelClass({}), '');
+    assert.equal(element._computeLabelClass({value: 0}), '');
+
+    assert.equal(element._computeLabelValue(1), '+1');
+    assert.equal(element._computeLabelValue(-1), '-1');
+    assert.equal(element._computeLabelValue(0), '0');
+  });
+
+  test('_computeLabels', () => {
+    assert.equal(element._optionalLabels.length, 0);
+    assert.equal(element._requiredLabels.length, 0);
+    element._computeLabels({base: {
+      test: {
+        all: [{_account_id: 1, name: 'bojack', value: 1}],
+        default_value: 0,
+        values: [],
+        value: 1,
+      },
+      opt_test: {
+        all: [{_account_id: 1, name: 'bojack', value: 1}],
+        default_value: 0,
+        values: [],
+        optional: true,
+      },
+    }});
+    assert.equal(element._optionalLabels.length, 1);
+    assert.equal(element._requiredLabels.length, 1);
+
+    assert.equal(element._optionalLabels[0].label, 'opt_test');
+    assert.equal(element._optionalLabels[0].icon, 'gr-icons:schedule');
+    assert.equal(element._optionalLabels[0].style, '');
+    assert.ok(element._optionalLabels[0].labelInfo);
+  });
+
+  test('optional show/hide', () => {
+    element._optionalLabels = [{label: 'test'}];
+    flushAsynchronousOperations();
+
+    assert.ok(element.shadowRoot
+        .querySelector('section.optional'));
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.showHide'));
+    flushAsynchronousOperations();
+
+    assert.isFalse(element._showOptionalLabels);
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('section.optional')));
+  });
+
+  test('properly converts satisfied labels', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {
+        Verified: {
+          approved: [],
+        },
+      },
+      requirements: [],
+    };
+    flushAsynchronousOperations();
+
+    assert.ok(element.shadowRoot
+        .querySelector('.approved'));
+    assert.ok(element.shadowRoot
+        .querySelector('.name'));
+    assert.equal(element.shadowRoot
+        .querySelector('.name').text, 'Verified');
+  });
+
+  test('properly converts unsatisfied labels', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {
+        Verified: {
+          approved: false,
+        },
+      },
+    };
+    flushAsynchronousOperations();
+
+    const name = element.shadowRoot
+        .querySelector('.name');
+    assert.ok(name);
+    assert.isFalse(name.hasAttribute('hidden'));
+    assert.equal(name.text, 'Verified');
+  });
+
+  test('properly displays Work In Progress', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [],
+      work_in_progress: true,
+    };
+    flushAsynchronousOperations();
+
+    const changeIsWip = element.shadowRoot
+        .querySelector('.title');
+    assert.ok(changeIsWip);
+  });
+
+  test('properly displays a satisfied requirement', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'OK',
+      }],
+    };
+    flushAsynchronousOperations();
+
+    const requirement = element.shadowRoot
+        .querySelector('.requirement');
+    assert.ok(requirement);
+    assert.isFalse(requirement.hasAttribute('hidden'));
+    assert.ok(requirement.querySelector('.approved'));
+    assert.equal(requirement.querySelector('.name').text,
+        'Resolve all comments');
+  });
+
+  test('satisfied class is applied with OK', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'OK',
+      }],
+    };
+    flushAsynchronousOperations();
+
+    const requirement = element.shadowRoot
+        .querySelector('.requirement');
+    assert.ok(requirement);
+    assert.ok(requirement.querySelector('.approved'));
+  });
+
+  test('satisfied class is not applied with NOT_READY', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'NOT_READY',
+      }],
+    };
+    flushAsynchronousOperations();
+
+    const requirement = element.shadowRoot
+        .querySelector('.requirement');
+    assert.ok(requirement);
+    assert.strictEqual(requirement.querySelector('.approved'), null);
+  });
+
+  test('satisfied class is not applied with RULE_ERROR', () => {
+    element.change = {
+      status: 'NEW',
+      labels: {},
+      requirements: [{
+        fallback_text: 'Resolve all comments',
+        status: 'RULE_ERROR',
+      }],
+    };
+    flushAsynchronousOperations();
+
+    const requirement = element.shadowRoot
+        .querySelector('.requirement');
+    assert.ok(requirement);
+    assert.strictEqual(requirement.querySelector('.approved'), null);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index ddf28d4..f68d683 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -14,11 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/paper-tabs/paper-tabs.js';
 import '../../../styles/shared-styles.js';
-import '../../core/gr-reporting/gr-reporting.js';
 import '../../diff/gr-comment-api/gr-comment-api.js';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
@@ -43,32 +40,39 @@
 import '../gr-file-list/gr-file-list.js';
 import '../gr-included-in-dialog/gr-included-in-dialog.js';
 import '../gr-messages-list/gr-messages-list.js';
-import '../gr-messages-list/gr-messages-list-experimental.js';
 import '../gr-related-changes-list/gr-related-changes-list.js';
 import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js';
 import '../gr-reply-dialog/gr-reply-dialog.js';
 import '../gr-thread-list/gr-thread-list.js';
 import '../gr-upload-help-dialog/gr-upload-help-dialog.js';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-change-view_html.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {GrEditConstants} from '../../edit/gr-edit-constants.js';
 import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {util} from '../../../scripts/util.js';
+import {getComputedStyleValue} from '../../../utils/dom-util.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
 
-import {PrimaryTabs, SecondaryTabs} from '../../../constants/constants.js';
+import {PrimaryTab, SecondaryTab} from '../../../constants/constants.js';
 import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages.js';
 import {appContext} from '../../../services/app-context.js';
+import {ChangeStatus} from '../../../constants/constants.js';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  fetchChangeUpdates,
+  hasEditBasedOnCurrentPatchSet,
+  hasEditPatchsetLoaded,
+  patchNumEquals,
+  SPECIAL_PATCH_SET_NUM,
+} from '../../../utils/patch-set-util.js';
+import {changeStatuses, changeStatusString} from '../../../utils/change-util.js';
 
 const CHANGE_ID_ERROR = {
   MISMATCH: 'mismatch',
@@ -129,17 +133,10 @@
  */
 
 /**
- * @appliesMixin RESTClientMixin
- * @appliesMixin PatchSetMixin
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrChangeView extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-  PatchSetBehavior,
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrChangeView extends KeyboardShortcutMixin(
+    GestureEventListeners(LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-change-view'; }
@@ -229,7 +226,6 @@
         type: Boolean,
         computed: '_computeCanStartReview(_change)',
       },
-      _comments: Object,
       /** @type {?} */
       _change: {
         type: Object,
@@ -272,8 +268,8 @@
       _constants: {
         type: Object,
         value: {
-          SecondaryTabs,
-          PrimaryTabs,
+          SecondaryTab,
+          PrimaryTab,
         },
       },
       _messages: {
@@ -298,7 +294,7 @@
       _currentRevisionActions: Object,
       _allPatchSets: {
         type: Array,
-        computed: 'computeAllPatchSets(_change, _change.revisions.*)',
+        computed: '_computeAllPatchSets(_change, _change.revisions.*)',
       },
       _loggedIn: {
         type: Boolean,
@@ -325,7 +321,7 @@
       },
       _changeStatus: {
         type: String,
-        computed: 'changeStatusString(_change)',
+        computed: '_changeStatusString(_change)',
       },
       _changeStatuses: {
         type: String,
@@ -407,7 +403,7 @@
        */
       _activeTabs: {
         type: Array,
-        value: [PrimaryTabs.FILES, SecondaryTabs.CHANGE_LOG],
+        value: [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG],
       },
       _showAllRobotComments: {
         type: Boolean,
@@ -430,26 +426,33 @@
 
   keyboardShortcuts() {
     return {
-      [this.Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
-      [this.Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
-      [this.Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
-      [this.Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
-      [this.Shortcut.OPEN_DOWNLOAD_DIALOG]:
+      [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
+      [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
+      [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
+      [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
+      [Shortcut.OPEN_DOWNLOAD_DIALOG]:
           '_handleOpenDownloadDialogShortcut',
-      [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-      [this.Shortcut.TOGGLE_CHANGE_STAR]: '_handleToggleChangeStar',
-      [this.Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
-      [this.Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
-      [this.Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
-      [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
-      [this.Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
-      [this.Shortcut.EDIT_TOPIC]: '_handleEditTopic',
+      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+      [Shortcut.TOGGLE_CHANGE_STAR]: '_handleToggleChangeStar',
+      [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
+      [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
+      [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
+      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
+      [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
+      [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
+      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
+      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
+      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]:
+        '_handleDiffRightAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LATEST]:
+        '_handleDiffBaseAgainstLatest',
     };
   }
 
   constructor() {
     super();
-    this.flagsService = appContext.flagsService;
+    this.reporting = appContext.reportingService;
   }
 
   /** @override */
@@ -473,6 +476,9 @@
 
     this.addEventListener('diff-comments-modified',
         () => this._handleReloadCommentThreads());
+
+    this.addEventListener('open-reply-dialog',
+        e => this._openReplyDialog());
   }
 
   /** @override */
@@ -495,9 +501,9 @@
     pluginLoader.awaitPluginsLoaded()
         .then(() => {
           this._dynamicTabHeaderEndpoints =
-            pluginEndpoints.getDynamicEndpoints('change-view-tab-header');
+            getPluginEndpoints().getDynamicEndpoints('change-view-tab-header');
           this._dynamicTabContentEndpoints =
-            pluginEndpoints.getDynamicEndpoints('change-view-tab-content');
+            getPluginEndpoints().getDynamicEndpoints('change-view-tab-content');
           if (this._dynamicTabContentEndpoints.length !==
           this._dynamicTabHeaderEndpoints.length) {
             console.warn('Different number of tab headers and tab content.');
@@ -526,6 +532,11 @@
         e => this._setActivePrimaryTab(e));
     this.addEventListener('show-secondary-tab',
         e => this._setActiveSecondaryTab(e));
+    this.addEventListener('reload', e => {
+      e.stopPropagation();
+      this._reload(/* opt_isLocationChange= */false,
+          /* opt_clearPatchset= */e.detail && e.detail.clearPatchset);
+    });
   }
 
   /** @override */
@@ -539,20 +550,18 @@
     }
   }
 
-  _isChangeLogExperimentEnabled() {
-    return this.flagsService.isEnabled('UiFeature__cleaner_changelog');
-  }
-
   get messagesList() {
-    const tagName = this._isChangeLogExperimentEnabled()
-      ? 'gr-messages-list-experimental' : 'gr-messages-list';
-    return this.shadowRoot.querySelector(tagName);
+    return this.shadowRoot.querySelector('gr-messages-list');
   }
 
   get threadList() {
     return this.shadowRoot.querySelector('gr-thread-list');
   }
 
+  _changeStatusString(change) {
+    return changeStatusString(change);
+  }
+
   /**
    * @param {boolean=} opt_reset
    */
@@ -627,7 +636,7 @@
     }
     if (paperTabs.selected !== activeIndex) {
       paperTabs.selected = activeIndex;
-      this.$.reporting.reportInteraction('show-tab', {tabName});
+      this.reporting.reportInteraction('show-tab', {tabName});
     }
     return tabName;
   }
@@ -720,7 +729,7 @@
     if ([
       change,
       mergeable,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       // To keep consistent with Polymer 1, we are returning undefined
       // if not all dependencies are defined
       return undefined;
@@ -736,13 +745,13 @@
       mergeable: !!mergeable,
       submitEnabled: !!submitEnabled,
     };
-    return this.changeStatuses(change, options);
+    return changeStatuses(change, options);
   }
 
   _computeHideEditCommitMessage(
       loggedIn, editing, change, editMode, collapsed, collapsible) {
     if (!loggedIn || editing ||
-        (change && change.status === this.ChangeStatus.MERGED) ||
+        (change && change.status === ChangeStatus.MERGED) ||
         editMode ||
         (collapsed && collapsible)) {
       return true;
@@ -821,8 +830,7 @@
     // Get any new drafts that have been saved in the diff view and show
     // in the comment thread view.
     this._reloadDrafts().then(() => {
-      this._commentThreads = this._changeComments.getAllThreadsForChange()
-          .map(c => Object.assign({}, c));
+      this._commentThreads = this._changeComments.getAllThreadsForChange();
       flush();
     });
   }
@@ -862,7 +870,7 @@
     // because the paths could contain dots in them. A new object must be
     // created to satisfy Polymer’s dirty checking.
     // https://github.com/Polymer/polymer/issues/3127
-    const diffDrafts = Object.assign({}, this._diffDrafts);
+    const diffDrafts = {...this._diffDrafts};
     if (!diffDrafts[draft.path]) {
       diffDrafts[draft.path] = [draft];
       this._diffDrafts = diffDrafts;
@@ -910,7 +918,7 @@
     // because the paths could contain dots in them. A new object must be
     // created to satisfy Polymer’s dirty checking.
     // https://github.com/Polymer/polymer/issues/3127
-    const diffDrafts = Object.assign({}, this._diffDrafts);
+    const diffDrafts = {...this._diffDrafts};
     diffDrafts[draft.path].splice(index, 1);
     if (diffDrafts[draft.path].length === 0) {
       delete diffDrafts[draft.path];
@@ -979,7 +987,7 @@
   _handleReplySent(e) {
     this.addEventListener('change-details-loaded',
         () => {
-          this.$.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
+          this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
         }, {once: true});
     this.$.replyOverlay.close();
     this._reload();
@@ -1014,7 +1022,8 @@
     this._shownFileCount = e.detail.length;
   }
 
-  _expandAllDiffs() {
+  _expandAllDiffs(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
     this.$.fileList.expandAllDiffs();
   }
 
@@ -1036,10 +1045,7 @@
         (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
         (this._patchRange.patchNum !== value.patchNum ||
         this._patchRange.basePatchNum !== value.basePatchNum);
-
-    if (this._changeNum !== value.changeNum) {
-      this._initialLoadComplete = false;
-    }
+    const changeChanged = this._changeNum !== value.changeNum;
 
     const patchRange = {
       patchNum: value.patchNum,
@@ -1051,9 +1057,9 @@
 
     // If the change has already been loaded and the parameter change is only
     // in the patch range, then don't do a full reload.
-    if (this._initialLoadComplete && patchChanged) {
+    if (!changeChanged && patchChanged) {
       if (patchRange.patchNum == null) {
-        patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets);
+        patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
       }
       this._reloadPatchNumDependentResources().then(() => {
         this._sendShowChangeEvent();
@@ -1061,6 +1067,7 @@
       return;
     }
 
+    this._initialLoadComplete = false;
     this._changeNum = value.changeNum;
     this.$.relatedChanges.clear();
 
@@ -1074,7 +1081,7 @@
   }
 
   _initActiveTabs(params = {}) {
-    let primaryTab = PrimaryTabs.FILES;
+    let primaryTab = PrimaryTab.FILES;
     if (params.queryMap && params.queryMap.has('tab')) {
       primaryTab = params.queryMap.get('tab');
     }
@@ -1083,16 +1090,9 @@
         tab: primaryTab,
       },
     });
-
-    // TODO: should drop this once we move CommentThreads tab
-    // to primary as well
-    let secondaryTab = SecondaryTabs.CHANGE_LOG;
-    if (params.queryMap && params.queryMap.has('secondaryTab')) {
-      secondaryTab = params.queryMap.get('secondaryTab');
-    }
     this._setActiveSecondaryTab({
       detail: {
-        tab: secondaryTab,
+        tab: SecondaryTab.CHANGE_LOG,
       },
     });
   }
@@ -1124,7 +1124,7 @@
 
   _paramsAndChangeChanged(value, change) {
     // Polymer 2: check for undefined
-    if ([value, change].some(arg => arg === undefined)) {
+    if ([value, change].includes(undefined)) {
       return;
     }
 
@@ -1183,7 +1183,7 @@
         .then(this._getLoggedIn.bind(this))
         .then(loggedIn => {
           if (!loggedIn || !this._change ||
-              this._change.status !== this.ChangeStatus.MERGED) {
+              this._change.status !== ChangeStatus.MERGED) {
           // Do not display dialog if not logged-in or the change is not
           // merged.
             return;
@@ -1230,7 +1230,7 @@
     const parent = this._getBasePatchNum(change, this._patchRange);
 
     this.set('_patchRange.patchNum', this._patchRange.patchNum ||
-            this.computeLatestPatchNum(this._allPatchSets));
+            computeLatestPatchNum(this._allPatchSets));
 
     this.set('_patchRange.basePatchNum', parent);
 
@@ -1305,7 +1305,7 @@
 
   _computeChangeIdCommitMessageError(commitMessage, change) {
     // Polymer 2: check for undefined
-    if ([commitMessage, change].some(arg => arg === undefined)) {
+    if ([commitMessage, change].includes(undefined)) {
       return undefined;
     }
 
@@ -1364,18 +1364,15 @@
 
   _computeReplyButtonLabel(changeRecord, canStartReview) {
     // Polymer 2: check for undefined
-    if ([changeRecord, canStartReview].some(arg => arg === undefined)) {
+    if ([changeRecord, canStartReview].includes(undefined)) {
       return 'Reply';
     }
-    if (canStartReview) {
-      return 'Start Review';
-    }
 
     const drafts = (changeRecord && changeRecord.base) || {};
     const draftCount = Object.keys(drafts)
         .reduce((count, file) => count + drafts[file].length, 0);
 
-    let label = 'Reply';
+    let label = canStartReview ? 'Start Review' : 'Reply';
     if (draftCount > 0) {
       label += ' (' + draftCount + ')';
     }
@@ -1405,7 +1402,7 @@
         this.modifierPressed(e)) { return; }
 
     e.preventDefault();
-    this.$.downloadOverlay.open();
+    this._handleOpenDownloadDialog();
   }
 
   _handleEditTopic(e) {
@@ -1416,10 +1413,90 @@
     this.$.metadata.editTopic();
   }
 
+  _handleDiffAgainstBase(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    if (patchNumEquals(this._patchRange.basePatchNum,
+        SPECIAL_PATCH_SET_NUM.PARENT)) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {
+          message: 'Base is already selected.',
+        },
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+  }
+
+  _handleDiffBaseAgainstLeft(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    if (patchNumEquals(this._patchRange.basePatchNum,
+        SPECIAL_PATCH_SET_NUM.PARENT)) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {
+          message: 'Left is already base.',
+        },
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
+  }
+
+  _handleDiffAgainstLatest(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {
+          message: 'Latest is already selected.',
+        },
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    GerritNav.navigateToChange(this._change, latestPatchNum,
+        this._patchRange.basePatchNum);
+  }
+
+  _handleDiffRightAgainstLatest(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {
+          message: 'Right is already latest.',
+        },
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    GerritNav.navigateToChange(this._change, latestPatchNum,
+        this._patchRange.patchNum);
+  }
+
+  _handleDiffBaseAgainstLatest(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
+      patchNumEquals(this._patchRange.basePatchNum,
+          SPECIAL_PATCH_SET_NUM.PARENT)) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {
+          message: 'Already diffing base against latest.',
+        },
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    GerritNav.navigateToChange(this._change, latestPatchNum);
+  }
+
   _handleRefreshChange(e) {
     if (this.shouldSuppressKeyboardShortcut(e)) { return; }
     e.preventDefault();
-    GerritNav.navigateToChange(this._change);
+    this._reload(/* opt_isLocationChange= */false,
+        /* opt_clearPatchset= */true);
   }
 
   _handleToggleChangeStar(e) {
@@ -1510,17 +1587,6 @@
     });
   }
 
-  _handleReloadChange(e) {
-    return this._reload().then(() => {
-      // If the change was rebased or submitted, we need to reload the page
-      // with the latest patch.
-      const action = e.detail.action;
-      if (action === 'rebase' || action === 'submit') {
-        GerritNav.navigateToChange(this._change);
-      }
-    });
-  }
-
   _handleGetChangeDetailError(response) {
     this.dispatchEvent(new CustomEvent('page-error', {
       detail: {response},
@@ -1565,7 +1631,7 @@
   _processEdit(change, edit) {
     if (!edit) { return; }
     change.revisions[edit.commit.commit] = {
-      _number: this.EDIT_NAME,
+      _number: SPECIAL_PATCH_SET_NUM.EDIT,
       basePatchNum: edit.base_patch_set_number,
       commit: edit.commit,
       fetch: edit.fetch,
@@ -1575,7 +1641,7 @@
     if (!this._patchRange.patchNum &&
         change.current_revision === edit.base_revision) {
       change.current_revision = edit.commit.commit;
-      this.set('_patchRange.patchNum', this.EDIT_NAME);
+      this.set('_patchRange.patchNum', SPECIAL_PATCH_SET_NUM.EDIT);
       // Because edits are fibbed as revisions and added to the revisions
       // array, and revision actions are always derived from the 'latest'
       // patch set, we must copy over actions from the patch set base.
@@ -1621,7 +1687,7 @@
 
           this._change = change;
           if (!this._patchRange || !this._patchRange.patchNum ||
-              this.patchNumEquals(this._patchRange.patchNum,
+              patchNumEquals(this._patchRange.patchNum,
                   currentRevision._number)) {
             // CommitInfo.commit is optional, and may need patching.
             if (!currentRevision.commit.commit) {
@@ -1664,7 +1730,7 @@
 
   _getLatestCommitMessage() {
     return this.$.restAPI.getChangeCommitInfo(this._changeNum,
-        this.computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
+        computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
       if (!commitInfo) return Promise.resolve();
       this._latestCommitMessage =
                   this._prepareCommitMsgForLinkify(commitInfo.message);
@@ -1707,6 +1773,13 @@
    * (comments, robot comments, draft comments) is requested.
    */
   _reloadComments() {
+    // We are resetting all comment related properties, because we want to avoid
+    // a new change being loaded and then paired with outdated comments.
+    this._changeComments = undefined;
+    this._commentThreads = undefined;
+    this._diffDrafts = undefined;
+    this._draftCommentThreads = undefined;
+    this._robotCommentThreads = undefined;
     return this.$.commentAPI.loadAll(this._changeNum)
         .then(comments => this._recomputeComments(comments));
   }
@@ -1726,11 +1799,18 @@
 
   _recomputeComments(comments) {
     this._changeComments = comments;
-    this._diffDrafts = Object.assign({}, this._changeComments.drafts);
-    this._commentThreads = this._changeComments.getAllThreadsForChange()
-        .map(c => Object.assign({}, c));
+    this._diffDrafts = {...this._changeComments.drafts};
+    this._commentThreads = this._changeComments.getAllThreadsForChange();
     this._draftCommentThreads = this._commentThreads
-        .filter(c => c.comments[c.comments.length - 1].__draft);
+        .filter(thread => thread.comments[thread.comments.length - 1].__draft)
+        .map(thread => {
+          const copiedThread = {...thread};
+          // Make a hardcopy of all comments and collapse all but last one
+          const commentsInThread = copiedThread.comments = thread.comments
+              .map(comment => { return {...comment, collapsed: true}; });
+          commentsInThread[commentsInThread.length - 1].collapsed = false;
+          return copiedThread;
+        });
   }
 
   /**
@@ -1738,15 +1818,21 @@
    *
    * @param {boolean=} opt_isLocationChange Reloads the related changes
    *     when true and ends reporting events that started on location change.
+   * @param {boolean=} opt_clearPatchset Reloads the related changes
+   *     ignoring any patchset choice made.
    * @return {Promise} A promise that resolves when the core data has loaded.
    *     Some non-core data loading may still be in-flight when the core data
    *     promise resolves.
    */
-  _reload(opt_isLocationChange) {
+  _reload(opt_isLocationChange, opt_clearPatchset) {
+    if (opt_clearPatchset) {
+      GerritNav.navigateToChange(this._change);
+      return;
+    }
     this._loading = true;
     this._relatedChangesCollapsed = true;
-    this.$.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
-    this.$.reporting.time(CHANGE_DATA_TIMING_LABEL);
+    this.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
+    this.reporting.time(CHANGE_DATA_TIMING_LABEL);
 
     // Array to house all promises related to data requests.
     const allDataPromises = [];
@@ -1765,9 +1851,9 @@
               {bubbles: true, composed: true}));
         })
         .then(() => {
-          this.$.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
+          this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
           if (opt_isLocationChange) {
-            this.$.reporting.changeDisplayed();
+            this.reporting.changeDisplayed();
           }
         });
 
@@ -1837,9 +1923,9 @@
     }
 
     Promise.all(allDataPromises).then(() => {
-      this.$.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
+      this.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
       if (opt_isLocationChange) {
-        this.$.reporting.changeFullyLoaded();
+        this.reporting.changeFullyLoaded();
       }
     });
 
@@ -1865,8 +1951,8 @@
     // If the change is closed, it is not mergeable. Note: already merged
     // changes are obviously not mergeable, but the mergeability API will not
     // answer for abandoned changes.
-    if (this._change.status === this.ChangeStatus.MERGED ||
-        this._change.status === this.ChangeStatus.ABANDONED) {
+    if (this._change.status === ChangeStatus.MERGED ||
+        this._change.status === ChangeStatus.ABANDONED) {
       this._mergeable = false;
       return Promise.resolve();
     }
@@ -2008,7 +2094,7 @@
   _computeShowRelatedToggle() {
     // Make sure the max height has been applied, since there is now content
     // to populate.
-    if (!util.getComputedStyleValue('--relation-chain-max-height', this)) {
+    if (!getComputedStyleValue('--relation-chain-max-height', this)) {
       this._updateRelatedChangeMaxHeight();
     }
     // Prevents showMore from showing when click on related change, since the
@@ -2042,15 +2128,15 @@
     }
 
     this._updateCheckTimerHandle = this.async(() => {
-      this.fetchChangeUpdates(this._change, this.$.restAPI).then(result => {
+      fetchChangeUpdates(this._change, this.$.restAPI).then(result => {
         let toastMessage = null;
         if (!result.isLatest) {
           toastMessage = ReloadToastMessage.NEWER_REVISION;
-        } else if (result.newStatus === this.ChangeStatus.MERGED) {
+        } else if (result.newStatus === ChangeStatus.MERGED) {
           toastMessage = ReloadToastMessage.MERGED;
-        } else if (result.newStatus === this.ChangeStatus.ABANDONED) {
+        } else if (result.newStatus === ChangeStatus.ABANDONED) {
           toastMessage = ReloadToastMessage.ABANDONED;
-        } else if (result.newStatus === this.ChangeStatus.NEW) {
+        } else if (result.newStatus === ChangeStatus.NEW) {
           toastMessage = ReloadToastMessage.RESTORED;
         } else if (result.newMessages) {
           toastMessage = ReloadToastMessage.NEW_MESSAGE;
@@ -2068,10 +2154,10 @@
             // Persist this alert.
             dismissOnNavigation: true,
             action: 'Reload',
-            callback: function() {
-            // Load the current change without any patch range.
-              GerritNav.navigateToChange(this._change);
-            }.bind(this),
+            callback: () => {
+              this._reload(/* opt_isLocationChange= */false,
+                  /* opt_clearPatchset= */true);
+            },
           },
           composed: true, bubbles: true,
         }));
@@ -2105,19 +2191,20 @@
   }
 
   _computeEditMode(patchRangeRecord, paramsRecord) {
-    if ([patchRangeRecord, paramsRecord].some(arg => arg === undefined)) {
+    if ([patchRangeRecord, paramsRecord].includes(undefined)) {
       return undefined;
     }
 
     if (paramsRecord.base && paramsRecord.base.edit) { return true; }
 
     const patchRange = patchRangeRecord.base || {};
-    return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
+    return patchNumEquals(patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
   }
 
   _handleFileActionTap(e) {
     e.preventDefault();
-    const controls = this.$.fileListHeader.$.editControls;
+    const controls = this.$.fileListHeader
+        .shadowRoot.querySelector('#editControls');
     const path = e.detail.path;
     switch (e.detail.action) {
       case GrEditConstants.Actions.DELETE.id:
@@ -2164,18 +2251,18 @@
    */
   _handleEditTap() {
     const editInfo = Object.values(this._change.revisions).find(info =>
-      info._number === this.EDIT_NAME);
+      info._number === SPECIAL_PATCH_SET_NUM.EDIT);
 
     if (editInfo) {
-      GerritNav.navigateToChange(this._change, this.EDIT_NAME);
+      GerritNav.navigateToChange(this._change, SPECIAL_PATCH_SET_NUM.EDIT);
       return;
     }
 
     // Avoid putting patch set in the URL unless a non-latest patch set is
     // selected.
     let patchNum;
-    if (!this.patchNumEquals(this._patchRange.patchNum,
-        this.computeLatestPatchNum(this._allPatchSets))) {
+    if (!patchNumEquals(this._patchRange.patchNum,
+        computeLatestPatchNum(this._allPatchSets))) {
       patchNum = this._patchRange.patchNum;
     }
     GerritNav.navigateToChange(this._change, patchNum, null, true);
@@ -2205,6 +2292,34 @@
   _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
     return disableDiffPrefs || !loggedIn;
   }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeLatestPatchNum(allPatchSets) {
+    return computeLatestPatchNum(allPatchSets);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _hasEditBasedOnCurrentPatchSet(allPatchSets) {
+    return hasEditBasedOnCurrentPatchSet(allPatchSets);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _hasEditPatchsetLoaded(patchRangeRecord) {
+    return hasEditPatchsetLoaded(patchRangeRecord);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeAllPatchSets(change) {
+    return computeAllPatchSets(change);
+  }
 }
 
 customElements.define(GrChangeView.is, GrChangeView);
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
deleted file mode 100644
index a1d4c52..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.js
+++ /dev/null
@@ -1,793 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .container:not(.loading) {
-      background-color: var(--background-color-tertiary);
-    }
-    .container.loading {
-      color: var(--deemphasized-text-color);
-      padding: var(--spacing-l);
-    }
-    .header {
-      align-items: center;
-      background-color: var(--background-color-primary);
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-      padding: var(--spacing-s) var(--spacing-l);
-      z-index: 99; /* Less than gr-overlay's backdrop */
-    }
-    .header.editMode {
-      background-color: var(--edit-mode-background-color);
-    }
-    .header .download {
-      margin-right: var(--spacing-l);
-    }
-    gr-change-status {
-      display: initial;
-      margin-left: var(--spacing-s);
-    }
-    gr-change-status:first-child {
-      margin-left: 0;
-    }
-    .headerTitle {
-      align-items: center;
-      display: flex;
-      flex: 1;
-    }
-    .headerSubject {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-      margin-left: var(--spacing-l);
-    }
-    .changeNumberColon {
-      color: transparent;
-    }
-    .changeCopyClipboard {
-      margin-left: var(--spacing-s);
-    }
-    #replyBtn {
-      margin-bottom: var(--spacing-l);
-    }
-    gr-change-star {
-      margin-left: var(--spacing-s);
-      --gr-change-star-size: var(--line-height-normal);
-    }
-    a.changeNumber {
-      margin-left: var(--spacing-xs);
-    }
-    gr-reply-dialog {
-      width: 60em;
-    }
-    .changeStatus {
-      text-transform: capitalize;
-    }
-    /* Strong specificity here is needed due to
-         https://github.com/Polymer/polymer/issues/2531 */
-    .container .changeInfo {
-      display: flex;
-      background-color: var(--background-color-secondary);
-    }
-    section {
-      background-color: var(--view-background-color);
-      box-shadow: var(--elevation-level-1);
-    }
-    .changeId {
-      color: var(--deemphasized-text-color);
-      font-family: var(--font-family);
-      margin-top: var(--spacing-l);
-    }
-    .changeMetadata {
-      /* Limit meta section to half of the screen at max */
-      max-width: 50%;
-    }
-    .commitMessage {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      margin-right: var(--spacing-l);
-      margin-bottom: var(--spacing-l);
-      /* Account for border and padding and rounding errors. */
-      max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
-    }
-    .commitMessage gr-linked-text {
-      word-break: break-word;
-    }
-    #commitMessageEditor {
-      /* Account for border and padding and rounding errors. */
-      min-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
-    }
-    .editCommitMessage {
-      margin-top: var(--spacing-l);
-
-      --gr-button: {
-        padding: 5px 0px;
-      }
-    }
-    .changeStatuses,
-    .commitActions,
-    .statusText {
-      align-items: center;
-      display: flex;
-    }
-    .changeStatuses {
-      flex-wrap: wrap;
-    }
-    .mainChangeInfo {
-      display: flex;
-      flex: 1;
-      flex-direction: column;
-      min-width: 0;
-    }
-    #commitAndRelated {
-      align-content: flex-start;
-      display: flex;
-      flex: 1;
-      overflow-x: hidden;
-    }
-    .relatedChanges {
-      flex: 1 1 auto;
-      overflow: hidden;
-      padding: var(--spacing-l) 0;
-    }
-    .mobile {
-      display: none;
-    }
-    .warning {
-      color: var(--error-text-color);
-    }
-    hr {
-      border: 0;
-      border-top: 1px solid var(--border-color);
-      height: 0;
-      margin-bottom: var(--spacing-l);
-    }
-    #relatedChanges.collapsed {
-      margin-bottom: var(--spacing-l);
-      max-height: var(--relation-chain-max-height, 2em);
-      overflow: hidden;
-    }
-    .commitContainer {
-      display: flex;
-      flex-direction: column;
-      flex-shrink: 0;
-      margin: var(--spacing-l) 0;
-      padding: 0 var(--spacing-l);
-    }
-    .collapseToggleContainer {
-      display: flex;
-      margin-bottom: 8px;
-    }
-    #relatedChangesToggle {
-      display: none;
-    }
-    #relatedChangesToggle.showToggle {
-      display: flex;
-    }
-    .collapseToggleContainer gr-button {
-      display: block;
-    }
-    #relatedChangesToggle {
-      margin-left: var(--spacing-l);
-      padding-top: var(--related-change-btn-top-padding, 0);
-    }
-    .showOnEdit {
-      display: none;
-    }
-    .scrollable {
-      overflow: auto;
-    }
-    .text {
-      white-space: pre;
-    }
-    gr-commit-info {
-      display: inline-block;
-    }
-    paper-tabs {
-      background-color: var(--background-color-tertiary);
-      margin-top: var(--spacing-m);
-      height: calc(var(--line-height-h3) + var(--spacing-m));
-      --paper-tabs-selection-bar-color: var(--link-color);
-    }
-    paper-tab {
-      box-sizing: border-box;
-      max-width: 12em;
-      --paper-tab-ink: var(--link-color);
-    }
-    gr-thread-list,
-    gr-messages-list,
-    gr-messages-list-experimental {
-      display: block;
-    }
-    gr-thread-list {
-      min-height: 250px;
-    }
-    #includedInOverlay {
-      width: 65em;
-    }
-    #uploadHelpOverlay {
-      width: 50em;
-    }
-    #metadata {
-      --metadata-horizontal-padding: var(--spacing-l);
-      padding-top: var(--spacing-l);
-      width: 100%;
-    }
-    /* NOTE: If you update this breakpoint, also update the
-      BREAKPOINT_RELATED_MED in the JS */
-    @media screen and (max-width: 75em) {
-      .relatedChanges {
-        padding: 0;
-      }
-      #relatedChanges {
-        padding-top: var(--spacing-l);
-      }
-      #commitAndRelated {
-        flex-direction: column;
-        flex-wrap: nowrap;
-      }
-      #commitMessageEditor {
-        min-width: 0;
-      }
-      .commitMessage {
-        margin-right: 0;
-      }
-      .mainChangeInfo {
-        padding-right: 0;
-      }
-    }
-    /* NOTE: If you update this breakpoint, also update the
-      BREAKPOINT_RELATED_SMALL in the JS */
-    @media screen and (max-width: 50em) {
-      .mobile {
-        display: block;
-      }
-      .header {
-        align-items: flex-start;
-        flex-direction: column;
-        flex: 1;
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-      gr-change-star {
-        vertical-align: middle;
-      }
-      .headerTitle {
-        flex-wrap: wrap;
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      .desktop {
-        display: none;
-      }
-      .reply {
-        display: block;
-        margin-right: 0;
-        /* px because don't have the same font size */
-        margin-bottom: 6px;
-      }
-      .changeInfo-column:not(:last-of-type) {
-        margin-right: 0;
-        padding-right: 0;
-      }
-      .changeInfo,
-      #commitAndRelated {
-        flex-direction: column;
-        flex-wrap: nowrap;
-      }
-      .commitContainer {
-        margin: 0;
-        padding: var(--spacing-l);
-      }
-      .changeMetadata {
-        margin-top: var(--spacing-xs);
-        max-width: none;
-      }
-      #metadata,
-      .mainChangeInfo {
-        padding: 0;
-      }
-      .commitActions {
-        display: block;
-        margin-top: var(--spacing-l);
-        width: 100%;
-      }
-      .commitMessage {
-        flex: initial;
-        margin: 0;
-      }
-      /* Change actions are the only thing thant need to remain visible due
-        to the fact that they may have the currently visible overlay open. */
-      #mainContent.overlayOpen .hideOnMobileOverlay {
-        display: none;
-      }
-      gr-reply-dialog {
-        height: 100vh;
-        min-width: initial;
-        width: 100vw;
-      }
-      #replyOverlay {
-        z-index: var(--reply-overlay-z-index);
-      }
-    }
-    .patch-set-dropdown {
-      margin: var(--spacing-m) 0 0 var(--spacing-m);
-    }
-    .show-robot-comments {
-      margin: var(--spacing-m);
-    }
-  </style>
-  <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
-  <!-- TODO(taoalpha): remove on-show-checks-table,
-    Gerrit should not have any thing too special for a plugin,
-    replace with a generic event: show-primary-tab. -->
-  <div
-    id="mainContent"
-    class="container"
-    on-show-checks-table="_setActivePrimaryTab"
-    hidden$="{{_loading}}"
-  >
-    <section class="changeInfoSection">
-      <div class$="[[_computeHeaderClass(_editMode)]]">
-        <div class="headerTitle">
-          <div class="changeStatuses">
-            <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
-              <gr-change-status
-                max-width="100"
-                status="[[status]]"
-              ></gr-change-status>
-            </template>
-          </div>
-          <div class="statusText">
-            <template
-              is="dom-if"
-              if="[[_computeShowCommitInfo(_changeStatus, _change.current_revision)]]"
-            >
-              <span class="text"> as </span>
-              <gr-commit-info
-                change="[[_change]]"
-                commit-info="[[_computeMergedCommitInfo(_change.current_revision, _change.revisions)]]"
-                server-config="[[_serverConfig]]"
-              ></gr-commit-info>
-            </template>
-          </div>
-          <gr-change-star
-            id="changeStar"
-            change="{{_change}}"
-            on-toggle-star="_handleToggleStar"
-            hidden$="[[!_loggedIn]]"
-          ></gr-change-star>
-
-          <a
-            class="changeNumber"
-            aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
-            href$="[[_computeChangeUrl(_change)]]"
-            >[[_change._number]]</a
-          >
-          <span class="changeNumberColon">:&nbsp;</span>
-          <span class="headerSubject">[[_change.subject]]</span>
-          <gr-copy-clipboard
-            class="changeCopyClipboard"
-            hide-input=""
-            text="[[_computeCopyTextForTitle(_change)]]"
-          >
-          </gr-copy-clipboard>
-        </div>
-        <!-- end headerTitle -->
-        <div class="commitActions" hidden$="[[!_loggedIn]]">
-          <gr-change-actions
-            id="actions"
-            change="[[_change]]"
-            disable-edit="[[disableEdit]]"
-            has-parent="[[hasParent]]"
-            actions="[[_change.actions]]"
-            revision-actions="{{_currentRevisionActions}}"
-            change-num="[[_changeNum]]"
-            change-status="[[_change.status]]"
-            commit-num="[[_commitInfo.commit]]"
-            latest-patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
-            commit-message="[[_latestCommitMessage]]"
-            edit-patchset-loaded="[[hasEditPatchsetLoaded(_patchRange.*)]]"
-            edit-mode="[[_editMode]]"
-            edit-based-on-current-patch-set="[[hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
-            private-by-default="[[_projectConfig.private_by_default]]"
-            on-reload-change="_handleReloadChange"
-            on-edit-tap="_handleEditTap"
-            on-stop-edit-tap="_handleStopEditTap"
-            on-download-tap="_handleOpenDownloadDialog"
-          ></gr-change-actions>
-        </div>
-        <!-- end commit actions -->
-      </div>
-      <!-- end header -->
-      <div class="changeInfo">
-        <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
-          <gr-change-metadata
-            id="metadata"
-            change="{{_change}}"
-            account="[[_account]]"
-            revision="[[_selectedRevision]]"
-            commit-info="[[_commitInfo]]"
-            server-config="[[_serverConfig]]"
-            parent-is-current="[[_parentIsCurrent]]"
-            on-show-reply-dialog="_handleShowReplyDialog"
-          >
-          </gr-change-metadata>
-        </div>
-        <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
-          <div id="commitAndRelated" class="hideOnMobileOverlay">
-            <div class="commitContainer">
-              <div>
-                <gr-button
-                  id="replyBtn"
-                  class="reply"
-                  title="[[createTitle(Shortcut.OPEN_REPLY_DIALOG,
-                        ShortcutSection.ACTIONS)]]"
-                  hidden$="[[!_loggedIn]]"
-                  primary=""
-                  disabled="[[_replyDisabled]]"
-                  on-click="_handleReplyTap"
-                  >[[_replyButtonLabel]]</gr-button
-                >
-              </div>
-              <div id="commitMessage" class="commitMessage">
-                <gr-editable-content
-                  id="commitMessageEditor"
-                  editing="[[_editingCommitMessage]]"
-                  content="{{_latestCommitMessage}}"
-                  storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]"
-                  remove-zero-width-space=""
-                  collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, _commitCollapsible)]]"
-                >
-                  <gr-linked-text
-                    pre=""
-                    content="[[_latestCommitMessage]]"
-                    config="[[_projectConfig.commentlinks]]"
-                    remove-zero-width-space=""
-                  ></gr-linked-text>
-                </gr-editable-content>
-                <gr-button
-                  link=""
-                  class="editCommitMessage"
-                  on-click="_handleEditCommitMessage"
-                  hidden$="[[_hideEditCommitMessage]]"
-                  >Edit</gr-button
-                >
-                <div
-                  class="changeId"
-                  hidden$="[[!_changeIdCommitMessageError]]"
-                >
-                  <hr />
-                  Change-Id:
-                  <span
-                    class$="[[_computeChangeIdClass(_changeIdCommitMessageError)]]"
-                    title$="[[_computeTitleAttributeWarning(_changeIdCommitMessageError)]]"
-                  >
-                    [[_change.change_id]]
-                  </span>
-                </div>
-              </div>
-              <div
-                id="commitCollapseToggle"
-                class="collapseToggleContainer"
-                hidden$="[[!_commitCollapsible]]"
-              >
-                <gr-button
-                  link=""
-                  id="commitCollapseToggleButton"
-                  class="collapseToggleButton"
-                  on-click="_toggleCommitCollapsed"
-                >
-                  [[_computeCollapseText(_commitCollapsed)]]
-                </gr-button>
-              </div>
-              <gr-endpoint-decorator name="commit-container">
-                <gr-endpoint-param name="change" value="[[_change]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param
-                  name="revision"
-                  value="[[_selectedRevision]]"
-                >
-                </gr-endpoint-param>
-              </gr-endpoint-decorator>
-            </div>
-            <div class="relatedChanges">
-              <gr-related-changes-list
-                id="relatedChanges"
-                class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed)]]"
-                change="[[_change]]"
-                mergeable="[[_mergeable]]"
-                has-parent="{{hasParent}}"
-                on-update="_updateRelatedChangeMaxHeight"
-                patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
-                on-new-section-loaded="_computeShowRelatedToggle"
-              >
-              </gr-related-changes-list>
-              <div id="relatedChangesToggle" class="collapseToggleContainer">
-                <gr-button
-                  link=""
-                  id="relatedChangesToggleButton"
-                  class="collapseToggleButton"
-                  on-click="_toggleRelatedChangesCollapsed"
-                >
-                  [[_computeCollapseText(_relatedChangesCollapsed)]]
-                </gr-button>
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
-    </section>
-
-    <paper-tabs id="primaryTabs" on-selected-changed="_setActivePrimaryTab">
-      <paper-tab data-name$="[[_constants.PrimaryTabs.FILES]]">Files</paper-tab>
-      <template
-        is="dom-repeat"
-        items="[[_dynamicTabHeaderEndpoints]]"
-        as="tabHeader"
-      >
-        <paper-tab data-name$="[[tabHeader]]">
-          <gr-endpoint-decorator name$="[[tabHeader]]">
-            <gr-endpoint-param name="change" value="[[_change]]">
-            </gr-endpoint-param>
-            <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
-            </gr-endpoint-param>
-          </gr-endpoint-decorator>
-        </paper-tab>
-      </template>
-      <paper-tab data-name$="[[_constants.PrimaryTabs.FINDINGS]]">
-        Findings
-      </paper-tab>
-    </paper-tabs>
-
-    <section class="patchInfo">
-      <div
-        hidden$="[[!_isTabActive(_constants.PrimaryTabs.FILES, _activeTabs)]]"
-      >
-        <gr-file-list-header
-          id="fileListHeader"
-          account="[[_account]]"
-          all-patch-sets="[[_allPatchSets]]"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          revision-info="[[_revisionInfo]]"
-          change-comments="[[_changeComments]]"
-          commit-info="[[_commitInfo]]"
-          change-url="[[_computeChangeUrl(_change)]]"
-          edit-mode="[[_editMode]]"
-          logged-in="[[_loggedIn]]"
-          server-config="[[_serverConfig]]"
-          shown-file-count="[[_shownFileCount]]"
-          diff-prefs="[[_diffPrefs]]"
-          diff-view-mode="{{viewState.diffMode}}"
-          patch-num="{{_patchRange.patchNum}}"
-          base-patch-num="{{_patchRange.basePatchNum}}"
-          files-expanded="[[_filesExpanded]]"
-          diff-prefs-disabled="[[_diffPrefsDisabled]]"
-          on-open-diff-prefs="_handleOpenDiffPrefs"
-          on-open-download-dialog="_handleOpenDownloadDialog"
-          on-open-upload-help-dialog="_handleOpenUploadHelpDialog"
-          on-open-included-in-dialog="_handleOpenIncludedInDialog"
-          on-expand-diffs="_expandAllDiffs"
-          on-collapse-diffs="_collapseAllDiffs"
-        >
-        </gr-file-list-header>
-        <gr-file-list
-          id="fileList"
-          class="hideOnMobileOverlay"
-          diff-prefs="{{_diffPrefs}}"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          patch-range="{{_patchRange}}"
-          change-comments="[[_changeComments]]"
-          drafts="[[_diffDrafts]]"
-          revisions="[[_change.revisions]]"
-          project-config="[[_projectConfig]]"
-          selected-index="{{viewState.selectedFileIndex}}"
-          diff-view-mode="[[viewState.diffMode]]"
-          edit-mode="[[_editMode]]"
-          num-files-shown="{{_numFilesShown}}"
-          files-expanded="{{_filesExpanded}}"
-          file-list-increment="{{_numFilesShown}}"
-          on-files-shown-changed="_setShownFiles"
-          on-file-action-tap="_handleFileActionTap"
-          on-reload-drafts="_reloadDraftsWithCallback"
-        >
-        </gr-file-list>
-      </div>
-
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_constants.PrimaryTabs.FINDINGS, _activeTabs)]]"
-      >
-        <gr-dropdown-list
-          class="patch-set-dropdown"
-          items="[[_robotCommentsPatchSetDropdownItems]]"
-          on-value-change="_handleRobotCommentPatchSetChanged"
-          value="[[_currentRobotCommentsPatchSet]]"
-        >
-        </gr-dropdown-list>
-        <gr-thread-list
-          threads="[[_robotCommentThreads]]"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          logged-in="[[_loggedIn]]"
-          hide-toggle-buttons
-          empty-thread-msg="[[_messages.NO_ROBOT_COMMENTS_THREADS_MSG]]"
-          on-thread-list-modified="_handleReloadDiffComments"
-        >
-        </gr-thread-list>
-        <template is="dom-if" if="[[_showRobotCommentsButton]]">
-          <gr-button
-            class="show-robot-comments"
-            on-click="_toggleShowRobotComments"
-          >
-            [[_computeShowText(_showAllRobotComments)]]
-          </gr-button>
-        </template>
-      </template>
-
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_selectedTabPluginHeader, _activeTabs)]]"
-      >
-        <gr-endpoint-decorator name$="[[_selectedTabPluginEndpoint]]">
-          <gr-endpoint-param name="change" value="[[_change]]">
-          </gr-endpoint-param>
-          <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
-          </gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </template>
-    </section>
-
-    <gr-endpoint-decorator name="change-view-integration">
-      <gr-endpoint-param name="change" value="[[_change]]"> </gr-endpoint-param>
-      <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
-      </gr-endpoint-param>
-    </gr-endpoint-decorator>
-
-    <paper-tabs id="secondaryTabs" on-selected-changed="_setActiveSecondaryTab">
-      <paper-tab
-        data-name$="[[_constants.SecondaryTabs.CHANGE_LOG]]"
-        class="changeLog"
-      >
-        Change Log
-      </paper-tab>
-      <paper-tab
-        data-name$="[[_constants.SecondaryTabs.COMMENT_THREADS]]"
-        class="commentThreads"
-      >
-        <gr-tooltip-content
-          has-tooltip=""
-          title$="[[_computeTotalCommentCounts(_change.unresolved_comment_count, _changeComments)]]"
-        >
-          <span>Comment Threads</span></gr-tooltip-content
-        >
-      </paper-tab>
-    </paper-tabs>
-    <section class="changeLog">
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_constants.SecondaryTabs.CHANGE_LOG, _activeTabs)]]"
-      >
-        <template is="dom-if" if="[[!_isChangeLogExperimentEnabled()]]">
-          <gr-messages-list
-            class="hideOnMobileOverlay"
-            change-num="[[_changeNum]]"
-            labels="[[_change.labels]]"
-            messages="[[_change.messages]]"
-            reviewer-updates="[[_change.reviewer_updates]]"
-            change-comments="[[_changeComments]]"
-            project-name="[[_change.project]]"
-            show-reply-buttons="[[_loggedIn]]"
-            on-message-anchor-tap="_handleMessageAnchorTap"
-            on-reply="_handleMessageReply"
-          ></gr-messages-list>
-        </template>
-        <template is="dom-if" if="[[_isChangeLogExperimentEnabled()]]">
-          <gr-messages-list-experimental
-            class="hideOnMobileOverlay"
-            change-num="[[_changeNum]]"
-            labels="[[_change.labels]]"
-            messages="[[_change.messages]]"
-            reviewer-updates="[[_change.reviewer_updates]]"
-            change-comments="[[_changeComments]]"
-            project-name="[[_change.project]]"
-            show-reply-buttons="[[_loggedIn]]"
-            on-message-anchor-tap="_handleMessageAnchorTap"
-            on-reply="_handleMessageReply"
-          ></gr-messages-list-experimental>
-        </template>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_constants.SecondaryTabs.COMMENT_THREADS, _activeTabs)]]"
-      >
-        <gr-thread-list
-          threads="[[_commentThreads]]"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          logged-in="[[_loggedIn]]"
-          only-show-robot-comments-with-human-reply=""
-          on-thread-list-modified="_handleReloadDiffComments"
-        ></gr-thread-list>
-      </template>
-    </section>
-  </div>
-
-  <gr-apply-fix-dialog
-    id="applyFixDialog"
-    prefs="[[_diffPrefs]]"
-    change="[[_change]]"
-    change-num="[[_changeNum]]"
-  ></gr-apply-fix-dialog>
-  <gr-overlay id="downloadOverlay" with-backdrop="">
-    <gr-download-dialog
-      id="downloadDialog"
-      change="[[_change]]"
-      patch-num="[[_patchRange.patchNum]]"
-      config="[[_serverConfig.download]]"
-      on-close="_handleDownloadDialogClose"
-    ></gr-download-dialog>
-  </gr-overlay>
-  <gr-overlay id="uploadHelpOverlay" with-backdrop="">
-    <gr-upload-help-dialog
-      revision="[[_currentRevision]]"
-      target-branch="[[_change.branch]]"
-      on-close="_handleCloseUploadHelpDialog"
-    ></gr-upload-help-dialog>
-  </gr-overlay>
-  <gr-overlay id="includedInOverlay" with-backdrop="">
-    <gr-included-in-dialog
-      id="includedInDialog"
-      change-num="[[_changeNum]]"
-      on-close="_handleIncludedInDialogClose"
-    ></gr-included-in-dialog>
-  </gr-overlay>
-  <gr-overlay
-    id="replyOverlay"
-    class="scrollable"
-    no-cancel-on-outside-click=""
-    no-cancel-on-esc-key=""
-    with-backdrop=""
-  >
-    <gr-reply-dialog
-      id="replyDialog"
-      change="{{_change}}"
-      patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
-      permitted-labels="[[_change.permitted_labels]]"
-      draft-comment-threads="[[_draftCommentThreads]]"
-      project-config="[[_projectConfig]]"
-      can-be-started="[[_canStartReview]]"
-      on-send="_handleReplySent"
-      on-cancel="_handleReplyCancel"
-      on-autogrow="_handleReplyAutogrow"
-      on-send-disabled-changed="_resetReplyOverlayFocusStops"
-      hidden$="[[!_loggedIn]]"
-    >
-    </gr-reply-dialog>
-  </gr-overlay>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-comment-api id="commentAPI"></gr-comment-api>
-  <gr-reporting id="reporting"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
new file mode 100644
index 0000000..6352238
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -0,0 +1,775 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .container:not(.loading) {
+      background-color: var(--background-color-tertiary);
+    }
+    .container.loading {
+      color: var(--deemphasized-text-color);
+      padding: var(--spacing-l);
+    }
+    .header {
+      align-items: center;
+      background-color: var(--background-color-primary);
+      border-bottom: 1px solid var(--border-color);
+      display: flex;
+      padding: var(--spacing-s) var(--spacing-l);
+      z-index: 99; /* Less than gr-overlay's backdrop */
+    }
+    .header.editMode {
+      background-color: var(--edit-mode-background-color);
+    }
+    .header .download {
+      margin-right: var(--spacing-l);
+    }
+    gr-change-status {
+      display: initial;
+      margin-left: var(--spacing-s);
+    }
+    gr-change-status:first-child {
+      margin-left: 0;
+    }
+    .headerTitle {
+      align-items: center;
+      display: flex;
+      flex: 1;
+    }
+    .headerSubject {
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h3);
+      font-weight: var(--font-weight-h3);
+      line-height: var(--line-height-h3);
+      margin-left: var(--spacing-l);
+    }
+    .changeNumberColon {
+      color: transparent;
+    }
+    .changeCopyClipboard {
+      margin-left: var(--spacing-s);
+    }
+    #replyBtn {
+      margin-bottom: var(--spacing-l);
+    }
+    gr-change-star {
+      margin-left: var(--spacing-s);
+      --gr-change-star-size: var(--line-height-normal);
+    }
+    a.changeNumber {
+      margin-left: var(--spacing-xs);
+    }
+    gr-reply-dialog {
+      width: 60em;
+    }
+    .changeStatus {
+      text-transform: capitalize;
+    }
+    /* Strong specificity here is needed due to
+         https://github.com/Polymer/polymer/issues/2531 */
+    .container .changeInfo {
+      display: flex;
+      background-color: var(--background-color-secondary);
+    }
+    section {
+      background-color: var(--view-background-color);
+      box-shadow: var(--elevation-level-1);
+    }
+    .changeId {
+      color: var(--deemphasized-text-color);
+      font-family: var(--font-family);
+      margin-top: var(--spacing-l);
+    }
+    .changeMetadata {
+      /* Limit meta section to half of the screen at max */
+      max-width: 50%;
+    }
+    .commitMessage {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      margin-right: var(--spacing-l);
+      margin-bottom: var(--spacing-l);
+      /* Account for border and padding and rounding errors. */
+      max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
+    }
+    .commitMessage gr-linked-text {
+      word-break: break-word;
+    }
+    #commitMessageEditor {
+      /* Account for border and padding and rounding errors. */
+      min-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
+    }
+    .editCommitMessage {
+      margin-top: var(--spacing-l);
+
+      --gr-button: {
+        padding: 5px 0px;
+      }
+    }
+    .changeStatuses,
+    .commitActions,
+    .statusText {
+      align-items: center;
+      display: flex;
+    }
+    .changeStatuses {
+      flex-wrap: wrap;
+    }
+    .mainChangeInfo {
+      display: flex;
+      flex: 1;
+      flex-direction: column;
+      min-width: 0;
+    }
+    #commitAndRelated {
+      align-content: flex-start;
+      display: flex;
+      flex: 1;
+      overflow-x: hidden;
+    }
+    .relatedChanges {
+      flex: 1 1 auto;
+      overflow: hidden;
+      padding: var(--spacing-l) 0;
+    }
+    .mobile {
+      display: none;
+    }
+    .warning {
+      color: var(--error-text-color);
+    }
+    hr {
+      border: 0;
+      border-top: 1px solid var(--border-color);
+      height: 0;
+      margin-bottom: var(--spacing-l);
+    }
+    #relatedChanges.collapsed {
+      margin-bottom: var(--spacing-l);
+      max-height: var(--relation-chain-max-height, 2em);
+      overflow: hidden;
+    }
+    .commitContainer {
+      display: flex;
+      flex-direction: column;
+      flex-shrink: 0;
+      margin: var(--spacing-l) 0;
+      padding: 0 var(--spacing-l);
+    }
+    .collapseToggleContainer {
+      display: flex;
+      margin-bottom: 8px;
+    }
+    #relatedChangesToggle {
+      display: none;
+    }
+    #relatedChangesToggle.showToggle {
+      display: flex;
+    }
+    .collapseToggleContainer gr-button {
+      display: block;
+    }
+    #relatedChangesToggle {
+      margin-left: var(--spacing-l);
+      padding-top: var(--related-change-btn-top-padding, 0);
+    }
+    .showOnEdit {
+      display: none;
+    }
+    .scrollable {
+      overflow: auto;
+    }
+    .text {
+      white-space: pre;
+    }
+    gr-commit-info {
+      display: inline-block;
+    }
+    paper-tabs {
+      background-color: var(--background-color-tertiary);
+      margin-top: var(--spacing-m);
+      height: calc(var(--line-height-h3) + var(--spacing-m));
+      --paper-tabs-selection-bar-color: var(--link-color);
+    }
+    paper-tab {
+      box-sizing: border-box;
+      max-width: 12em;
+      --paper-tab-ink: var(--link-color);
+    }
+    gr-thread-list,
+    gr-messages-list {
+      display: block;
+    }
+    gr-thread-list {
+      min-height: 250px;
+    }
+    #includedInOverlay {
+      width: 65em;
+    }
+    #uploadHelpOverlay {
+      width: 50em;
+    }
+    #metadata {
+      --metadata-horizontal-padding: var(--spacing-l);
+      padding-top: var(--spacing-l);
+      width: 100%;
+    }
+    /* NOTE: If you update this breakpoint, also update the
+      BREAKPOINT_RELATED_MED in the JS */
+    @media screen and (max-width: 75em) {
+      .relatedChanges {
+        padding: 0;
+      }
+      #relatedChanges {
+        padding-top: var(--spacing-l);
+      }
+      #commitAndRelated {
+        flex-direction: column;
+        flex-wrap: nowrap;
+      }
+      #commitMessageEditor {
+        min-width: 0;
+      }
+      .commitMessage {
+        margin-right: 0;
+      }
+      .mainChangeInfo {
+        padding-right: 0;
+      }
+    }
+    /* NOTE: If you update this breakpoint, also update the
+      BREAKPOINT_RELATED_SMALL in the JS */
+    @media screen and (max-width: 50em) {
+      .mobile {
+        display: block;
+      }
+      .header {
+        align-items: flex-start;
+        flex-direction: column;
+        flex: 1;
+        padding: var(--spacing-s) var(--spacing-l);
+      }
+      gr-change-star {
+        vertical-align: middle;
+      }
+      .headerTitle {
+        flex-wrap: wrap;
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+      }
+      .desktop {
+        display: none;
+      }
+      .reply {
+        display: block;
+        margin-right: 0;
+        /* px because don't have the same font size */
+        margin-bottom: 6px;
+      }
+      .changeInfo-column:not(:last-of-type) {
+        margin-right: 0;
+        padding-right: 0;
+      }
+      .changeInfo,
+      #commitAndRelated {
+        flex-direction: column;
+        flex-wrap: nowrap;
+      }
+      .commitContainer {
+        margin: 0;
+        padding: var(--spacing-l);
+      }
+      .changeMetadata {
+        margin-top: var(--spacing-xs);
+        max-width: none;
+      }
+      #metadata,
+      .mainChangeInfo {
+        padding: 0;
+      }
+      .commitActions {
+        display: block;
+        margin-top: var(--spacing-l);
+        width: 100%;
+      }
+      .commitMessage {
+        flex: initial;
+        margin: 0;
+      }
+      /* Change actions are the only thing thant need to remain visible due
+        to the fact that they may have the currently visible overlay open. */
+      #mainContent.overlayOpen .hideOnMobileOverlay {
+        display: none;
+      }
+      gr-reply-dialog {
+        height: 100vh;
+        min-width: initial;
+        width: 100vw;
+      }
+      #replyOverlay {
+        z-index: var(--reply-overlay-z-index);
+      }
+    }
+    .patch-set-dropdown {
+      margin: var(--spacing-m) 0 0 var(--spacing-m);
+    }
+    .show-robot-comments {
+      margin: var(--spacing-m);
+    }
+  </style>
+  <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
+  <!-- TODO(taoalpha): remove on-show-checks-table,
+    Gerrit should not have any thing too special for a plugin,
+    replace with a generic event: show-primary-tab. -->
+  <div
+    id="mainContent"
+    class="container"
+    on-show-checks-table="_setActivePrimaryTab"
+    hidden$="{{_loading}}"
+  >
+    <section class="changeInfoSection">
+      <div class$="[[_computeHeaderClass(_editMode)]]">
+        <div class="headerTitle">
+          <div class="changeStatuses">
+            <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
+              <gr-change-status
+                max-width="100"
+                status="[[status]]"
+              ></gr-change-status>
+            </template>
+          </div>
+          <div class="statusText">
+            <template
+              is="dom-if"
+              if="[[_computeShowCommitInfo(_changeStatus, _change.current_revision)]]"
+            >
+              <span class="text"> as </span>
+              <gr-commit-info
+                change="[[_change]]"
+                commit-info="[[_computeMergedCommitInfo(_change.current_revision, _change.revisions)]]"
+                server-config="[[_serverConfig]]"
+              ></gr-commit-info>
+            </template>
+          </div>
+          <gr-change-star
+            id="changeStar"
+            change="{{_change}}"
+            on-toggle-star="_handleToggleStar"
+            hidden$="[[!_loggedIn]]"
+          ></gr-change-star>
+
+          <a
+            class="changeNumber"
+            aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
+            href$="[[_computeChangeUrl(_change)]]"
+            >[[_change._number]]</a
+          >
+          <span class="changeNumberColon">:&nbsp;</span>
+          <span class="headerSubject">[[_change.subject]]</span>
+          <gr-copy-clipboard
+            class="changeCopyClipboard"
+            hide-input=""
+            text="[[_computeCopyTextForTitle(_change)]]"
+          >
+          </gr-copy-clipboard>
+        </div>
+        <!-- end headerTitle -->
+        <div class="commitActions" hidden$="[[!_loggedIn]]">
+          <gr-change-actions
+            id="actions"
+            change="[[_change]]"
+            disable-edit="[[disableEdit]]"
+            has-parent="[[hasParent]]"
+            actions="[[_change.actions]]"
+            revision-actions="{{_currentRevisionActions}}"
+            change-num="[[_changeNum]]"
+            change-status="[[_change.status]]"
+            commit-num="[[_commitInfo.commit]]"
+            latest-patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+            commit-message="[[_latestCommitMessage]]"
+            edit-patchset-loaded="[[_hasEditPatchsetLoaded(_patchRange.*)]]"
+            edit-mode="[[_editMode]]"
+            edit-based-on-current-patch-set="[[_hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
+            private-by-default="[[_projectConfig.private_by_default]]"
+            on-edit-tap="_handleEditTap"
+            on-stop-edit-tap="_handleStopEditTap"
+            on-download-tap="_handleOpenDownloadDialog"
+          ></gr-change-actions>
+        </div>
+        <!-- end commit actions -->
+      </div>
+      <!-- end header -->
+      <div class="changeInfo">
+        <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
+          <gr-change-metadata
+            id="metadata"
+            change="{{_change}}"
+            account="[[_account]]"
+            revision="[[_selectedRevision]]"
+            commit-info="[[_commitInfo]]"
+            server-config="[[_serverConfig]]"
+            parent-is-current="[[_parentIsCurrent]]"
+            on-show-reply-dialog="_handleShowReplyDialog"
+          >
+          </gr-change-metadata>
+        </div>
+        <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
+          <div id="commitAndRelated" class="hideOnMobileOverlay">
+            <div class="commitContainer">
+              <div>
+                <gr-button
+                  id="replyBtn"
+                  class="reply"
+                  title="[[createTitle(Shortcut.OPEN_REPLY_DIALOG,
+                        ShortcutSection.ACTIONS)]]"
+                  hidden$="[[!_loggedIn]]"
+                  primary=""
+                  disabled="[[_replyDisabled]]"
+                  on-click="_handleReplyTap"
+                  >[[_replyButtonLabel]]</gr-button
+                >
+              </div>
+              <div id="commitMessage" class="commitMessage">
+                <gr-editable-content
+                  id="commitMessageEditor"
+                  editing="[[_editingCommitMessage]]"
+                  content="{{_latestCommitMessage}}"
+                  storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]"
+                  remove-zero-width-space=""
+                  collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, _commitCollapsible)]]"
+                >
+                  <gr-linked-text
+                    pre=""
+                    content="[[_latestCommitMessage]]"
+                    config="[[_projectConfig.commentlinks]]"
+                    remove-zero-width-space=""
+                  ></gr-linked-text>
+                </gr-editable-content>
+                <gr-button
+                  link=""
+                  class="editCommitMessage"
+                  on-click="_handleEditCommitMessage"
+                  hidden$="[[_hideEditCommitMessage]]"
+                  >Edit</gr-button
+                >
+                <div
+                  class="changeId"
+                  hidden$="[[!_changeIdCommitMessageError]]"
+                >
+                  <hr />
+                  Change-Id:
+                  <span
+                    class$="[[_computeChangeIdClass(_changeIdCommitMessageError)]]"
+                    title$="[[_computeTitleAttributeWarning(_changeIdCommitMessageError)]]"
+                  >
+                    [[_change.change_id]]
+                  </span>
+                </div>
+              </div>
+              <div
+                id="commitCollapseToggle"
+                class="collapseToggleContainer"
+                hidden$="[[!_commitCollapsible]]"
+              >
+                <gr-button
+                  link=""
+                  id="commitCollapseToggleButton"
+                  class="collapseToggleButton"
+                  on-click="_toggleCommitCollapsed"
+                >
+                  [[_computeCollapseText(_commitCollapsed)]]
+                </gr-button>
+              </div>
+              <gr-endpoint-decorator name="commit-container">
+                <gr-endpoint-param name="change" value="[[_change]]">
+                </gr-endpoint-param>
+                <gr-endpoint-param
+                  name="revision"
+                  value="[[_selectedRevision]]"
+                >
+                </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </div>
+            <div class="relatedChanges">
+              <gr-related-changes-list
+                id="relatedChanges"
+                class$="[[_computeRelatedChangesClass(_relatedChangesCollapsed)]]"
+                change="[[_change]]"
+                mergeable="[[_mergeable]]"
+                has-parent="{{hasParent}}"
+                on-update="_updateRelatedChangeMaxHeight"
+                patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+                on-new-section-loaded="_computeShowRelatedToggle"
+              >
+              </gr-related-changes-list>
+              <div id="relatedChangesToggle" class="collapseToggleContainer">
+                <gr-button
+                  link=""
+                  id="relatedChangesToggleButton"
+                  class="collapseToggleButton"
+                  on-click="_toggleRelatedChangesCollapsed"
+                >
+                  [[_computeCollapseText(_relatedChangesCollapsed)]]
+                </gr-button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <paper-tabs id="primaryTabs" on-selected-changed="_setActivePrimaryTab">
+      <paper-tab data-name$="[[_constants.PrimaryTab.FILES]]">Files</paper-tab>
+      <paper-tab
+        data-name$="[[_constants.PrimaryTab.COMMENT_THREADS]]"
+        class="commentThreads"
+      >
+        <gr-tooltip-content
+          has-tooltip=""
+          title$="[[_computeTotalCommentCounts(_change.unresolved_comment_count, _changeComments)]]"
+        >
+          <span>Comments</span></gr-tooltip-content
+        >
+      </paper-tab>
+      <template
+        is="dom-repeat"
+        items="[[_dynamicTabHeaderEndpoints]]"
+        as="tabHeader"
+      >
+        <paper-tab data-name$="[[tabHeader]]">
+          <gr-endpoint-decorator name$="[[tabHeader]]">
+            <gr-endpoint-param name="change" value="[[_change]]">
+            </gr-endpoint-param>
+            <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
+            </gr-endpoint-param>
+          </gr-endpoint-decorator>
+        </paper-tab>
+      </template>
+      <paper-tab data-name$="[[_constants.PrimaryTab.FINDINGS]]">
+        Findings
+      </paper-tab>
+    </paper-tabs>
+
+    <section class="patchInfo">
+      <div
+        hidden$="[[!_isTabActive(_constants.PrimaryTab.FILES, _activeTabs)]]"
+      >
+        <gr-file-list-header
+          id="fileListHeader"
+          account="[[_account]]"
+          all-patch-sets="[[_allPatchSets]]"
+          change="[[_change]]"
+          change-num="[[_changeNum]]"
+          revision-info="[[_revisionInfo]]"
+          change-comments="[[_changeComments]]"
+          commit-info="[[_commitInfo]]"
+          change-url="[[_computeChangeUrl(_change)]]"
+          edit-mode="[[_editMode]]"
+          logged-in="[[_loggedIn]]"
+          server-config="[[_serverConfig]]"
+          shown-file-count="[[_shownFileCount]]"
+          diff-prefs="[[_diffPrefs]]"
+          diff-view-mode="{{viewState.diffMode}}"
+          patch-num="{{_patchRange.patchNum}}"
+          base-patch-num="{{_patchRange.basePatchNum}}"
+          files-expanded="[[_filesExpanded]]"
+          diff-prefs-disabled="[[_diffPrefsDisabled]]"
+          on-open-diff-prefs="_handleOpenDiffPrefs"
+          on-open-download-dialog="_handleOpenDownloadDialog"
+          on-open-upload-help-dialog="_handleOpenUploadHelpDialog"
+          on-open-included-in-dialog="_handleOpenIncludedInDialog"
+          on-expand-diffs="_expandAllDiffs"
+          on-collapse-diffs="_collapseAllDiffs"
+        >
+        </gr-file-list-header>
+        <gr-file-list
+          id="fileList"
+          class="hideOnMobileOverlay"
+          diff-prefs="{{_diffPrefs}}"
+          change="[[_change]]"
+          change-num="[[_changeNum]]"
+          patch-range="{{_patchRange}}"
+          change-comments="[[_changeComments]]"
+          drafts="[[_diffDrafts]]"
+          revisions="[[_change.revisions]]"
+          project-config="[[_projectConfig]]"
+          selected-index="{{viewState.selectedFileIndex}}"
+          diff-view-mode="[[viewState.diffMode]]"
+          edit-mode="[[_editMode]]"
+          num-files-shown="{{_numFilesShown}}"
+          files-expanded="{{_filesExpanded}}"
+          file-list-increment="{{_numFilesShown}}"
+          on-files-shown-changed="_setShownFiles"
+          on-file-action-tap="_handleFileActionTap"
+          on-reload-drafts="_reloadDraftsWithCallback"
+        >
+        </gr-file-list>
+      </div>
+      <template
+        is="dom-if"
+        if="[[_isTabActive(_constants.PrimaryTab.COMMENT_THREADS, _activeTabs)]]"
+      >
+        <gr-thread-list
+          threads="[[_commentThreads]]"
+          change="[[_change]]"
+          change-num="[[_changeNum]]"
+          logged-in="[[_loggedIn]]"
+          only-show-robot-comments-with-human-reply=""
+          on-thread-list-modified="_handleReloadDiffComments"
+        ></gr-thread-list>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_isTabActive(_constants.PrimaryTab.FINDINGS, _activeTabs)]]"
+      >
+        <gr-dropdown-list
+          class="patch-set-dropdown"
+          items="[[_robotCommentsPatchSetDropdownItems]]"
+          on-value-change="_handleRobotCommentPatchSetChanged"
+          value="[[_currentRobotCommentsPatchSet]]"
+        >
+        </gr-dropdown-list>
+        <gr-thread-list
+          threads="[[_robotCommentThreads]]"
+          change="[[_change]]"
+          change-num="[[_changeNum]]"
+          logged-in="[[_loggedIn]]"
+          hide-toggle-buttons
+          empty-thread-msg="[[_messages.NO_ROBOT_COMMENTS_THREADS_MSG]]"
+          on-thread-list-modified="_handleReloadDiffComments"
+        >
+        </gr-thread-list>
+        <template is="dom-if" if="[[_showRobotCommentsButton]]">
+          <gr-button
+            class="show-robot-comments"
+            on-click="_toggleShowRobotComments"
+          >
+            [[_computeShowText(_showAllRobotComments)]]
+          </gr-button>
+        </template>
+      </template>
+
+      <template
+        is="dom-if"
+        if="[[_isTabActive(_selectedTabPluginHeader, _activeTabs)]]"
+      >
+        <gr-endpoint-decorator name$="[[_selectedTabPluginEndpoint]]">
+          <gr-endpoint-param name="change" value="[[_change]]">
+          </gr-endpoint-param>
+          <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </template>
+    </section>
+
+    <gr-endpoint-decorator name="change-view-integration">
+      <gr-endpoint-param name="change" value="[[_change]]"> </gr-endpoint-param>
+      <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
+      </gr-endpoint-param>
+    </gr-endpoint-decorator>
+
+    <paper-tabs id="secondaryTabs" on-selected-changed="_setActiveSecondaryTab">
+      <paper-tab
+        data-name$="[[_constants.SecondaryTab.CHANGE_LOG]]"
+        class="changeLog"
+      >
+        Change Log
+      </paper-tab>
+    </paper-tabs>
+    <section class="changeLog">
+      <template
+        is="dom-if"
+        if="[[_isTabActive(_constants.SecondaryTab.CHANGE_LOG, _activeTabs)]]"
+      >
+        <gr-messages-list
+          class="hideOnMobileOverlay"
+          change="[[_change]]"
+          change-num="[[_changeNum]]"
+          labels="[[_change.labels]]"
+          messages="[[_change.messages]]"
+          reviewer-updates="[[_change.reviewer_updates]]"
+          change-comments="[[_changeComments]]"
+          project-name="[[_change.project]]"
+          show-reply-buttons="[[_loggedIn]]"
+          on-message-anchor-tap="_handleMessageAnchorTap"
+          on-reply="_handleMessageReply"
+        ></gr-messages-list>
+      </template>
+    </section>
+  </div>
+
+  <gr-apply-fix-dialog
+    id="applyFixDialog"
+    prefs="[[_diffPrefs]]"
+    change="[[_change]]"
+    change-num="[[_changeNum]]"
+  ></gr-apply-fix-dialog>
+  <gr-overlay id="downloadOverlay" with-backdrop="">
+    <gr-download-dialog
+      id="downloadDialog"
+      change="[[_change]]"
+      patch-num="[[_patchRange.patchNum]]"
+      config="[[_serverConfig.download]]"
+      on-close="_handleDownloadDialogClose"
+    ></gr-download-dialog>
+  </gr-overlay>
+  <gr-overlay id="uploadHelpOverlay" with-backdrop="">
+    <gr-upload-help-dialog
+      revision="[[_currentRevision]]"
+      target-branch="[[_change.branch]]"
+      on-close="_handleCloseUploadHelpDialog"
+    ></gr-upload-help-dialog>
+  </gr-overlay>
+  <gr-overlay id="includedInOverlay" with-backdrop="">
+    <gr-included-in-dialog
+      id="includedInDialog"
+      change-num="[[_changeNum]]"
+      on-close="_handleIncludedInDialogClose"
+    ></gr-included-in-dialog>
+  </gr-overlay>
+  <gr-overlay
+    id="replyOverlay"
+    class="scrollable"
+    no-cancel-on-outside-click=""
+    no-cancel-on-esc-key=""
+    with-backdrop=""
+  >
+    <gr-reply-dialog
+      id="replyDialog"
+      change="{{_change}}"
+      patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+      permitted-labels="[[_change.permitted_labels]]"
+      draft-comment-threads="[[_draftCommentThreads]]"
+      project-config="[[_projectConfig]]"
+      server-config="[[_serverConfig]]"
+      can-be-started="[[_canStartReview]]"
+      on-send="_handleReplySent"
+      on-cancel="_handleReplyCancel"
+      on-autogrow="_handleReplyAutogrow"
+      on-send-disabled-changed="_resetReplyOverlayFocusStops"
+      hidden$="[[!_loggedIn]]"
+    >
+    </gr-reply-dialog>
+  </gr-overlay>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-comment-api id="commentAPI"></gr-comment-api>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
deleted file mode 100644
index 913a914..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ /dev/null
@@ -1,2354 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-change-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script src="/node_modules/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-view></gr-change-view>
-  </template>
-</test-fixture>
-
-<test-fixture id="blank">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../edit/gr-edit-constants.js';
-import './gr-change-view.js';
-import {PrimaryTabs, SecondaryTabs} from '../../../constants/constants.js';
-
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {GrEditConstants} from '../../edit/gr-edit-constants.js';
-import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {util} from '../../../scripts/util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-change-view tests', () => {
-  const kb = KeyboardShortcutBinder;
-  kb.bindShortcut(kb.Shortcut.SEND_REPLY, 'ctrl+enter');
-  kb.bindShortcut(kb.Shortcut.REFRESH_CHANGE, 'shift+r');
-  kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
-  kb.bindShortcut(kb.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
-  kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
-  kb.bindShortcut(kb.Shortcut.TOGGLE_CHANGE_STAR, 's');
-  kb.bindShortcut(kb.Shortcut.UP_TO_DASHBOARD, 'u');
-  kb.bindShortcut(kb.Shortcut.EXPAND_ALL_MESSAGES, 'x');
-  kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-  kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
-  kb.bindShortcut(kb.Shortcut.EDIT_TOPIC, 't');
-
-  let element;
-  let sandbox;
-  let navigateToChangeStub;
-  const TEST_SCROLL_TOP_PX = 100;
-
-  const ROBOT_COMMENTS_LIMIT = 10;
-
-  const THREADS = [
-    {
-      comments: [
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 2,
-          robot_id: 'rb1',
-          id: 'ecf9fa_fe1a5f62',
-          line: 5,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'test',
-          unresolved: true,
-        },
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 4,
-          id: 'ecf0b9fa_fe1a5f62',
-          line: 5,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'test',
-          unresolved: true,
-        },
-        {
-          id: '503008e2_0ab203ee',
-          path: '/COMMIT_MSG',
-          line: 5,
-          in_reply_to: 'ecf0b9fa_fe1a5f62',
-          updated: '2018-02-13 22:48:48.018000000',
-          message: 'draft',
-          unresolved: false,
-          __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
-          patch_set: '2',
-        },
-      ],
-      patchNum: 4,
-      path: '/COMMIT_MSG',
-      line: 5,
-      rootId: 'ecf0b9fa_fe1a5f62',
-      start_datetime: '2018-02-08 18:49:18.000000000',
-    },
-    {
-      comments: [
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 3,
-          id: 'ecf0b9fa_fe5f62',
-          robot_id: 'rb2',
-          line: 5,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'test',
-          unresolved: true,
-        },
-        {
-          __path: 'test.txt',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 3,
-          id: '09a9fb0a_1484e6cf',
-          side: 'PARENT',
-          updated: '2018-02-13 22:47:19.000000000',
-          message: 'Some comment on another patchset.',
-          unresolved: false,
-        },
-      ],
-      patchNum: 3,
-      path: 'test.txt',
-      rootId: '09a9fb0a_1484e6cf',
-      start_datetime: '2018-02-13 22:47:19.000000000',
-      commentSide: 'PARENT',
-    },
-    {
-      comments: [
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 2,
-          id: '8caddf38_44770ec1',
-          line: 4,
-          updated: '2018-02-13 22:48:40.000000000',
-          message: 'Another unresolved comment',
-          unresolved: true,
-        },
-      ],
-      patchNum: 2,
-      path: '/COMMIT_MSG',
-      line: 4,
-      rootId: '8caddf38_44770ec1',
-      start_datetime: '2018-02-13 22:48:40.000000000',
-    },
-    {
-      comments: [
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 2,
-          id: 'scaddf38_44770ec1',
-          line: 4,
-          updated: '2018-02-14 22:48:40.000000000',
-          message: 'Yet another unresolved comment',
-          unresolved: true,
-        },
-      ],
-      patchNum: 2,
-      path: '/COMMIT_MSG',
-      line: 4,
-      rootId: 'scaddf38_44770ec1',
-      start_datetime: '2018-02-14 22:48:40.000000000',
-    },
-    {
-      comments: [
-        {
-          id: 'zcf0b9fa_fe1a5f62',
-          path: '/COMMIT_MSG',
-          line: 6,
-          updated: '2018-02-15 22:48:48.018000000',
-          message: 'resolved draft',
-          unresolved: false,
-          __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
-          patch_set: '2',
-        },
-      ],
-      patchNum: 4,
-      path: '/COMMIT_MSG',
-      line: 6,
-      rootId: 'zcf0b9fa_fe1a5f62',
-      start_datetime: '2018-02-09 18:49:18.000000000',
-    },
-    {
-      comments: [
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 4,
-          id: 'rc1',
-          line: 5,
-          updated: '2019-02-08 18:49:18.000000000',
-          message: 'test',
-          unresolved: true,
-          robot_id: 'rc1',
-        },
-      ],
-      patchNum: 4,
-      path: '/COMMIT_MSG',
-      line: 5,
-      rootId: 'rc1',
-      start_datetime: '2019-02-08 18:49:18.000000000',
-    },
-    {
-      comments: [
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 4,
-          id: 'rc2',
-          line: 5,
-          updated: '2019-03-08 18:49:18.000000000',
-          message: 'test',
-          unresolved: true,
-          robot_id: 'rc2',
-        },
-        {
-          __path: '/COMMIT_MSG',
-          author: {
-            _account_id: 1000000,
-            name: 'user',
-            username: 'user',
-          },
-          patch_set: 4,
-          id: 'c2_1',
-          line: 5,
-          updated: '2019-03-08 18:49:18.000000000',
-          message: 'test',
-          unresolved: true,
-        },
-      ],
-      patchNum: 4,
-      path: '/COMMIT_MSG',
-      line: 5,
-      rootId: 'rc2',
-      start_datetime: '2019-03-08 18:49:18.000000000',
-    },
-  ];
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-endpoint-decorator', {
-      _import: sandbox.stub().returns(Promise.resolve()),
-    });
-    // Since pluginEndpoints are global, must reset state.
-    _testOnly_resetEndpoints();
-    navigateToChangeStub = sandbox.stub(GerritNav, 'navigateToChange');
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({test: 'config'}); },
-      getAccount() { return Promise.resolve(null); },
-      getDiffComments() { return Promise.resolve({}); },
-      getDiffRobotComments() { return Promise.resolve({}); },
-      getDiffDrafts() { return Promise.resolve({}); },
-      _fetchSharedCacheURL() { return Promise.resolve({}); },
-    });
-    element = fixture('basic');
-    sandbox.stub(element.$.actions, 'reload').returns(Promise.resolve());
-    pluginLoader.loadPlugins([]);
-    pluginApi.install(
-        plugin => {
-          plugin.registerDynamicCustomComponent(
-              'change-view-tab-header',
-              'gr-checks-change-view-tab-header-view'
-          );
-          plugin.registerDynamicCustomComponent(
-              'change-view-tab-content',
-              'gr-checks-view'
-          );
-        },
-        '0.1',
-        'http://some/plugins/url.html'
-    );
-  });
-
-  teardown(done => {
-    flush(() => {
-      sandbox.restore();
-      done();
-    });
-  });
-
-  const getCustomCssValue =
-      cssParam => util.getComputedStyleValue(cssParam, element);
-
-  test('_handleMessageAnchorTap', () => {
-    element._changeNum = '1';
-    element._patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 1,
-    };
-    const getUrlStub = sandbox.stub(GerritNav, 'getUrlForChange');
-    const replaceStateStub = sandbox.stub(history, 'replaceState');
-    element._handleMessageAnchorTap({detail: {id: 'a12345'}});
-
-    assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
-    assert.isTrue(replaceStateStub.called);
-  });
-
-  suite('plugins adding to file tab', () => {
-    setup(done => {
-      // Resolving it here instead of during setup() as other tests depend
-      // on flush() not being called during setup.
-      flush(() => done());
-    });
-
-    test('plugin added tab shows up as a dynamic endpoint', () => {
-      assert(element._dynamicTabHeaderEndpoints.includes(
-          'change-view-tab-header-url'));
-      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
-      // 3 Tabs are : Files, Plugin, Findings
-      assert.equal(paperTabs.querySelectorAll('paper-tab').length, 3);
-      assert.equal(paperTabs.querySelectorAll('paper-tab')[1].dataset.name,
-          'change-view-tab-header-url');
-    });
-
-    test('_setActivePrimaryTab switched tab correctly', done => {
-      element._setActivePrimaryTab({detail:
-          {tab: 'change-view-tab-header-url'}});
-      flush(() => {
-        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
-        done();
-      });
-    });
-
-    test('show-primary-tab switched primary tab correctly', done => {
-      element.dispatchEvent(
-          new CustomEvent('show-primary-tab', {
-            composed: true,
-            bubbles: true,
-            detail: {
-              tab: 'change-view-tab-header-url',
-            },
-          }));
-      flush(() => {
-        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
-        done();
-      });
-    });
-
-    test('param change should switch primary tab correctly', done => {
-      assert.equal(element._activeTabs[0], PrimaryTabs.FILES);
-      const queryMap = new Map();
-      queryMap.set('tab', PrimaryTabs.FINDINGS);
-      // view is required
-      element.params = Object.assign(
-          {
-            view: GerritNav.View.CHANGE,
-          },
-          element.params, {queryMap});
-      flush(() => {
-        assert.equal(element._activeTabs[0], PrimaryTabs.FINDINGS);
-        done();
-      });
-    });
-
-    test('invalid param change should not switch primary tab', done => {
-      assert.equal(element._activeTabs[0], PrimaryTabs.FILES);
-      const queryMap = new Map();
-      queryMap.set('tab', 'random');
-      // view is required
-      element.params = Object.assign(
-          {
-            view: GerritNav.View.CHANGE,
-          },
-          element.params, {queryMap});
-      flush(() => {
-        assert.equal(element._activeTabs[0], PrimaryTabs.FILES);
-        done();
-      });
-    });
-
-    test('switching tab sets _selectedTabPluginEndpoint', done => {
-      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
-      MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[1]);
-      flush(() => {
-        assert.equal(element._selectedTabPluginEndpoint,
-            'change-view-tab-content-url');
-        done();
-      });
-    });
-  });
-
-  suite('keyboard shortcuts', () => {
-    test('t to add topic', () => {
-      const editStub = sandbox.stub(element.$.metadata, 'editTopic');
-      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 't');
-      assert(editStub.called);
-    });
-
-    test('S should toggle the CL star', () => {
-      const starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
-      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
-      assert(starStub.called);
-    });
-
-    test('U should navigate to root if no backPage set', () => {
-      const relativeNavStub = sandbox.stub(GerritNav,
-          'navigateToRelativeUrl');
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert.isTrue(relativeNavStub.called);
-      assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
-          GerritNav.getUrlForRoot()));
-    });
-
-    test('U should navigate to backPage if set', () => {
-      const relativeNavStub = sandbox.stub(GerritNav,
-          'navigateToRelativeUrl');
-      element.backPage = '/dashboard/self';
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert.isTrue(relativeNavStub.called);
-      assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
-          '/dashboard/self'));
-    });
-
-    test('A fires an error event when not logged in', done => {
-      sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
-      const loggedInErrorSpy = sandbox.spy();
-      element.addEventListener('show-auth-required', loggedInErrorSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      flush(() => {
-        assert.isFalse(element.$.replyOverlay.opened);
-        assert.isTrue(loggedInErrorSpy.called);
-        done();
-      });
-    });
-
-    test('shift A does not open reply overlay', done => {
-      sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-      flush(() => {
-        assert.isFalse(element.$.replyOverlay.opened);
-        done();
-      });
-    });
-
-    test('A toggles overlay when logged in', done => {
-      sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-      sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates')
-          .returns(Promise.resolve({isLatest: true}));
-      element._change = {labels: {}};
-      const openSpy = sandbox.spy(element, '_openReplyDialog');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      flush(() => {
-        assert.isTrue(element.$.replyOverlay.opened);
-        element.$.replyOverlay.close();
-        assert.isFalse(element.$.replyOverlay.opened);
-        assert(openSpy.lastCall.calledWithExactly(
-            element.$.replyDialog.FocusTarget.ANY),
-        '_openReplyDialog should have been passed ANY');
-        assert.equal(openSpy.callCount, 1);
-        done();
-      });
-    });
-
-    test('fullscreen-overlay-opened hides content', () => {
-      element._loggedIn = true;
-      element._loading = false;
-      element._change = {
-        owner: {_account_id: 1},
-        labels: {},
-        actions: {
-          abandon: {
-            enabled: true,
-            label: 'Abandon',
-            method: 'POST',
-            title: 'Abandon',
-          },
-        },
-      };
-      sandbox.spy(element, '_handleHideBackgroundContent');
-      element.$.replyDialog.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-opened', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleHideBackgroundContent.called);
-      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-      assert.equal(getComputedStyle(element.$.actions).display, 'flex');
-    });
-
-    test('fullscreen-overlay-closed shows content', () => {
-      element._loggedIn = true;
-      element._loading = false;
-      element._change = {
-        owner: {_account_id: 1},
-        labels: {},
-        actions: {
-          abandon: {
-            enabled: true,
-            label: 'Abandon',
-            method: 'POST',
-            title: 'Abandon',
-          },
-        },
-      };
-      sandbox.spy(element, '_handleShowBackgroundContent');
-      element.$.replyDialog.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-closed', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleShowBackgroundContent.called);
-      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
-    });
-
-    test('expand all messages when expand-diffs fired', () => {
-      const handleExpand =
-          sandbox.stub(element.$.fileList, 'expandAllDiffs');
-      element.$.fileListHeader.dispatchEvent(
-          new CustomEvent('expand-diffs', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(handleExpand.called);
-    });
-
-    test('collapse all messages when collapse-diffs fired', () => {
-      const handleCollapse =
-      sandbox.stub(element.$.fileList, 'collapseAllDiffs');
-      element.$.fileListHeader.dispatchEvent(
-          new CustomEvent('collapse-diffs', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(handleCollapse.called);
-    });
-
-    test('X should expand all messages', done => {
-      flush(() => {
-        const handleExpand = sandbox.stub(element.messagesList,
-            'handleExpandCollapse');
-        MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'x');
-        assert(handleExpand.calledWith(true));
-        done();
-      });
-    });
-
-    test('Z should collapse all messages', done => {
-      flush(() => {
-        const handleExpand = sandbox.stub(element.messagesList,
-            'handleExpandCollapse');
-        MockInteractions.pressAndReleaseKeyOn(element, 90, null, 'z');
-        assert(handleExpand.calledWith(false));
-        done();
-      });
-    });
-
-    test('shift + R should fetch and navigate to the latest patch set',
-        done => {
-          element._changeNum = '42';
-          element._patchRange = {
-            basePatchNum: 'PARENT',
-            patchNum: 1,
-          };
-          element._change = {
-            change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-            _number: 42,
-            revisions: {
-              rev1: {_number: 1, commit: {parents: []}},
-            },
-            current_revision: 'rev1',
-            status: 'NEW',
-            labels: {},
-            actions: {},
-          };
-
-          navigateToChangeStub.restore();
-          navigateToChangeStub = sandbox.stub(GerritNav, 'navigateToChange',
-              (change, patchNum, basePatchNum) => {
-                assert.equal(change, element._change);
-                assert.isUndefined(patchNum);
-                assert.isUndefined(basePatchNum);
-                done();
-              });
-
-          MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-        });
-
-    test('d should open download overlay', () => {
-      const stub = sandbox.stub(element.$.downloadOverlay, 'open');
-      MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
-      assert.isTrue(stub.called);
-    });
-
-    test(', should open diff preferences', () => {
-      const stub = sandbox.stub(
-          element.$.fileList.$.diffPreferencesDialog, 'open');
-      element._loggedIn = false;
-      element.disableDiffPrefs = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-      assert.isFalse(stub.called);
-
-      element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-      assert.isFalse(stub.called);
-
-      element.disableDiffPrefs = false;
-      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-      assert.isTrue(stub.called);
-    });
-
-    test('m should toggle diff mode', () => {
-      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      const setModeStub = sandbox.stub(element.$.fileListHeader,
-          'setDiffViewMode');
-      const e = {preventDefault: () => {}};
-      flushAsynchronousOperations();
-
-      element.viewState.diffMode = 'SIDE_BY_SIDE';
-      element._handleToggleDiffMode(e);
-      assert.isTrue(setModeStub.calledWith('UNIFIED_DIFF'));
-
-      element.viewState.diffMode = 'UNIFIED_DIFF';
-      element._handleToggleDiffMode(e);
-      assert.isTrue(setModeStub.calledWith('SIDE_BY_SIDE'));
-    });
-  });
-
-  suite('reloading drafts', () => {
-    let reloadStub;
-    const drafts = {
-      'testfile.txt': [
-        {
-          patch_set: 5,
-          id: 'dd2982f5_c01c9e6a',
-          line: 1,
-          updated: '2017-11-08 18:47:45.000000000',
-          message: 'test',
-          unresolved: true,
-        },
-      ],
-    };
-    setup(() => {
-      // Fake computeDraftCount as its required for ChangeComments,
-      // see gr-comment-api#reloadDrafts.
-      reloadStub = sandbox.stub(element.$.commentAPI, 'reloadDrafts')
-          .returns(Promise.resolve({
-            drafts,
-            getAllThreadsForChange: () => ([]),
-            computeDraftCount: () => 1,
-          }));
-    });
-
-    test('drafts are reloaded when reload-drafts fired', done => {
-      element.$.fileList.dispatchEvent(
-          new CustomEvent('reload-drafts', {
-            detail: {
-              resolve: () => {
-                assert.isTrue(reloadStub.called);
-                assert.deepEqual(element._diffDrafts, drafts);
-                done();
-              },
-            },
-            composed: true, bubbles: true,
-          }));
-    });
-
-    test('drafts are reloaded when comment-refresh fired', () => {
-      element.dispatchEvent(
-          new CustomEvent('comment-refresh', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(reloadStub.called);
-    });
-  });
-
-  test('diff comments modified', () => {
-    sandbox.spy(element, '_handleReloadCommentThreads');
-    return element._reloadComments().then(() => {
-      element.dispatchEvent(
-          new CustomEvent('diff-comments-modified', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleReloadCommentThreads.called);
-    });
-  });
-
-  test('thread list modified', () => {
-    sandbox.spy(element, '_handleReloadDiffComments');
-    element._activeTabs = [PrimaryTabs.FILES, SecondaryTabs.COMMENT_THREADS];
-    flushAsynchronousOperations();
-
-    return element._reloadComments().then(() => {
-      element.threadList.dispatchEvent(
-          new CustomEvent('thread-list-modified', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleReloadDiffComments.called);
-
-      let draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
-          .returns(1);
-      assert.equal(element._computeTotalCommentCounts(5,
-          element._changeComments), '5 unresolved, 1 draft');
-      assert.equal(element._computeTotalCommentCounts(0,
-          element._changeComments), '1 draft');
-      draftStub.restore();
-      draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
-          .returns(0);
-      assert.equal(element._computeTotalCommentCounts(0,
-          element._changeComments), '');
-      assert.equal(element._computeTotalCommentCounts(1,
-          element._changeComments), '1 unresolved');
-      draftStub.restore();
-      draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
-          .returns(2);
-      assert.equal(element._computeTotalCommentCounts(1,
-          element._changeComments), '1 unresolved, 2 drafts');
-      draftStub.restore();
-    });
-  });
-
-  suite('thread list and change log tabs', () => {
-    setup(() => {
-      element._changeNum = '1';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev2: {_number: 2, commit: {parents: []}},
-          rev1: {_number: 1, commit: {parents: []}},
-          rev13: {_number: 13, commit: {parents: []}},
-          rev3: {_number: 3, commit: {parents: []}},
-        },
-        current_revision: 'rev3',
-        status: 'NEW',
-        labels: {
-          test: {
-            all: [],
-            default_value: 0,
-            values: [],
-            approved: {},
-          },
-        },
-      };
-      sandbox.stub(element.$.relatedChanges, 'reload');
-      sandbox.stub(element, '_reload').returns(Promise.resolve());
-      sandbox.spy(element, '_paramsChanged');
-      element.params = {view: 'change', changeNum: '1'};
-    });
-
-    test('tab switch works correctly', done => {
-      assert.isTrue(element._paramsChanged.called);
-      assert.equal(element._activeTabs[1], SecondaryTabs.CHANGE_LOG);
-
-      const commentTab = element.shadowRoot.querySelector(
-          'paper-tab.commentThreads'
-      );
-      // Switch to comment thread tab
-      MockInteractions.tap(commentTab);
-      assert.equal(element._activeTabs[1], SecondaryTabs.COMMENT_THREADS);
-
-      // Switch back to 'Change Log' tab
-      element._paramsChanged(element.params);
-      flush(() => {
-        assert.equal(element._activeTabs[1], SecondaryTabs.CHANGE_LOG);
-        done();
-      });
-    });
-
-    test('show-secondary-tab event works', () => {
-      assert.equal(element._activeTabs[1], SecondaryTabs.CHANGE_LOG);
-      // Switch to comment thread tab
-      element.fire('show-secondary-tab', {tab: SecondaryTabs.COMMENT_THREADS});
-      assert.equal(element._activeTabs[1], SecondaryTabs.COMMENT_THREADS);
-    });
-
-    test('param change should switched secondary tab correctly', done => {
-      assert.equal(element._activeTabs[1], SecondaryTabs.CHANGE_LOG);
-      const queryMap = new Map();
-      queryMap.set('secondaryTab', SecondaryTabs.COMMENT_THREADS);
-      // view is required
-      element.params = Object.assign(
-          {view: GerritNav.View.CHANGE},
-          element.params, {queryMap}
-      );
-      flush(() => {
-        assert.equal(element._activeTabs[1], SecondaryTabs.COMMENT_THREADS);
-        done();
-      });
-    });
-
-    test('invalid secondaryTab should not switch tab', done => {
-      assert.equal(element._activeTabs[1], SecondaryTabs.CHANGE_LOG);
-      const queryMap = new Map();
-      queryMap.set('secondaryTab', 'random');
-      // view is required
-      element.params = Object.assign({
-        view: GerritNav.View.CHANGE,
-      }, element.params, {queryMap});
-      flush(() => {
-        assert.equal(element._activeTabs[1], SecondaryTabs.CHANGE_LOG);
-        done();
-      });
-    });
-  });
-
-  suite('Findings comment tab', () => {
-    setup(done => {
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev2: {_number: 2, commit: {parents: []}},
-          rev1: {_number: 1, commit: {parents: []}},
-          rev13: {_number: 13, commit: {parents: []}},
-          rev3: {_number: 3, commit: {parents: []}},
-          rev4: {_number: 4, commit: {parents: []}},
-        },
-        current_revision: 'rev4',
-      };
-      element._commentThreads = THREADS;
-      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
-      MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[2]);
-      flush(() => {
-        done();
-      });
-    });
-
-    test('robot comments count per patchset', () => {
-      const count = element._robotCommentCountPerPatchSet(THREADS);
-      const expectedCount = {
-        2: 1,
-        3: 1,
-        4: 2,
-      };
-      assert.deepEqual(count, expectedCount);
-      assert.equal(element._computeText({_number: 2}, THREADS),
-          'Patchset 2 (1 finding)');
-      assert.equal(element._computeText({_number: 4}, THREADS),
-          'Patchset 4 (2 findings)');
-      assert.equal(element._computeText({_number: 5}, THREADS),
-          'Patchset 5');
-    });
-
-    test('only robot comments are rendered', () => {
-      assert.equal(element._robotCommentThreads.length, 2);
-      assert.equal(element._robotCommentThreads[0].comments[0].robot_id,
-          'rc1');
-      assert.equal(element._robotCommentThreads[1].comments[0].robot_id,
-          'rc2');
-    });
-
-    test('changing patchsets resets robot comments', done => {
-      element.set('_change.current_revision', 'rev3');
-      flush(() => {
-        assert.equal(element._robotCommentThreads.length, 1);
-        done();
-      });
-    });
-
-    test('Show more button is hidden', () => {
-      assert.isNull(element.shadowRoot.querySelector('.show-robot-comments'));
-    });
-
-    suite('robot comments show more button', () => {
-      setup(done => {
-        const arr = [];
-        for (let i = 0; i <= 30; i++) {
-          arr.push(...THREADS);
-        }
-        element._commentThreads = arr;
-        flush(() => {
-          done();
-        });
-      });
-
-      test('Show more button is rendered', () => {
-        assert.isOk(element.shadowRoot.querySelector('.show-robot-comments'));
-        assert.equal(element._robotCommentThreads.length,
-            ROBOT_COMMENTS_LIMIT);
-      });
-
-      test('Clicking show more button renders all comments', done => {
-        MockInteractions.tap(element.shadowRoot.querySelector(
-            '.show-robot-comments'));
-        flush(() => {
-          assert.equal(element._robotCommentThreads.length, 62);
-          done();
-        });
-      });
-    });
-  });
-
-  test('reply button is not visible when logged out', () => {
-    assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
-    element._loggedIn = true;
-    assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
-  });
-
-  test('download tap calls _handleOpenDownloadDialog', () => {
-    sandbox.stub(element, '_handleOpenDownloadDialog');
-    element.$.actions.dispatchEvent(
-        new CustomEvent('download-tap', {
-          composed: true, bubbles: true,
-        }));
-    assert.isTrue(element._handleOpenDownloadDialog.called);
-  });
-
-  test('fetches the server config on attached', done => {
-    flush(() => {
-      assert.equal(element._serverConfig.test, 'config');
-      done();
-    });
-  });
-
-  test('_changeStatuses', () => {
-    sandbox.stub(element, 'changeStatuses').returns(
-        ['Merged', 'WIP']);
-    element._loading = false;
-    element._change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev2: {_number: 2},
-        rev1: {_number: 1},
-        rev13: {_number: 13},
-        rev3: {_number: 3},
-      },
-      current_revision: 'rev3',
-      labels: {
-        test: {
-          all: [],
-          default_value: 0,
-          values: [],
-          approved: {},
-        },
-      },
-    };
-    element._mergeable = true;
-    const expectedStatuses = ['Merged', 'WIP'];
-    assert.deepEqual(element._changeStatuses, expectedStatuses);
-    assert.equal(element._changeStatus, expectedStatuses.join(', '));
-    flushAsynchronousOperations();
-    const statusChips = dom(element.root)
-        .querySelectorAll('gr-change-status');
-    assert.equal(statusChips.length, 2);
-  });
-
-  test('diff preferences open when open-diff-prefs is fired', () => {
-    const overlayOpenStub = sandbox.stub(element.$.fileList,
-        'openDiffPrefs');
-    element.$.fileListHeader.dispatchEvent(
-        new CustomEvent('open-diff-prefs', {
-          composed: true, bubbles: true,
-        }));
-    assert.isTrue(overlayOpenStub.called);
-  });
-
-  test('_prepareCommitMsgForLinkify', () => {
-    let commitMessage = 'R=test@google.com';
-    let result = element._prepareCommitMsgForLinkify(commitMessage);
-    assert.equal(result, 'R=\u200Btest@google.com');
-
-    commitMessage = 'R=test@google.com\nR=test@google.com';
-    result = element._prepareCommitMsgForLinkify(commitMessage);
-    assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
-
-    commitMessage = 'CC=test@google.com';
-    result = element._prepareCommitMsgForLinkify(commitMessage);
-    assert.equal(result, 'CC=\u200Btest@google.com');
-  }),
-
-  test('_isSubmitEnabled', () => {
-    assert.isFalse(element._isSubmitEnabled({}));
-    assert.isFalse(element._isSubmitEnabled({submit: {}}));
-    assert.isTrue(element._isSubmitEnabled(
-        {submit: {enabled: true}}));
-  });
-
-  test('_reload is called when an approved label is removed', () => {
-    const vote = {_account_id: 1, name: 'bojack', value: 1};
-    element._changeNum = '42';
-    element._patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 1,
-    };
-    element._change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      owner: {email: 'abc@def'},
-      revisions: {
-        rev2: {_number: 2, commit: {parents: []}},
-        rev1: {_number: 1, commit: {parents: []}},
-        rev13: {_number: 13, commit: {parents: []}},
-        rev3: {_number: 3, commit: {parents: []}},
-      },
-      current_revision: 'rev3',
-      status: 'NEW',
-      labels: {
-        test: {
-          all: [vote],
-          default_value: 0,
-          values: [],
-          approved: {},
-        },
-      },
-    };
-    flushAsynchronousOperations();
-    const reloadStub = sandbox.stub(element, '_reload');
-    element.splice('_change.labels.test.all', 0, 1);
-    assert.isFalse(reloadStub.called);
-    element._change.labels.test.all.push(vote);
-    element._change.labels.test.all.push(vote);
-    element._change.labels.test.approved = vote;
-    flushAsynchronousOperations();
-    element.splice('_change.labels.test.all', 0, 2);
-    assert.isTrue(reloadStub.called);
-    assert.isTrue(reloadStub.calledOnce);
-  });
-
-  test('reply button has updated count when there are drafts', () => {
-    const getLabel = element._computeReplyButtonLabel;
-
-    assert.equal(getLabel(null, false), 'Reply');
-    assert.equal(getLabel(null, true), 'Start Review');
-
-    const changeRecord = {base: null};
-    assert.equal(getLabel(changeRecord, false), 'Reply');
-
-    changeRecord.base = {};
-    assert.equal(getLabel(changeRecord, false), 'Reply');
-
-    changeRecord.base = {
-      'file1.txt': [{}],
-      'file2.txt': [{}, {}],
-    };
-    assert.equal(getLabel(changeRecord, false), 'Reply (3)');
-  });
-
-  test('comment events properly update diff drafts', () => {
-    element._patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 2,
-    };
-    const draft = {
-      __draft: true,
-      id: 'id1',
-      path: '/foo/bar.txt',
-      text: 'hello',
-    };
-    element._handleCommentSave({detail: {comment: draft}});
-    draft.patch_set = 2;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-    draft.patch_set = null;
-    draft.text = 'hello, there';
-    element._handleCommentSave({detail: {comment: draft}});
-    draft.patch_set = 2;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-    const draft2 = {
-      __draft: true,
-      id: 'id2',
-      path: '/foo/bar.txt',
-      text: 'hola',
-    };
-    element._handleCommentSave({detail: {comment: draft2}});
-    draft2.patch_set = 2;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
-    draft.patch_set = null;
-    element._handleCommentDiscard({detail: {comment: draft}});
-    draft.patch_set = 2;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
-    element._handleCommentDiscard({detail: {comment: draft2}});
-    assert.deepEqual(element._diffDrafts, {});
-  });
-
-  test('change num change', () => {
-    element._changeNum = null;
-    element._patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 2,
-    };
-    element._change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      labels: {},
-    };
-    element.viewState.changeNum = null;
-    element.viewState.diffMode = 'UNIFIED';
-    assert.equal(element.viewState.numFilesShown, 200);
-    assert.equal(element._numFilesShown, 200);
-    element._numFilesShown = 150;
-    flushAsynchronousOperations();
-    assert.equal(element.viewState.diffMode, 'UNIFIED');
-    assert.equal(element.viewState.numFilesShown, 150);
-
-    element._changeNum = '1';
-    element.params = {changeNum: '1'};
-    element._change.newProp = '1';
-    flushAsynchronousOperations();
-    assert.equal(element.viewState.diffMode, 'UNIFIED');
-    assert.equal(element.viewState.changeNum, '1');
-
-    element._changeNum = '2';
-    element.params = {changeNum: '2'};
-    element._change.newProp = '2';
-    flushAsynchronousOperations();
-    assert.equal(element.viewState.diffMode, 'UNIFIED');
-    assert.equal(element.viewState.changeNum, '2');
-    assert.equal(element.viewState.numFilesShown, 200);
-    assert.equal(element._numFilesShown, 200);
-  });
-
-  test('_setDiffViewMode is called with reset when new change is loaded',
-      () => {
-        sandbox.stub(element, '_setDiffViewMode');
-        element.viewState = {changeNum: 1};
-        element._changeNum = 2;
-        element._resetFileListViewState();
-        assert.isTrue(
-            element._setDiffViewMode.lastCall.calledWithExactly(true));
-      });
-
-  test('diffViewMode is propagated from file list header', () => {
-    element.viewState = {diffMode: 'UNIFIED'};
-    element.$.fileListHeader.diffViewMode = 'SIDE_BY_SIDE';
-    assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
-  });
-
-  test('diffMode defaults to side by side without preferences', done => {
-    sandbox.stub(element.$.restAPI, 'getPreferences').returns(
-        Promise.resolve({}));
-    // No user prefs or diff view mode set.
-
-    element._setDiffViewMode().then(() => {
-      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
-      done();
-    });
-  });
-
-  test('diffMode defaults to preference when not already set', done => {
-    sandbox.stub(element.$.restAPI, 'getPreferences').returns(
-        Promise.resolve({default_diff_view: 'UNIFIED'}));
-
-    element._setDiffViewMode().then(() => {
-      assert.equal(element.viewState.diffMode, 'UNIFIED');
-      done();
-    });
-  });
-
-  test('existing diffMode overrides preference', done => {
-    element.viewState.diffMode = 'SIDE_BY_SIDE';
-    sandbox.stub(element.$.restAPI, 'getPreferences').returns(
-        Promise.resolve({default_diff_view: 'UNIFIED'}));
-    element._setDiffViewMode().then(() => {
-      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
-      done();
-    });
-  });
-
-  test('don’t reload entire page when patchRange changes', () => {
-    const reloadStub = sandbox.stub(element, '_reload',
-        () => Promise.resolve());
-    const reloadPatchDependentStub = sandbox.stub(element,
-        '_reloadPatchNumDependentResources',
-        () => Promise.resolve());
-    const relatedClearSpy = sandbox.spy(element.$.relatedChanges, 'clear');
-    const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
-
-    const value = {
-      view: GerritNav.View.CHANGE,
-      patchNum: '1',
-    };
-    element._paramsChanged(value);
-    assert.isTrue(reloadStub.calledOnce);
-    assert.isTrue(relatedClearSpy.calledOnce);
-
-    element._initialLoadComplete = true;
-
-    value.basePatchNum = '1';
-    value.patchNum = '2';
-    element._paramsChanged(value);
-    assert.isFalse(reloadStub.calledTwice);
-    assert.isTrue(reloadPatchDependentStub.calledOnce);
-    assert.isTrue(relatedClearSpy.calledOnce);
-    assert.isTrue(collapseStub.calledTwice);
-  });
-
-  test('reload entire page when patchRange doesnt change', () => {
-    const reloadStub = sandbox.stub(element, '_reload',
-        () => Promise.resolve());
-    const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
-    const value = {
-      view: GerritNav.View.CHANGE,
-    };
-    element._paramsChanged(value);
-    assert.isTrue(reloadStub.calledOnce);
-    element._initialLoadComplete = true;
-    element._paramsChanged(value);
-    assert.isTrue(reloadStub.calledTwice);
-    assert.isTrue(collapseStub.calledTwice);
-  });
-
-  test('related changes are updated and new patch selected after rebase',
-      done => {
-        element._changeNum = '42';
-        sandbox.stub(element, 'computeLatestPatchNum', () => 1);
-        sandbox.stub(element, '_reload',
-            () => Promise.resolve());
-        const e = {detail: {action: 'rebase'}};
-        element._handleReloadChange(e).then(() => {
-          assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
-              element._change));
-          done();
-        });
-      });
-
-  test('related changes are not updated after other action', done => {
-    sandbox.stub(element, '_reload', () => Promise.resolve());
-    sandbox.stub(element.$.relatedChanges, 'reload');
-    const e = {detail: {action: 'abandon'}};
-    element._handleReloadChange(e).then(() => {
-      assert.isFalse(navigateToChangeStub.called);
-      done();
-    });
-  });
-
-  test('_computeMergedCommitInfo', () => {
-    const dummyRevs = {
-      1: {commit: {commit: 1}},
-      2: {commit: {}},
-    };
-    assert.deepEqual(element._computeMergedCommitInfo(0, dummyRevs), {});
-    assert.deepEqual(element._computeMergedCommitInfo(1, dummyRevs),
-        dummyRevs[1].commit);
-
-    // Regression test for issue 5337.
-    const commit = element._computeMergedCommitInfo(2, dummyRevs);
-    assert.notDeepEqual(commit, dummyRevs[2]);
-    assert.deepEqual(commit, {commit: 2});
-  });
-
-  test('_computeCopyTextForTitle', () => {
-    const change = {
-      _number: 123,
-      subject: 'test subject',
-      revisions: {
-        rev1: {_number: 1},
-        rev3: {_number: 3},
-      },
-      current_revision: 'rev3',
-    };
-    sandbox.stub(GerritNav, 'getUrlForChange')
-        .returns('/change/123');
-    assert.equal(
-        element._computeCopyTextForTitle(change),
-        `123: test subject | http://${location.host}/change/123`
-    );
-  });
-
-  test('get latest revision', () => {
-    let change = {
-      revisions: {
-        rev1: {_number: 1},
-        rev3: {_number: 3},
-      },
-      current_revision: 'rev3',
-    };
-    assert.equal(element._getLatestRevisionSHA(change), 'rev3');
-    change = {
-      revisions: {
-        rev1: {_number: 1},
-      },
-    };
-    assert.equal(element._getLatestRevisionSHA(change), 'rev1');
-  });
-
-  test('show commit message edit button', () => {
-    const _change = {
-      status: element.ChangeStatus.MERGED,
-    };
-    assert.isTrue(element._computeHideEditCommitMessage(false, false, {}));
-    assert.isTrue(element._computeHideEditCommitMessage(true, true, {}));
-    assert.isTrue(element._computeHideEditCommitMessage(false, true, {}));
-    assert.isFalse(element._computeHideEditCommitMessage(true, false, {}));
-    assert.isTrue(element._computeHideEditCommitMessage(true, false,
-        _change));
-    assert.isTrue(element._computeHideEditCommitMessage(true, false, {},
-        true));
-    assert.isFalse(element._computeHideEditCommitMessage(true, false, {},
-        false));
-  });
-
-  test('_handleCommitMessageSave trims trailing whitespace', () => {
-    const putStub = sandbox.stub(element.$.restAPI, 'putChangeCommitMessage')
-        .returns(Promise.resolve({}));
-
-    const mockEvent = content => { return {detail: {content}}; };
-
-    element._handleCommitMessageSave(mockEvent('test \n  test '));
-    assert.equal(putStub.lastCall.args[1], 'test\n  test');
-
-    element._handleCommitMessageSave(mockEvent('  test\ntest'));
-    assert.equal(putStub.lastCall.args[1], '  test\ntest');
-
-    element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
-    assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
-  });
-
-  test('_computeChangeIdCommitMessageError', () => {
-    let commitMessage =
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
-    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        null);
-
-    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'mismatch');
-
-    commitMessage = 'This is the greatest change.';
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'missing');
-  });
-
-  test('multiple change Ids in commit message picks last', () => {
-    const commitMessage = [
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
-    ].join('\n');
-    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        null);
-    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'mismatch');
-  });
-
-  test('does not count change Id that starts mid line', () => {
-    const commitMessage = [
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
-    ].join(' and ');
-    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        null);
-    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
-    assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'mismatch');
-  });
-
-  test('_computeTitleAttributeWarning', () => {
-    let changeIdCommitMessageError = 'missing';
-    assert.equal(
-        element._computeTitleAttributeWarning(changeIdCommitMessageError),
-        'No Change-Id in commit message');
-
-    changeIdCommitMessageError = 'mismatch';
-    assert.equal(
-        element._computeTitleAttributeWarning(changeIdCommitMessageError),
-        'Change-Id mismatch');
-  });
-
-  test('_computeChangeIdClass', () => {
-    let changeIdCommitMessageError = 'missing';
-    assert.equal(
-        element._computeChangeIdClass(changeIdCommitMessageError), '');
-
-    changeIdCommitMessageError = 'mismatch';
-    assert.equal(
-        element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
-  });
-
-  test('topic is coalesced to null', done => {
-    sandbox.stub(element, '_changeChanged');
-    sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
-      id: '123456789',
-      labels: {},
-      current_revision: 'foo',
-      revisions: {foo: {commit: {}}},
-    }));
-
-    element._getChangeDetail().then(() => {
-      assert.isNull(element._change.topic);
-      done();
-    });
-  });
-
-  test('commit sha is populated from getChangeDetail', done => {
-    sandbox.stub(element, '_changeChanged');
-    sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
-      id: '123456789',
-      labels: {},
-      current_revision: 'foo',
-      revisions: {foo: {commit: {}}},
-    }));
-
-    element._getChangeDetail().then(() => {
-      assert.equal('foo', element._commitInfo.commit);
-      done();
-    });
-  });
-
-  test('edit is added to change', () => {
-    sandbox.stub(element, '_changeChanged');
-    sandbox.stub(element.$.restAPI, 'getChangeDetail', () => Promise.resolve({
-      id: '123456789',
-      labels: {},
-      current_revision: 'foo',
-      revisions: {foo: {commit: {}}},
-    }));
-    sandbox.stub(element, '_getEdit', () => Promise.resolve({
-      base_patch_set_number: 1,
-      commit: {commit: 'bar'},
-    }));
-    element._patchRange = {};
-
-    return element._getChangeDetail().then(() => {
-      const revs = element._change.revisions;
-      assert.equal(Object.keys(revs).length, 2);
-      assert.deepEqual(revs['foo'], {commit: {commit: 'foo'}});
-      assert.deepEqual(revs['bar'], {
-        _number: element.EDIT_NAME,
-        basePatchNum: 1,
-        commit: {commit: 'bar'},
-        fetch: undefined,
-      });
-    });
-  });
-
-  test('_getBasePatchNum', () => {
-    const _change = {
-      _number: 42,
-      revisions: {
-        '98da160735fb81604b4c40e93c368f380539dd0e': {
-          _number: 1,
-          commit: {
-            parents: [],
-          },
-        },
-      },
-    };
-    const _patchRange = {
-      basePatchNum: 'PARENT',
-    };
-    assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
-
-    element._prefs = {
-      default_base_for_merges: 'FIRST_PARENT',
-    };
-
-    const _change2 = {
-      _number: 42,
-      revisions: {
-        '98da160735fb81604b4c40e93c368f380539dd0e': {
-          _number: 1,
-          commit: {
-            parents: [
-              {
-                commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8',
-                subject: 'test',
-              },
-              {
-                commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841',
-                subject: 'test3',
-              },
-            ],
-          },
-        },
-      },
-    };
-    assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
-
-    _patchRange.patchNum = 1;
-    assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
-  });
-
-  test('_openReplyDialog called with `ANY` when coming from tap event',
-      () => {
-        const openStub = sandbox.stub(element, '_openReplyDialog');
-        element._serverConfig = {};
-        MockInteractions.tap(element.$.replyBtn);
-        assert(openStub.lastCall.calledWithExactly(
-            element.$.replyDialog.FocusTarget.ANY),
-        '_openReplyDialog should have been passed ANY');
-        assert.equal(openStub.callCount, 1);
-      });
-
-  test('_openReplyDialog called with `BODY` when coming from message reply' +
-      'event', done => {
-    flush(() => {
-      const openStub = sandbox.stub(element, '_openReplyDialog');
-      element.messagesList.dispatchEvent(
-          new CustomEvent('reply', {
-            detail:
-          {message: {message: 'text'}},
-            composed: true, bubbles: true,
-          }));
-      assert(openStub.lastCall.calledWithExactly(
-          element.$.replyDialog.FocusTarget.BODY),
-      '_openReplyDialog should have been passed BODY');
-      assert.equal(openStub.callCount, 1);
-      done();
-    });
-  });
-
-  test('reply dialog focus can be controlled', () => {
-    const FocusTarget = element.$.replyDialog.FocusTarget;
-    const openStub = sandbox.stub(element, '_openReplyDialog');
-
-    const e = {detail: {}};
-    element._handleShowReplyDialog(e);
-    assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
-        '_openReplyDialog should have been passed REVIEWERS');
-    assert.equal(openStub.callCount, 1);
-
-    e.detail.value = {ccsOnly: true};
-    element._handleShowReplyDialog(e);
-    assert(openStub.lastCall.calledWithExactly(FocusTarget.CCS),
-        '_openReplyDialog should have been passed CCS');
-    assert.equal(openStub.callCount, 2);
-  });
-
-  test('getUrlParameter functionality', () => {
-    const locationStub = sandbox.stub(element, '_getLocationSearch');
-
-    locationStub.returns('?test');
-    assert.equal(element._getUrlParameter('test'), 'test');
-    locationStub.returns('?test2=12&test=3');
-    assert.equal(element._getUrlParameter('test'), 'test');
-    locationStub.returns('');
-    assert.isNull(element._getUrlParameter('test'));
-    locationStub.returns('?');
-    assert.isNull(element._getUrlParameter('test'));
-    locationStub.returns('?test2');
-    assert.isNull(element._getUrlParameter('test'));
-  });
-
-  test('revert dialog opened with revert param', done => {
-    sandbox.stub(element.$.restAPI, 'getLoggedIn', () => Promise.resolve(true));
-    sandbox.stub(pluginLoader, 'awaitPluginsLoaded', () => Promise.resolve());
-
-    element._patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 2,
-    };
-    element._change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1, commit: {parents: []}},
-        rev2: {_number: 2, commit: {parents: []}},
-      },
-      current_revision: 'rev1',
-      status: element.ChangeStatus.MERGED,
-      labels: {},
-      actions: {},
-    };
-
-    sandbox.stub(element, '_getUrlParameter',
-        param => {
-          assert.equal(param, 'revert');
-          return param;
-        });
-
-    sandbox.stub(element.$.actions, 'showRevertDialog',
-        done);
-
-    element._maybeShowRevertDialog();
-    assert.isTrue(pluginLoader.awaitPluginsLoaded.called);
-  });
-
-  suite('scroll related tests', () => {
-    test('document scrolling calls function to set scroll height', done => {
-      const originalHeight = document.body.scrollHeight;
-      const scrollStub = sandbox.stub(element, '_handleScroll',
-          () => {
-            assert.isTrue(scrollStub.called);
-            document.body.style.height = originalHeight + 'px';
-            scrollStub.restore();
-            done();
-          });
-      document.body.style.height = '10000px';
-      element._handleScroll();
-    });
-
-    test('scrollTop is set correctly', () => {
-      element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
-
-      sandbox.stub(element, '_reload', () => {
-        // When element is reloaded, ensure that the history
-        // state has the scrollTop set earlier. This will then
-        // be reset.
-        assert.isTrue(element.viewState.scrollTop == TEST_SCROLL_TOP_PX);
-        return Promise.resolve({});
-      });
-
-      // simulate reloading component, which is done when route
-      // changes to match a regex of change view type.
-      element._paramsChanged({view: GerritNav.View.CHANGE});
-    });
-
-    test('scrollTop is reset when new change is loaded', () => {
-      element._resetFileListViewState();
-      assert.equal(element.viewState.scrollTop, 0);
-    });
-  });
-
-  suite('reply dialog tests', () => {
-    setup(() => {
-      sandbox.stub(element.$.replyDialog, '_draftChanged');
-      sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates',
-          () => Promise.resolve({isLatest: true}));
-      element._change = {labels: {}};
-    });
-
-    test('reply from comment adds quote text', () => {
-      const e = {detail: {message: {message: 'quote text'}}};
-      element._handleMessageReply(e);
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-    });
-
-    test('reply from comment replaces quote text', () => {
-      element.$.replyDialog.draft = '> old quote text\n\n some draft text';
-      element.$.replyDialog.quote = '> old quote text\n\n';
-      const e = {detail: {message: {message: 'quote text'}}};
-      element._handleMessageReply(e);
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-    });
-
-    test('reply from same comment preserves quote text', () => {
-      element.$.replyDialog.draft = '> quote text\n\n some draft text';
-      element.$.replyDialog.quote = '> quote text\n\n';
-      const e = {detail: {message: {message: 'quote text'}}};
-      element._handleMessageReply(e);
-      assert.equal(element.$.replyDialog.draft,
-          '> quote text\n\n some draft text');
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-    });
-
-    test('reply from top of page contains previous draft', () => {
-      const div = document.createElement('div');
-      element.$.replyDialog.draft = '> quote text\n\n some draft text';
-      element.$.replyDialog.quote = '> quote text\n\n';
-      const e = {target: div, preventDefault: sandbox.spy()};
-      element._handleReplyTap(e);
-      assert.equal(element.$.replyDialog.draft,
-          '> quote text\n\n some draft text');
-      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
-    });
-  });
-
-  test('reply button is disabled until server config is loaded', () => {
-    assert.isTrue(element._replyDisabled);
-    element._serverConfig = {};
-    assert.isFalse(element._replyDisabled);
-  });
-
-  suite('commit message expand/collapse', () => {
-    setup(() => {
-      sandbox.stub(element, 'fetchChangeUpdates',
-          () => Promise.resolve({isLatest: false}));
-    });
-
-    test('commitCollapseToggle hidden for short commit message', () => {
-      element._latestCommitMessage = '';
-      assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
-    });
-
-    test('commitCollapseToggle shown for long commit message', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
-    });
-
-    test('commitCollapseToggle functions', () => {
-      element._latestCommitMessage = _.times(35, String).join('\n');
-      assert.isTrue(element._commitCollapsed);
-      assert.isTrue(element._commitCollapsible);
-      assert.isTrue(
-          element.$.commitMessageEditor.hasAttribute('collapsed'));
-      MockInteractions.tap(element.$.commitCollapseToggleButton);
-      assert.isFalse(element._commitCollapsed);
-      assert.isTrue(element._commitCollapsible);
-      assert.isFalse(
-          element.$.commitMessageEditor.hasAttribute('collapsed'));
-    });
-  });
-
-  suite('related changes expand/collapse', () => {
-    let updateHeightSpy;
-    setup(() => {
-      updateHeightSpy = sandbox.spy(element, '_updateRelatedChangeMaxHeight');
-    });
-
-    test('relatedChangesToggle shown height greater than changeInfo height',
-        () => {
-          assert.isFalse(element.$.relatedChangesToggle.classList
-              .contains('showToggle'));
-          sandbox.stub(element, '_getOffsetHeight', () => 50);
-          sandbox.stub(element, '_getScrollHeight', () => 60);
-          sandbox.stub(element, '_getLineHeight', () => 5);
-          sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
-          element.$.relatedChanges.dispatchEvent(
-              new CustomEvent('new-section-loaded'));
-          assert.isTrue(element.$.relatedChangesToggle.classList
-              .contains('showToggle'));
-          assert.equal(updateHeightSpy.callCount, 1);
-        });
-
-    test('relatedChangesToggle hidden height less than changeInfo height',
-        () => {
-          assert.isFalse(element.$.relatedChangesToggle.classList
-              .contains('showToggle'));
-          sandbox.stub(element, '_getOffsetHeight', () => 50);
-          sandbox.stub(element, '_getScrollHeight', () => 40);
-          sandbox.stub(element, '_getLineHeight', () => 5);
-          sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
-          element.$.relatedChanges.dispatchEvent(
-              new CustomEvent('new-section-loaded'));
-          assert.isFalse(element.$.relatedChangesToggle.classList
-              .contains('showToggle'));
-          assert.equal(updateHeightSpy.callCount, 1);
-        });
-
-    test('relatedChangesToggle functions', () => {
-      sandbox.stub(element, '_getOffsetHeight', () => 50);
-      sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
-      element._relatedChangesLoading = false;
-      assert.isTrue(element._relatedChangesCollapsed);
-      assert.isTrue(
-          element.$.relatedChanges.classList.contains('collapsed'));
-      MockInteractions.tap(element.$.relatedChangesToggleButton);
-      assert.isFalse(element._relatedChangesCollapsed);
-      assert.isFalse(
-          element.$.relatedChanges.classList.contains('collapsed'));
-    });
-
-    test('_updateRelatedChangeMaxHeight without commit toggle', () => {
-      sandbox.stub(element, '_getOffsetHeight', () => 50);
-      sandbox.stub(element, '_getLineHeight', () => 12);
-      sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
-
-      // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
-      // 20 (max existing height)  % 12 (line height) = 6 (remainder).
-      // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
-
-      element._updateRelatedChangeMaxHeight();
-      assert.equal(getCustomCssValue('--relation-chain-max-height'),
-          '12px');
-      assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
-          '');
-    });
-
-    test('_updateRelatedChangeMaxHeight with commit toggle', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      sandbox.stub(element, '_getOffsetHeight', () => 50);
-      sandbox.stub(element, '_getLineHeight', () => 12);
-      sandbox.stub(window, 'matchMedia', () => { return {matches: false}; });
-
-      // 50 (existing height) % 12 (line height) = 2 (remainder).
-      // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
-
-      element._updateRelatedChangeMaxHeight();
-      assert.equal(getCustomCssValue('--relation-chain-max-height'),
-          '48px');
-      assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
-          '2px');
-    });
-
-    test('_updateRelatedChangeMaxHeight in small screen mode', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      sandbox.stub(element, '_getOffsetHeight', () => 50);
-      sandbox.stub(element, '_getLineHeight', () => 12);
-      sandbox.stub(window, 'matchMedia', () => { return {matches: true}; });
-
-      element._updateRelatedChangeMaxHeight();
-
-      // 400 (new height) % 12 (line height) = 4 (remainder).
-      // 400 (new height) - 4 (remainder) = 396.
-
-      assert.equal(getCustomCssValue('--relation-chain-max-height'),
-          '396px');
-    });
-
-    test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
-      element._latestCommitMessage = _.times(31, String).join('\n');
-      sandbox.stub(element, '_getOffsetHeight', () => 50);
-      sandbox.stub(element, '_getLineHeight', () => 12);
-      sandbox.stub(window, 'matchMedia', () => {
-        if (window.matchMedia.lastCall.args[0] === '(max-width: 75em)') {
-          return {matches: true};
-        } else {
-          return {matches: false};
-        }
-      });
-
-      // 100 (new height) % 12 (line height) = 4 (remainder).
-      // 100 (new height) - 4 (remainder) = 96.
-      element._updateRelatedChangeMaxHeight();
-      assert.equal(getCustomCssValue('--relation-chain-max-height'),
-          '96px');
-    });
-
-    suite('update checks', () => {
-      setup(() => {
-        sandbox.spy(element, '_startUpdateCheckTimer');
-        sandbox.stub(element, 'async', f => {
-          // Only fire the async callback one time.
-          if (element.async.callCount > 1) { return; }
-          f.call(element);
-        });
-      });
-
-      test('_startUpdateCheckTimer negative delay', () => {
-        sandbox.stub(element, 'fetchChangeUpdates');
-
-        element._serverConfig = {change: {update_delay: -1}};
-
-        assert.isTrue(element._startUpdateCheckTimer.called);
-        assert.isFalse(element.fetchChangeUpdates.called);
-      });
-
-      test('_startUpdateCheckTimer up-to-date', () => {
-        sandbox.stub(element, 'fetchChangeUpdates',
-            () => Promise.resolve({isLatest: true}));
-
-        element._serverConfig = {change: {update_delay: 12345}};
-
-        assert.isTrue(element._startUpdateCheckTimer.called);
-        assert.isTrue(element.fetchChangeUpdates.called);
-        assert.equal(element.async.lastCall.args[1], 12345 * 1000);
-      });
-
-      test('_startUpdateCheckTimer out-of-date shows an alert', done => {
-        sandbox.stub(element, 'fetchChangeUpdates',
-            () => Promise.resolve({isLatest: false}));
-        element.addEventListener('show-alert', e => {
-          assert.equal(e.detail.message,
-              'A newer patch set has been uploaded');
-          done();
-        });
-        element._serverConfig = {change: {update_delay: 12345}};
-      });
-
-      test('_startUpdateCheckTimer new status shows an alert', done => {
-        sandbox.stub(element, 'fetchChangeUpdates')
-            .returns(Promise.resolve({
-              isLatest: true,
-              newStatus: element.ChangeStatus.MERGED,
-            }));
-        element.addEventListener('show-alert', e => {
-          assert.equal(e.detail.message, 'This change has been merged');
-          done();
-        });
-        element._serverConfig = {change: {update_delay: 12345}};
-      });
-
-      test('_startUpdateCheckTimer new messages shows an alert', done => {
-        sandbox.stub(element, 'fetchChangeUpdates')
-            .returns(Promise.resolve({
-              isLatest: true,
-              newMessages: true,
-            }));
-        element.addEventListener('show-alert', e => {
-          assert.equal(e.detail.message,
-              'There are new messages on this change');
-          done();
-        });
-        element._serverConfig = {change: {update_delay: 12345}};
-      });
-    });
-
-    test('canStartReview computation', () => {
-      const change1 = {};
-      const change2 = {
-        actions: {
-          ready: {
-            enabled: true,
-          },
-        },
-      };
-      const change3 = {
-        actions: {
-          ready: {
-            label: 'Ready for Review',
-          },
-        },
-      };
-      assert.isFalse(element._computeCanStartReview(change1));
-      assert.isTrue(element._computeCanStartReview(change2));
-      assert.isFalse(element._computeCanStartReview(change3));
-    });
-  });
-
-  test('header class computation', () => {
-    assert.equal(element._computeHeaderClass(), 'header');
-    assert.equal(element._computeHeaderClass(true), 'header editMode');
-  });
-
-  test('_maybeScrollToMessage', done => {
-    flush(() => {
-      const scrollStub = sandbox.stub(element.messagesList,
-          'scrollToMessage');
-
-      element._maybeScrollToMessage('');
-      assert.isFalse(scrollStub.called);
-      element._maybeScrollToMessage('message');
-      assert.isFalse(scrollStub.called);
-      element._maybeScrollToMessage('#message-TEST');
-      assert.isTrue(scrollStub.called);
-      assert.equal(scrollStub.lastCall.args[0], 'TEST');
-      done();
-    });
-  });
-
-  test('topic update reloads related changes', () => {
-    sandbox.stub(element.$.relatedChanges, 'reload');
-    element.dispatchEvent(new CustomEvent('topic-changed'));
-    assert.isTrue(element.$.relatedChanges.reload.calledOnce);
-  });
-
-  test('_computeEditMode', () => {
-    const callCompute = (range, params) =>
-      element._computeEditMode({base: range}, {base: params});
-    assert.isFalse(callCompute({}, {}));
-    assert.isTrue(callCompute({}, {edit: true}));
-    assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}, {}));
-    assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}, {}));
-    assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}, {}));
-  });
-
-  test('_processEdit', () => {
-    element._patchRange = {};
-    const change = {
-      current_revision: 'foo',
-      revisions: {foo: {commit: {}, actions: {cherrypick: {enabled: true}}}},
-    };
-    let mockChange;
-
-    // With no edit, mockChange should be unmodified.
-    element._processEdit(mockChange = _.cloneDeep(change), null);
-    assert.deepEqual(mockChange, change);
-
-    // When edit is not based on the latest PS, current_revision should be
-    // unmodified.
-    const edit = {
-      base_patch_set_number: 1,
-      commit: {commit: 'bar'},
-      fetch: true,
-    };
-    element._processEdit(mockChange = _.cloneDeep(change), edit);
-    assert.notDeepEqual(mockChange, change);
-    assert.equal(mockChange.revisions.bar._number, element.EDIT_NAME);
-    assert.equal(mockChange.current_revision, change.current_revision);
-    assert.deepEqual(mockChange.revisions.bar.commit, {commit: 'bar'});
-    assert.notOk(mockChange.revisions.bar.actions);
-
-    edit.base_revision = 'foo';
-    element._processEdit(mockChange = _.cloneDeep(change), edit);
-    assert.notDeepEqual(mockChange, change);
-    assert.equal(mockChange.current_revision, 'bar');
-    assert.deepEqual(mockChange.revisions.bar.actions,
-        mockChange.revisions.foo.actions);
-
-    // If _patchRange.patchNum is defined, do not load edit.
-    element._patchRange.patchNum = 'baz';
-    change.current_revision = 'baz';
-    element._processEdit(mockChange = _.cloneDeep(change), edit);
-    assert.equal(element._patchRange.patchNum, 'baz');
-    assert.notOk(mockChange.revisions.bar.actions);
-  });
-
-  test('file-action-tap handling', () => {
-    element._patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 1,
-    };
-    const fileList = element.$.fileList;
-    const Actions = GrEditConstants.Actions;
-    const controls = element.$.fileListHeader.$.editControls;
-    sandbox.stub(controls, 'openDeleteDialog');
-    sandbox.stub(controls, 'openRenameDialog');
-    sandbox.stub(controls, 'openRestoreDialog');
-    sandbox.stub(GerritNav, 'getEditUrlForDiff');
-    sandbox.stub(GerritNav, 'navigateToRelativeUrl');
-
-    // Delete
-    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-      detail: {action: Actions.DELETE.id, path: 'foo'},
-      bubbles: true,
-      composed: true,
-    }));
-    flushAsynchronousOperations();
-
-    assert.isTrue(controls.openDeleteDialog.called);
-    assert.equal(controls.openDeleteDialog.lastCall.args[0], 'foo');
-
-    // Restore
-    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-      detail: {action: Actions.RESTORE.id, path: 'foo'},
-      bubbles: true,
-      composed: true,
-    }));
-    flushAsynchronousOperations();
-
-    assert.isTrue(controls.openRestoreDialog.called);
-    assert.equal(controls.openRestoreDialog.lastCall.args[0], 'foo');
-
-    // Rename
-    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-      detail: {action: Actions.RENAME.id, path: 'foo'},
-      bubbles: true,
-      composed: true,
-    }));
-    flushAsynchronousOperations();
-
-    assert.isTrue(controls.openRenameDialog.called);
-    assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
-
-    // Open
-    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
-      detail: {action: Actions.OPEN.id, path: 'foo'},
-      bubbles: true,
-      composed: true,
-    }));
-    flushAsynchronousOperations();
-
-    assert.isTrue(GerritNav.getEditUrlForDiff.called);
-    assert.equal(GerritNav.getEditUrlForDiff.lastCall.args[1], 'foo');
-    assert.equal(GerritNav.getEditUrlForDiff.lastCall.args[2], '1');
-    assert.isTrue(GerritNav.navigateToRelativeUrl.called);
-  });
-
-  test('_selectedRevision updates when patchNum is changed', () => {
-    const revision1 = {_number: 1, commit: {parents: []}};
-    const revision2 = {_number: 2, commit: {parents: []}};
-    sandbox.stub(element.$.restAPI, 'getChangeDetail').returns(
-        Promise.resolve({
-          revisions: {
-            aaa: revision1,
-            bbb: revision2,
-          },
-          labels: {},
-          actions: {},
-          current_revision: 'bbb',
-          change_id: 'loremipsumdolorsitamet',
-        }));
-    sandbox.stub(element, '_getEdit').returns(Promise.resolve());
-    sandbox.stub(element, '_getPreferences').returns(Promise.resolve({}));
-    element._patchRange = {patchNum: '2'};
-    return element._getChangeDetail().then(() => {
-      assert.strictEqual(element._selectedRevision, revision2);
-
-      element.set('_patchRange.patchNum', '1');
-      assert.strictEqual(element._selectedRevision, revision1);
-    });
-  });
-
-  test('_selectedRevision is assigned when patchNum is edit', () => {
-    const revision1 = {_number: 1, commit: {parents: []}};
-    const revision2 = {_number: 2, commit: {parents: []}};
-    const revision3 = {_number: 'edit', commit: {parents: []}};
-    sandbox.stub(element.$.restAPI, 'getChangeDetail').returns(
-        Promise.resolve({
-          revisions: {
-            aaa: revision1,
-            bbb: revision2,
-            ccc: revision3,
-          },
-          labels: {},
-          actions: {},
-          current_revision: 'ccc',
-          change_id: 'loremipsumdolorsitamet',
-        }));
-    sandbox.stub(element, '_getEdit').returns(Promise.resolve());
-    sandbox.stub(element, '_getPreferences').returns(Promise.resolve({}));
-    element._patchRange = {patchNum: 'edit'};
-    return element._getChangeDetail().then(() => {
-      assert.strictEqual(element._selectedRevision, revision3);
-    });
-  });
-
-  test('_sendShowChangeEvent', () => {
-    element._change = {labels: {}};
-    element._patchRange = {patchNum: 4};
-    element._mergeable = true;
-    const showStub = sandbox.stub(element.$.jsAPI, 'handleEvent');
-    element._sendShowChangeEvent();
-    assert.isTrue(showStub.calledOnce);
-    assert.equal(
-        showStub.lastCall.args[0], element.$.jsAPI.EventType.SHOW_CHANGE);
-    assert.deepEqual(showStub.lastCall.args[1], {
-      change: {labels: {}},
-      patchNum: 4,
-      info: {mergeable: true},
-    });
-  });
-
-  suite('_handleEditTap', () => {
-    let fireEdit;
-
-    setup(() => {
-      fireEdit = () => {
-        element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
-      };
-      navigateToChangeStub.restore();
-
-      element._change = {revisions: {rev1: {_number: 1}}};
-    });
-
-    test('edit exists in revisions', done => {
-      sandbox.stub(GerritNav, 'navigateToChange', (...args) => {
-        assert.equal(args.length, 2);
-        assert.equal(args[1], element.EDIT_NAME); // patchNum
-        done();
-      });
-
-      element.set('_change.revisions.rev2', {_number: element.EDIT_NAME});
-      flushAsynchronousOperations();
-
-      fireEdit();
-    });
-
-    test('no edit exists in revisions, non-latest patchset', done => {
-      sandbox.stub(GerritNav, 'navigateToChange', (...args) => {
-        assert.equal(args.length, 4);
-        assert.equal(args[1], 1); // patchNum
-        assert.equal(args[3], true); // opt_isEdit
-        done();
-      });
-
-      element.set('_change.revisions.rev2', {_number: 2});
-      element._patchRange = {patchNum: 1};
-      flushAsynchronousOperations();
-
-      fireEdit();
-    });
-
-    test('no edit exists in revisions, latest patchset', done => {
-      sandbox.stub(GerritNav, 'navigateToChange', (...args) => {
-        assert.equal(args.length, 4);
-        // No patch should be specified when patchNum == latest.
-        assert.isNotOk(args[1]); // patchNum
-        assert.equal(args[3], true); // opt_isEdit
-        done();
-      });
-
-      element.set('_change.revisions.rev2', {_number: 2});
-      element._patchRange = {patchNum: 2};
-      flushAsynchronousOperations();
-
-      fireEdit();
-    });
-  });
-
-  test('_handleStopEditTap', done => {
-    sandbox.stub(element.$.metadata, '_computeLabelNames');
-    navigateToChangeStub.restore();
-    sandbox.stub(element, 'computeLatestPatchNum').returns(1);
-    sandbox.stub(GerritNav, 'navigateToChange', (...args) => {
-      assert.equal(args.length, 2);
-      assert.equal(args[1], 1); // patchNum
-      done();
-    });
-
-    element._patchRange = {patchNum: 1};
-    element.$.actions.dispatchEvent(new CustomEvent('stop-edit-tap',
-        {bubbles: false}));
-  });
-
-  suite('plugin endpoints', () => {
-    test('endpoint params', done => {
-      element._change = {labels: {}};
-      element._selectedRevision = {};
-      let hookEl;
-      let plugin;
-      pluginApi.install(
-          p => {
-            plugin = p;
-            plugin.hook('change-view-integration').getLastAttached()
-                .then(
-                    el => hookEl = el);
-          },
-          '0.1',
-          'http://some/plugins/url.html');
-      flush(() => {
-        assert.strictEqual(hookEl.plugin, plugin);
-        assert.strictEqual(hookEl.change, element._change);
-        assert.strictEqual(hookEl.revision, element._selectedRevision);
-        done();
-      });
-    });
-  });
-
-  suite('_getMergeability', () => {
-    let getMergeableStub;
-
-    setup(() => {
-      element._change = {labels: {}};
-      getMergeableStub = sandbox.stub(element.$.restAPI, 'getMergeable')
-          .returns(Promise.resolve({mergeable: true}));
-    });
-
-    test('merged change', () => {
-      element._mergeable = null;
-      element._change.status = element.ChangeStatus.MERGED;
-      return element._getMergeability().then(() => {
-        assert.isFalse(element._mergeable);
-        assert.isFalse(getMergeableStub.called);
-      });
-    });
-
-    test('abandoned change', () => {
-      element._mergeable = null;
-      element._change.status = element.ChangeStatus.ABANDONED;
-      return element._getMergeability().then(() => {
-        assert.isFalse(element._mergeable);
-        assert.isFalse(getMergeableStub.called);
-      });
-    });
-
-    test('open change', () => {
-      element._mergeable = null;
-      return element._getMergeability().then(() => {
-        assert.isTrue(element._mergeable);
-        assert.isTrue(getMergeableStub.called);
-      });
-    });
-  });
-
-  test('_paramsChanged sets in projectLookup', () => {
-    sandbox.stub(element.$.relatedChanges, 'reload');
-    sandbox.stub(element, '_reload').returns(Promise.resolve());
-    const setStub = sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-    element._paramsChanged({
-      view: GerritNav.View.CHANGE,
-      changeNum: 101,
-      project: 'test-project',
-    });
-    assert.isTrue(setStub.calledOnce);
-    assert.isTrue(setStub.calledWith(101, 'test-project'));
-  });
-
-  test('_handleToggleStar called when star is tapped', () => {
-    element._change = {
-      owner: {_account_id: 1},
-      starred: false,
-    };
-    element._loggedIn = true;
-    const stub = sandbox.stub(element, '_handleToggleStar');
-    flushAsynchronousOperations();
-
-    MockInteractions.tap(element.$.changeStar.shadowRoot
-        .querySelector('button'));
-    assert.isTrue(stub.called);
-  });
-
-  suite('gr-reporting tests', () => {
-    setup(() => {
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      sandbox.stub(element, '_getChangeDetail').returns(Promise.resolve());
-      sandbox.stub(element, '_getProjectConfig').returns(Promise.resolve());
-      sandbox.stub(element, '_reloadComments').returns(Promise.resolve());
-      sandbox.stub(element, '_getMergeability').returns(Promise.resolve());
-      sandbox.stub(element, '_getLatestCommitMessage')
-          .returns(Promise.resolve());
-    });
-
-    test('don\'t report changedDisplayed on reply', done => {
-      const changeDisplayStub =
-        sandbox.stub(element.$.reporting, 'changeDisplayed');
-      const changeFullyLoadedStub =
-        sandbox.stub(element.$.reporting, 'changeFullyLoaded');
-      element._handleReplySent();
-      flush(() => {
-        assert.isFalse(changeDisplayStub.called);
-        assert.isFalse(changeFullyLoadedStub.called);
-        done();
-      });
-    });
-
-    test('report changedDisplayed on _paramsChanged', done => {
-      const changeDisplayStub =
-        sandbox.stub(element.$.reporting, 'changeDisplayed');
-      const changeFullyLoadedStub =
-        sandbox.stub(element.$.reporting, 'changeFullyLoaded');
-      element._paramsChanged({
-        view: GerritNav.View.CHANGE,
-        changeNum: 101,
-        project: 'test-project',
-      });
-      flush(() => {
-        assert.isTrue(changeDisplayStub.called);
-        assert.isTrue(changeFullyLoadedStub.called);
-        done();
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
new file mode 100644
index 0000000..a66c6a2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
@@ -0,0 +1,2469 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../edit/gr-edit-constants.js';
+import './gr-change-view.js';
+import {PrimaryTab, SecondaryTab, ChangeStatus} from '../../../constants/constants.js';
+
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrEditConstants} from '../../edit/gr-edit-constants.js';
+import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {getComputedStyleValue} from '../../../utils/dom-util.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
+import 'lodash/lodash.js';
+import {generateChange, TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+const fixture = fixtureFromElement('gr-change-view');
+
+suite('gr-change-view tests', () => {
+  let element;
+
+  let navigateToChangeStub;
+
+  suiteSetup(() => {
+    const kb = TestKeyboardShortcutBinder.push();
+    kb.bindShortcut(Shortcut.SEND_REPLY, 'ctrl+enter');
+    kb.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r');
+    kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
+    kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+    kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
+    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
+    kb.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
+    kb.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
+    kb.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+    kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
+    kb.bindShortcut(Shortcut.EDIT_TOPIC, 't');
+  });
+
+  suiteTeardown(() => {
+    TestKeyboardShortcutBinder.pop();
+  });
+
+  const TEST_SCROLL_TOP_PX = 100;
+
+  const ROBOT_COMMENTS_LIMIT = 10;
+
+  // TODO: should have a mock service to generate VALID fake data
+  const THREADS = [
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2,
+          robot_id: 'rb1',
+          id: 'ecf0b9fa_fe1a5f62',
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'ecf0b9fa_fe1a5f62_1',
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          id: '503008e2_0ab203ee',
+          path: '/COMMIT_MSG',
+          line: 5,
+          in_reply_to: 'ecf0b9fa_fe1a5f62',
+          updated: '2018-02-13 22:48:48.018000000',
+          message: 'draft',
+          unresolved: false,
+          __draft: true,
+          __draftID: '0.m683trwff68',
+          __editing: false,
+          patch_set: '2',
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'ecf0b9fa_fe1a5f62',
+      start_datetime: '2018-02-08 18:49:18.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 3,
+          id: 'ecf0b9fa_fe5f62',
+          robot_id: 'rb2',
+          line: 5,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+        {
+          __path: 'test.txt',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 3,
+          id: '09a9fb0a_1484e6cf',
+          side: 'PARENT',
+          updated: '2018-02-13 22:47:19.000000000',
+          message: 'Some comment on another patchset.',
+          unresolved: false,
+        },
+      ],
+      patchNum: 3,
+      path: 'test.txt',
+      rootId: '09a9fb0a_1484e6cf',
+      start_datetime: '2018-02-13 22:47:19.000000000',
+      commentSide: 'PARENT',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2,
+          id: '8caddf38_44770ec1',
+          line: 4,
+          updated: '2018-02-13 22:48:40.000000000',
+          message: 'Another unresolved comment',
+          unresolved: true,
+        },
+      ],
+      patchNum: 2,
+      path: '/COMMIT_MSG',
+      line: 4,
+      rootId: '8caddf38_44770ec1',
+      start_datetime: '2018-02-13 22:48:40.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 2,
+          id: 'scaddf38_44770ec1',
+          line: 4,
+          updated: '2018-02-14 22:48:40.000000000',
+          message: 'Yet another unresolved comment',
+          unresolved: true,
+        },
+      ],
+      patchNum: 2,
+      path: '/COMMIT_MSG',
+      line: 4,
+      rootId: 'scaddf38_44770ec1',
+      start_datetime: '2018-02-14 22:48:40.000000000',
+    },
+    {
+      comments: [
+        {
+          id: 'zcf0b9fa_fe1a5f62',
+          path: '/COMMIT_MSG',
+          line: 6,
+          updated: '2018-02-15 22:48:48.018000000',
+          message: 'resolved draft',
+          unresolved: false,
+          __draft: true,
+          __draftID: '0.m683trwff68',
+          __editing: false,
+          patch_set: '2',
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 6,
+      rootId: 'zcf0b9fa_fe1a5f62',
+      start_datetime: '2018-02-09 18:49:18.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'rc1',
+          line: 5,
+          updated: '2019-02-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+          robot_id: 'rc1',
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'rc1',
+      start_datetime: '2019-02-08 18:49:18.000000000',
+    },
+    {
+      comments: [
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'rc2',
+          line: 5,
+          updated: '2019-03-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+          robot_id: 'rc2',
+        },
+        {
+          __path: '/COMMIT_MSG',
+          author: {
+            _account_id: 1000000,
+            name: 'user',
+            username: 'user',
+          },
+          patch_set: 4,
+          id: 'c2_1',
+          line: 5,
+          updated: '2019-03-08 18:49:18.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+      ],
+      patchNum: 4,
+      path: '/COMMIT_MSG',
+      line: 5,
+      rootId: 'rc2',
+      start_datetime: '2019-03-08 18:49:18.000000000',
+    },
+  ];
+
+  setup(() => {
+    // Since pluginEndpoints are global, must reset state.
+    _testOnly_resetEndpoints();
+    navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({test: 'config'}); },
+      getAccount() { return Promise.resolve(null); },
+      getDiffComments() { return Promise.resolve({}); },
+      getDiffRobotComments() { return Promise.resolve({}); },
+      getDiffDrafts() { return Promise.resolve({}); },
+      _fetchSharedCacheURL() { return Promise.resolve({}); },
+    });
+    element = fixture.instantiate();
+    sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
+    pluginLoader.loadPlugins([]);
+    pluginApi.install(
+        plugin => {
+          plugin.registerDynamicCustomComponent(
+              'change-view-tab-header',
+              'gr-checks-change-view-tab-header-view'
+          );
+          plugin.registerDynamicCustomComponent(
+              'change-view-tab-content',
+              'gr-checks-view'
+          );
+        },
+        '0.1',
+        'http://some/plugins/url.html'
+    );
+  });
+
+  teardown(done => {
+    flush(() => {
+      done();
+    });
+  });
+
+  const getCustomCssValue =
+      cssParam => getComputedStyleValue(cssParam, element);
+
+  test('_handleMessageAnchorTap', () => {
+    element._changeNum = '1';
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 1,
+    };
+    const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
+    const replaceStateStub = sinon.stub(history, 'replaceState');
+    element._handleMessageAnchorTap({detail: {id: 'a12345'}});
+
+    assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+    assert.isTrue(replaceStateStub.called);
+  });
+
+  test('_handleDiffAgainstBase', () => {
+    element._change = generateChange({revisionsCount: 10});
+    element._patchRange = {
+      patchNum: 3,
+      basePatchNum: 1,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffAgainstBase(new CustomEvent(''));
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[0], element._change);
+    assert.equal(args[1], 3);
+  });
+
+  test('_handleDiffAgainstLatest', () => {
+    element._change = generateChange({revisionsCount: 10});
+    element._patchRange = {
+      basePatchNum: 1,
+      patchNum: 3,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffAgainstLatest(new CustomEvent(''));
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[0], element._change);
+    assert.equal(args[1], 10);
+    assert.equal(args[2], 1);
+  });
+
+  test('_handleDiffBaseAgainstLeft', () => {
+    element._change = generateChange({revisionsCount: 10});
+    element._patchRange = {
+      patchNum: 3,
+      basePatchNum: 1,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffBaseAgainstLeft(new CustomEvent(''));
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[0], element._change);
+    assert.equal(args[1], 1);
+  });
+
+  test('_handleDiffRightAgainstLatest', () => {
+    element._change = generateChange({revisionsCount: 10});
+    element._patchRange = {
+      basePatchNum: 1,
+      patchNum: 3,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffRightAgainstLatest(new CustomEvent(''));
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[1], 10);
+    assert.equal(args[2], 3);
+  });
+
+  test('_handleDiffBaseAgainstLatest', () => {
+    element._change = generateChange({revisionsCount: 10});
+    element._patchRange = {
+      basePatchNum: 1,
+      patchNum: 3,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+    element._handleDiffBaseAgainstLatest(new CustomEvent(''));
+    assert(navigateToChangeStub.called);
+    const args = navigateToChangeStub.getCall(0).args;
+    assert.equal(args[1], 10);
+    assert.isNotOk(args[2]);
+  });
+
+  suite('plugins adding to file tab', () => {
+    setup(done => {
+      // Resolving it here instead of during setup() as other tests depend
+      // on flush() not being called during setup.
+      flush(() => done());
+    });
+
+    test('plugin added tab shows up as a dynamic endpoint', () => {
+      assert(element._dynamicTabHeaderEndpoints.includes(
+          'change-view-tab-header-url'));
+      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
+      // 4 Tabs are : Files, Comment Threads, Plugin, Findings
+      assert.equal(paperTabs.querySelectorAll('paper-tab').length, 4);
+      assert.equal(paperTabs.querySelectorAll('paper-tab')[2].dataset.name,
+          'change-view-tab-header-url');
+    });
+
+    test('_setActivePrimaryTab switched tab correctly', done => {
+      element._setActivePrimaryTab({detail:
+          {tab: 'change-view-tab-header-url'}});
+      flush(() => {
+        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+        done();
+      });
+    });
+
+    test('show-primary-tab switched primary tab correctly', done => {
+      element.dispatchEvent(
+          new CustomEvent('show-primary-tab', {
+            composed: true,
+            bubbles: true,
+            detail: {
+              tab: 'change-view-tab-header-url',
+            },
+          }));
+      flush(() => {
+        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+        done();
+      });
+    });
+
+    test('param change should switch primary tab correctly', done => {
+      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      const queryMap = new Map();
+      queryMap.set('tab', PrimaryTab.FINDINGS);
+      // view is required
+      element.params = {
+        view: GerritNav.View.CHANGE,
+        ...element.params, queryMap};
+      flush(() => {
+        assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
+        done();
+      });
+    });
+
+    test('invalid param change should not switch primary tab', done => {
+      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      const queryMap = new Map();
+      queryMap.set('tab', 'random');
+      // view is required
+      element.params = {
+        view: GerritNav.View.CHANGE,
+        ...element.params, queryMap};
+      flush(() => {
+        assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+        done();
+      });
+    });
+
+    test('switching tab sets _selectedTabPluginEndpoint', done => {
+      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
+      MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[2]);
+      flush(() => {
+        assert.equal(element._selectedTabPluginEndpoint,
+            'change-view-tab-content-url');
+        done();
+      });
+    });
+  });
+
+  suite('keyboard shortcuts', () => {
+    test('t to add topic', () => {
+      const editStub = sinon.stub(element.$.metadata, 'editTopic');
+      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 't');
+      assert(editStub.called);
+    });
+
+    test('S should toggle the CL star', () => {
+      const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
+      MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
+      assert(starStub.called);
+    });
+
+    test('U should navigate to root if no backPage set', () => {
+      const relativeNavStub = sinon.stub(GerritNav,
+          'navigateToRelativeUrl');
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert.isTrue(relativeNavStub.called);
+      assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
+          GerritNav.getUrlForRoot()));
+    });
+
+    test('U should navigate to backPage if set', () => {
+      const relativeNavStub = sinon.stub(GerritNav,
+          'navigateToRelativeUrl');
+      element.backPage = '/dashboard/self';
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert.isTrue(relativeNavStub.called);
+      assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
+          '/dashboard/self'));
+    });
+
+    test('A fires an error event when not logged in', done => {
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+      const loggedInErrorSpy = sinon.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      flush(() => {
+        assert.isFalse(element.$.replyOverlay.opened);
+        assert.isTrue(loggedInErrorSpy.called);
+        done();
+      });
+    });
+
+    test('shift A does not open reply overlay', done => {
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+      flush(() => {
+        assert.isFalse(element.$.replyOverlay.opened);
+        done();
+      });
+    });
+
+    test('A toggles overlay when logged in', done => {
+      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+      element._change = generateChange({
+        revisionsCount: 1,
+        messagesCount: 1,
+      });
+      element._change.labels = {};
+      sinon.stub(element.$.restAPI, 'getChangeDetail')
+          .callsFake(() => Promise.resolve(generateChange({
+            // element has latest info
+            revisionsCount: 1,
+            messagesCount: 1,
+          })));
+
+      const openSpy = sinon.spy(element, '_openReplyDialog');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      flush(() => {
+        assert.isTrue(element.$.replyOverlay.opened);
+        element.$.replyOverlay.close();
+        assert.isFalse(element.$.replyOverlay.opened);
+        assert(openSpy.lastCall.calledWithExactly(
+            element.$.replyDialog.FocusTarget.ANY),
+        '_openReplyDialog should have been passed ANY');
+        assert.equal(openSpy.callCount, 1);
+        done();
+      });
+    });
+
+    test('fullscreen-overlay-opened hides content', () => {
+      element._loggedIn = true;
+      element._loading = false;
+      element._change = {
+        owner: {_account_id: 1},
+        labels: {},
+        actions: {
+          abandon: {
+            enabled: true,
+            label: 'Abandon',
+            method: 'POST',
+            title: 'Abandon',
+          },
+        },
+      };
+      sinon.spy(element, '_handleHideBackgroundContent');
+      element.$.replyDialog.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-opened', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleHideBackgroundContent.called);
+      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+      assert.equal(getComputedStyle(element.$.actions).display, 'flex');
+    });
+
+    test('fullscreen-overlay-closed shows content', () => {
+      element._loggedIn = true;
+      element._loading = false;
+      element._change = {
+        owner: {_account_id: 1},
+        labels: {},
+        actions: {
+          abandon: {
+            enabled: true,
+            label: 'Abandon',
+            method: 'POST',
+            title: 'Abandon',
+          },
+        },
+      };
+      sinon.spy(element, '_handleShowBackgroundContent');
+      element.$.replyDialog.dispatchEvent(
+          new CustomEvent('fullscreen-overlay-closed', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleShowBackgroundContent.called);
+      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('expand all messages when expand-diffs fired', () => {
+      const handleExpand =
+          sinon.stub(element.$.fileList, 'expandAllDiffs');
+      element.$.fileListHeader.dispatchEvent(
+          new CustomEvent('expand-diffs', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(handleExpand.called);
+    });
+
+    test('collapse all messages when collapse-diffs fired', () => {
+      const handleCollapse =
+      sinon.stub(element.$.fileList, 'collapseAllDiffs');
+      element.$.fileListHeader.dispatchEvent(
+          new CustomEvent('collapse-diffs', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(handleCollapse.called);
+    });
+
+    test('X should expand all messages', done => {
+      flush(() => {
+        const handleExpand = sinon.stub(element.messagesList,
+            'handleExpandCollapse');
+        MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'x');
+        assert(handleExpand.calledWith(true));
+        done();
+      });
+    });
+
+    test('Z should collapse all messages', done => {
+      flush(() => {
+        const handleExpand = sinon.stub(element.messagesList,
+            'handleExpandCollapse');
+        MockInteractions.pressAndReleaseKeyOn(element, 90, null, 'z');
+        assert(handleExpand.calledWith(false));
+        done();
+      });
+    });
+
+    test('reload event from reply dialog is processed', () => {
+      const handleReloadStub = sinon.stub(element, '_reload');
+      element.$.replyDialog.dispatchEvent(new CustomEvent('reload',
+          {detail: {clearPatchset: true}, bubbles: true, composed: true}));
+      assert.isTrue(handleReloadStub.called);
+    });
+
+    test('shift + R should fetch and navigate to the latest patch set',
+        done => {
+          element._changeNum = '42';
+          element._patchRange = {
+            basePatchNum: 'PARENT',
+            patchNum: 1,
+          };
+          element._change = {
+            change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+            _number: 42,
+            revisions: {
+              rev1: {_number: 1, commit: {parents: []}},
+            },
+            current_revision: 'rev1',
+            status: 'NEW',
+            labels: {},
+            actions: {},
+          };
+
+          const reloadChangeStub = sinon.stub(element, '_reload');
+          MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+          flush(() => {
+            assert.isTrue(reloadChangeStub.called);
+            done();
+          });
+        });
+
+    test('d should open download overlay', () => {
+      const stub = sinon.stub(element.$.downloadOverlay, 'open').returns(
+          new Promise(resolve => {})
+      );
+      MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
+      assert.isTrue(stub.called);
+    });
+
+    test(', should open diff preferences', () => {
+      const stub = sinon.stub(
+          element.$.fileList.$.diffPreferencesDialog, 'open');
+      element._loggedIn = false;
+      element.disableDiffPrefs = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isFalse(stub.called);
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isFalse(stub.called);
+
+      element.disableDiffPrefs = false;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert.isTrue(stub.called);
+    });
+
+    test('m should toggle diff mode', () => {
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const setModeStub = sinon.stub(element.$.fileListHeader,
+          'setDiffViewMode');
+      const e = {preventDefault: () => {}};
+      flushAsynchronousOperations();
+
+      element.viewState.diffMode = 'SIDE_BY_SIDE';
+      element._handleToggleDiffMode(e);
+      assert.isTrue(setModeStub.calledWith('UNIFIED_DIFF'));
+
+      element.viewState.diffMode = 'UNIFIED_DIFF';
+      element._handleToggleDiffMode(e);
+      assert.isTrue(setModeStub.calledWith('SIDE_BY_SIDE'));
+    });
+  });
+
+  suite('reloading drafts', () => {
+    let reloadStub;
+    const drafts = {
+      'testfile.txt': [
+        {
+          patch_set: 5,
+          id: 'dd2982f5_c01c9e6a',
+          line: 1,
+          updated: '2017-11-08 18:47:45.000000000',
+          message: 'test',
+          unresolved: true,
+        },
+      ],
+    };
+    setup(() => {
+      // Fake computeDraftCount as its required for ChangeComments,
+      // see gr-comment-api#reloadDrafts.
+      reloadStub = sinon.stub(element.$.commentAPI, 'reloadDrafts')
+          .returns(Promise.resolve({
+            drafts,
+            getAllThreadsForChange: () => ([]),
+            computeDraftCount: () => 1,
+          }));
+    });
+
+    test('drafts are reloaded when reload-drafts fired', done => {
+      element.$.fileList.dispatchEvent(
+          new CustomEvent('reload-drafts', {
+            detail: {
+              resolve: () => {
+                assert.isTrue(reloadStub.called);
+                assert.deepEqual(element._diffDrafts, drafts);
+                done();
+              },
+            },
+            composed: true, bubbles: true,
+          }));
+    });
+
+    test('drafts are reloaded when comment-refresh fired', () => {
+      element.dispatchEvent(
+          new CustomEvent('comment-refresh', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(reloadStub.called);
+    });
+  });
+
+  suite('_recomputeComments', () => {
+    setup(() => {
+      // Fake computeDraftCount as its required for ChangeComments,
+      // see gr-comment-api#reloadDrafts.
+      sinon.stub(element.$.commentAPI, 'reloadDrafts')
+          .returns(Promise.resolve({
+            drafts: {},
+            getAllThreadsForChange: () => THREADS,
+            computeDraftCount: () => 0,
+          }));
+    });
+
+    test('draft threads should be a new copy with correct states', done => {
+      element.$.fileList.dispatchEvent(
+          new CustomEvent('reload-drafts', {
+            detail: {
+              resolve: () => {
+                assert.equal(element._draftCommentThreads.length, 2);
+                assert.equal(
+                    element._draftCommentThreads[0].rootId,
+                    THREADS[0].rootId
+                );
+                assert.notEqual(
+                    element._draftCommentThreads[0].comments,
+                    THREADS[0].comments
+                );
+                assert.notEqual(
+                    element._draftCommentThreads[0].comments[0],
+                    THREADS[0].comments[0]
+                );
+                assert.isTrue(
+                    element._draftCommentThreads[0]
+                        .comments
+                        .slice(0, 2)
+                        .every(c => c.collapsed === true)
+                );
+
+                assert.isTrue(
+                    element._draftCommentThreads[0]
+                        .comments[2]
+                        .collapsed === false
+                );
+                done();
+              },
+            },
+            composed: true, bubbles: true,
+          }));
+    });
+  });
+
+  test('diff comments modified', () => {
+    sinon.spy(element, '_handleReloadCommentThreads');
+    return element._reloadComments().then(() => {
+      element.dispatchEvent(
+          new CustomEvent('diff-comments-modified', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleReloadCommentThreads.called);
+    });
+  });
+
+  test('thread list modified', () => {
+    sinon.spy(element, '_handleReloadDiffComments');
+    element._activeTabs = [PrimaryTab.COMMENT_THREADS, SecondaryTab.CHANGE_LOG];
+    flushAsynchronousOperations();
+
+    return element._reloadComments().then(() => {
+      element.threadList.dispatchEvent(
+          new CustomEvent('thread-list-modified', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._handleReloadDiffComments.called);
+
+      let draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+          .returns(1);
+      assert.equal(element._computeTotalCommentCounts(5,
+          element._changeComments), '5 unresolved, 1 draft');
+      assert.equal(element._computeTotalCommentCounts(0,
+          element._changeComments), '1 draft');
+      draftStub.restore();
+      draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+          .returns(0);
+      assert.equal(element._computeTotalCommentCounts(0,
+          element._changeComments), '');
+      assert.equal(element._computeTotalCommentCounts(1,
+          element._changeComments), '1 unresolved');
+      draftStub.restore();
+      draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
+          .returns(2);
+      assert.equal(element._computeTotalCommentCounts(1,
+          element._changeComments), '1 unresolved, 2 drafts');
+      draftStub.restore();
+    });
+  });
+
+  suite('thread list and change log tabs', () => {
+    setup(() => {
+      element._changeNum = '1';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2, commit: {parents: []}},
+          rev1: {_number: 1, commit: {parents: []}},
+          rev13: {_number: 13, commit: {parents: []}},
+          rev3: {_number: 3, commit: {parents: []}},
+        },
+        current_revision: 'rev3',
+        status: 'NEW',
+        labels: {
+          test: {
+            all: [],
+            default_value: 0,
+            values: [],
+            approved: {},
+          },
+        },
+      };
+      sinon.stub(element.$.relatedChanges, 'reload');
+      sinon.stub(element, '_reload').returns(Promise.resolve());
+      sinon.spy(element, '_paramsChanged');
+      element.params = {view: 'change', changeNum: '1'};
+    });
+  });
+
+  suite('Findings comment tab', () => {
+    setup(done => {
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2, commit: {parents: []}},
+          rev1: {_number: 1, commit: {parents: []}},
+          rev13: {_number: 13, commit: {parents: []}},
+          rev3: {_number: 3, commit: {parents: []}},
+          rev4: {_number: 4, commit: {parents: []}},
+        },
+        current_revision: 'rev4',
+      };
+      element._commentThreads = THREADS;
+      const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
+      MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[3]);
+      flush(() => {
+        done();
+      });
+    });
+
+    test('robot comments count per patchset', () => {
+      const count = element._robotCommentCountPerPatchSet(THREADS);
+      const expectedCount = {
+        2: 1,
+        3: 1,
+        4: 2,
+      };
+      assert.deepEqual(count, expectedCount);
+      assert.equal(element._computeText({_number: 2}, THREADS),
+          'Patchset 2 (1 finding)');
+      assert.equal(element._computeText({_number: 4}, THREADS),
+          'Patchset 4 (2 findings)');
+      assert.equal(element._computeText({_number: 5}, THREADS),
+          'Patchset 5');
+    });
+
+    test('only robot comments are rendered', () => {
+      assert.equal(element._robotCommentThreads.length, 2);
+      assert.equal(element._robotCommentThreads[0].comments[0].robot_id,
+          'rc1');
+      assert.equal(element._robotCommentThreads[1].comments[0].robot_id,
+          'rc2');
+    });
+
+    test('changing patchsets resets robot comments', done => {
+      element.set('_change.current_revision', 'rev3');
+      flush(() => {
+        assert.equal(element._robotCommentThreads.length, 1);
+        done();
+      });
+    });
+
+    test('Show more button is hidden', () => {
+      assert.isNull(element.shadowRoot.querySelector('.show-robot-comments'));
+    });
+
+    suite('robot comments show more button', () => {
+      setup(done => {
+        const arr = [];
+        for (let i = 0; i <= 30; i++) {
+          arr.push(...THREADS);
+        }
+        element._commentThreads = arr;
+        flush(() => {
+          done();
+        });
+      });
+
+      test('Show more button is rendered', () => {
+        assert.isOk(element.shadowRoot.querySelector('.show-robot-comments'));
+        assert.equal(element._robotCommentThreads.length,
+            ROBOT_COMMENTS_LIMIT);
+      });
+
+      test('Clicking show more button renders all comments', done => {
+        MockInteractions.tap(element.shadowRoot.querySelector(
+            '.show-robot-comments'));
+        flush(() => {
+          assert.equal(element._robotCommentThreads.length, 62);
+          done();
+        });
+      });
+    });
+  });
+
+  test('reply button is not visible when logged out', () => {
+    assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
+    element._loggedIn = true;
+    assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
+  });
+
+  test('download tap calls _handleOpenDownloadDialog', () => {
+    sinon.stub(element, '_handleOpenDownloadDialog');
+    element.$.actions.dispatchEvent(
+        new CustomEvent('download-tap', {
+          composed: true, bubbles: true,
+        }));
+    assert.isTrue(element._handleOpenDownloadDialog.called);
+  });
+
+  test('fetches the server config on attached', done => {
+    flush(() => {
+      assert.equal(element._serverConfig.test, 'config');
+      done();
+    });
+  });
+
+  test('_changeStatuses', () => {
+    element._loading = false;
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev2: {_number: 2},
+        rev1: {_number: 1},
+        rev13: {_number: 13},
+        rev3: {_number: 3},
+      },
+      current_revision: 'rev3',
+      status: ChangeStatus.MERGED,
+      work_in_progress: true,
+      labels: {
+        test: {
+          all: [],
+          default_value: 0,
+          values: [],
+          approved: {},
+        },
+      },
+    };
+    element._mergeable = true;
+    const expectedStatuses = ['Merged', 'WIP'];
+    assert.deepEqual(element._changeStatuses, expectedStatuses);
+    assert.equal(element._changeStatus, expectedStatuses.join(', '));
+    flushAsynchronousOperations();
+    const statusChips = dom(element.root)
+        .querySelectorAll('gr-change-status');
+    assert.equal(statusChips.length, 2);
+  });
+
+  test('diff preferences open when open-diff-prefs is fired', () => {
+    const overlayOpenStub = sinon.stub(element.$.fileList,
+        'openDiffPrefs');
+    element.$.fileListHeader.dispatchEvent(
+        new CustomEvent('open-diff-prefs', {
+          composed: true, bubbles: true,
+        }));
+    assert.isTrue(overlayOpenStub.called);
+  });
+
+  test('_prepareCommitMsgForLinkify', () => {
+    let commitMessage = 'R=test@google.com';
+    let result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'R=\u200Btest@google.com');
+
+    commitMessage = 'R=test@google.com\nR=test@google.com';
+    result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
+
+    commitMessage = 'CC=test@google.com';
+    result = element._prepareCommitMsgForLinkify(commitMessage);
+    assert.equal(result, 'CC=\u200Btest@google.com');
+  });
+
+  test('_isSubmitEnabled', () => {
+    assert.isFalse(element._isSubmitEnabled({}));
+    assert.isFalse(element._isSubmitEnabled({submit: {}}));
+    assert.isTrue(element._isSubmitEnabled(
+        {submit: {enabled: true}}));
+  });
+
+  test('_reload is called when an approved label is removed', () => {
+    const vote = {_account_id: 1, name: 'bojack', value: 1};
+    element._changeNum = '42';
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 1,
+    };
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      owner: {email: 'abc@def'},
+      revisions: {
+        rev2: {_number: 2, commit: {parents: []}},
+        rev1: {_number: 1, commit: {parents: []}},
+        rev13: {_number: 13, commit: {parents: []}},
+        rev3: {_number: 3, commit: {parents: []}},
+      },
+      current_revision: 'rev3',
+      status: 'NEW',
+      labels: {
+        test: {
+          all: [vote],
+          default_value: 0,
+          values: [],
+          approved: {},
+        },
+      },
+    };
+    flushAsynchronousOperations();
+    const reloadStub = sinon.stub(element, '_reload');
+    element.splice('_change.labels.test.all', 0, 1);
+    assert.isFalse(reloadStub.called);
+    element._change.labels.test.all.push(vote);
+    element._change.labels.test.all.push(vote);
+    element._change.labels.test.approved = vote;
+    flushAsynchronousOperations();
+    element.splice('_change.labels.test.all', 0, 2);
+    assert.isTrue(reloadStub.called);
+    assert.isTrue(reloadStub.calledOnce);
+  });
+
+  test('reply button has updated count when there are drafts', () => {
+    const getLabel = element._computeReplyButtonLabel;
+
+    assert.equal(getLabel(null, false), 'Reply');
+    assert.equal(getLabel(null, true), 'Start Review');
+
+    const changeRecord = {base: null};
+    assert.equal(getLabel(changeRecord, false), 'Reply');
+
+    changeRecord.base = {};
+    assert.equal(getLabel(changeRecord, false), 'Reply');
+
+    changeRecord.base = {
+      'file1.txt': [{}],
+      'file2.txt': [{}, {}],
+    };
+    assert.equal(getLabel(changeRecord, false), 'Reply (3)');
+    assert.equal(getLabel(changeRecord, true), 'Start Review (3)');
+  });
+
+  test('comment events properly update diff drafts', () => {
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    const draft = {
+      __draft: true,
+      id: 'id1',
+      path: '/foo/bar.txt',
+      text: 'hello',
+    };
+    element._handleCommentSave({detail: {comment: draft}});
+    draft.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+    draft.patch_set = null;
+    draft.text = 'hello, there';
+    element._handleCommentSave({detail: {comment: draft}});
+    draft.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+    const draft2 = {
+      __draft: true,
+      id: 'id2',
+      path: '/foo/bar.txt',
+      text: 'hola',
+    };
+    element._handleCommentSave({detail: {comment: draft2}});
+    draft2.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
+    draft.patch_set = null;
+    element._handleCommentDiscard({detail: {comment: draft}});
+    draft.patch_set = 2;
+    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
+    element._handleCommentDiscard({detail: {comment: draft2}});
+    assert.deepEqual(element._diffDrafts, {});
+  });
+
+  test('change num change', () => {
+    element._changeNum = null;
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      labels: {},
+    };
+    element.viewState.changeNum = null;
+    element.viewState.diffMode = 'UNIFIED';
+    assert.equal(element.viewState.numFilesShown, 200);
+    assert.equal(element._numFilesShown, 200);
+    element._numFilesShown = 150;
+    flushAsynchronousOperations();
+    assert.equal(element.viewState.diffMode, 'UNIFIED');
+    assert.equal(element.viewState.numFilesShown, 150);
+
+    element._changeNum = '1';
+    element.params = {changeNum: '1'};
+    element._change.newProp = '1';
+    flushAsynchronousOperations();
+    assert.equal(element.viewState.diffMode, 'UNIFIED');
+    assert.equal(element.viewState.changeNum, '1');
+
+    element._changeNum = '2';
+    element.params = {changeNum: '2'};
+    element._change.newProp = '2';
+    flushAsynchronousOperations();
+    assert.equal(element.viewState.diffMode, 'UNIFIED');
+    assert.equal(element.viewState.changeNum, '2');
+    assert.equal(element.viewState.numFilesShown, 200);
+    assert.equal(element._numFilesShown, 200);
+  });
+
+  test('_setDiffViewMode is called with reset when new change is loaded',
+      () => {
+        sinon.stub(element, '_setDiffViewMode');
+        element.viewState = {changeNum: 1};
+        element._changeNum = 2;
+        element._resetFileListViewState();
+        assert.isTrue(
+            element._setDiffViewMode.lastCall.calledWithExactly(true));
+      });
+
+  test('diffViewMode is propagated from file list header', () => {
+    element.viewState = {diffMode: 'UNIFIED'};
+    element.$.fileListHeader.diffViewMode = 'SIDE_BY_SIDE';
+    assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+  });
+
+  test('diffMode defaults to side by side without preferences', done => {
+    sinon.stub(element.$.restAPI, 'getPreferences').returns(
+        Promise.resolve({}));
+    // No user prefs or diff view mode set.
+
+    element._setDiffViewMode().then(() => {
+      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+      done();
+    });
+  });
+
+  test('diffMode defaults to preference when not already set', done => {
+    sinon.stub(element.$.restAPI, 'getPreferences').returns(
+        Promise.resolve({default_diff_view: 'UNIFIED'}));
+
+    element._setDiffViewMode().then(() => {
+      assert.equal(element.viewState.diffMode, 'UNIFIED');
+      done();
+    });
+  });
+
+  test('existing diffMode overrides preference', done => {
+    element.viewState.diffMode = 'SIDE_BY_SIDE';
+    sinon.stub(element.$.restAPI, 'getPreferences').returns(
+        Promise.resolve({default_diff_view: 'UNIFIED'}));
+    element._setDiffViewMode().then(() => {
+      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+      done();
+    });
+  });
+
+  test('don’t reload entire page when patchRange changes', () => {
+    const reloadStub = sinon.stub(element, '_reload').callsFake(
+        () => Promise.resolve());
+    const reloadPatchDependentStub = sinon.stub(element,
+        '_reloadPatchNumDependentResources')
+        .callsFake(() => Promise.resolve());
+    const relatedClearSpy = sinon.spy(element.$.relatedChanges, 'clear');
+    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
+
+    const value = {
+      view: GerritNav.View.CHANGE,
+      patchNum: '1',
+    };
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledOnce);
+    assert.isTrue(relatedClearSpy.calledOnce);
+
+    element._initialLoadComplete = true;
+
+    value.basePatchNum = '1';
+    value.patchNum = '2';
+    element._paramsChanged(value);
+    assert.isFalse(reloadStub.calledTwice);
+    assert.isTrue(reloadPatchDependentStub.calledOnce);
+    assert.isTrue(relatedClearSpy.calledOnce);
+    assert.isTrue(collapseStub.calledTwice);
+  });
+
+  test('reload entire page when patchRange doesnt change', () => {
+    const reloadStub = sinon.stub(element, '_reload').callsFake(
+        () => Promise.resolve());
+    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
+    const value = {
+      view: GerritNav.View.CHANGE,
+    };
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledOnce);
+    element._initialLoadComplete = true;
+    element._paramsChanged(value);
+    assert.isTrue(reloadStub.calledTwice);
+    assert.isTrue(collapseStub.calledTwice);
+  });
+
+  test('related changes are not updated after other action', done => {
+    sinon.stub(element, '_reload').callsFake(() => Promise.resolve());
+    sinon.stub(element.$.relatedChanges, 'reload');
+    const e = {detail: {action: 'abandon'}};
+    element._reload(e).then(() => {
+      assert.isFalse(navigateToChangeStub.called);
+      done();
+    });
+  });
+
+  test('_computeMergedCommitInfo', () => {
+    const dummyRevs = {
+      1: {commit: {commit: 1}},
+      2: {commit: {}},
+    };
+    assert.deepEqual(element._computeMergedCommitInfo(0, dummyRevs), {});
+    assert.deepEqual(element._computeMergedCommitInfo(1, dummyRevs),
+        dummyRevs[1].commit);
+
+    // Regression test for issue 5337.
+    const commit = element._computeMergedCommitInfo(2, dummyRevs);
+    assert.notDeepEqual(commit, dummyRevs[2]);
+    assert.deepEqual(commit, {commit: 2});
+  });
+
+  test('_computeCopyTextForTitle', () => {
+    const change = {
+      _number: 123,
+      subject: 'test subject',
+      revisions: {
+        rev1: {_number: 1},
+        rev3: {_number: 3},
+      },
+      current_revision: 'rev3',
+    };
+    sinon.stub(GerritNav, 'getUrlForChange')
+        .returns('/change/123');
+    assert.equal(
+        element._computeCopyTextForTitle(change),
+        `123: test subject | http://${location.host}/change/123`
+    );
+  });
+
+  test('get latest revision', () => {
+    let change = {
+      revisions: {
+        rev1: {_number: 1},
+        rev3: {_number: 3},
+      },
+      current_revision: 'rev3',
+    };
+    assert.equal(element._getLatestRevisionSHA(change), 'rev3');
+    change = {
+      revisions: {
+        rev1: {_number: 1},
+      },
+    };
+    assert.equal(element._getLatestRevisionSHA(change), 'rev1');
+  });
+
+  test('show commit message edit button', () => {
+    const _change = {
+      status: ChangeStatus.MERGED,
+    };
+    assert.isTrue(element._computeHideEditCommitMessage(false, false, {}));
+    assert.isTrue(element._computeHideEditCommitMessage(true, true, {}));
+    assert.isTrue(element._computeHideEditCommitMessage(false, true, {}));
+    assert.isFalse(element._computeHideEditCommitMessage(true, false, {}));
+    assert.isTrue(element._computeHideEditCommitMessage(true, false,
+        _change));
+    assert.isTrue(element._computeHideEditCommitMessage(true, false, {},
+        true));
+    assert.isFalse(element._computeHideEditCommitMessage(true, false, {},
+        false));
+  });
+
+  test('_handleCommitMessageSave trims trailing whitespace', () => {
+    const putStub = sinon.stub(element.$.restAPI, 'putChangeCommitMessage')
+        .returns(Promise.resolve({}));
+
+    const mockEvent = content => { return {detail: {content}}; };
+
+    element._handleCommitMessageSave(mockEvent('test \n  test '));
+    assert.equal(putStub.lastCall.args[1], 'test\n  test');
+
+    element._handleCommitMessageSave(mockEvent('  test\ntest'));
+    assert.equal(putStub.lastCall.args[1], '  test\ntest');
+
+    element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
+    assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
+  });
+
+  test('_computeChangeIdCommitMessageError', () => {
+    let commitMessage =
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
+    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+
+    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+
+    commitMessage = 'This is the greatest change.';
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'missing');
+  });
+
+  test('multiple change Ids in commit message picks last', () => {
+    const commitMessage = [
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    ].join('\n');
+    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+  });
+
+  test('does not count change Id that starts mid line', () => {
+    const commitMessage = [
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+      'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    ].join(' and ');
+    let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        null);
+    change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+    assert.equal(
+        element._computeChangeIdCommitMessageError(commitMessage, change),
+        'mismatch');
+  });
+
+  test('_computeTitleAttributeWarning', () => {
+    let changeIdCommitMessageError = 'missing';
+    assert.equal(
+        element._computeTitleAttributeWarning(changeIdCommitMessageError),
+        'No Change-Id in commit message');
+
+    changeIdCommitMessageError = 'mismatch';
+    assert.equal(
+        element._computeTitleAttributeWarning(changeIdCommitMessageError),
+        'Change-Id mismatch');
+  });
+
+  test('_computeChangeIdClass', () => {
+    let changeIdCommitMessageError = 'missing';
+    assert.equal(
+        element._computeChangeIdClass(changeIdCommitMessageError), '');
+
+    changeIdCommitMessageError = 'mismatch';
+    assert.equal(
+        element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
+  });
+
+  test('topic is coalesced to null', done => {
+    sinon.stub(element, '_changeChanged');
+    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(
+        () => Promise.resolve({
+          id: '123456789',
+          labels: {},
+          current_revision: 'foo',
+          revisions: {foo: {commit: {}}},
+        }));
+
+    element._getChangeDetail().then(() => {
+      assert.isNull(element._change.topic);
+      done();
+    });
+  });
+
+  test('commit sha is populated from getChangeDetail', done => {
+    sinon.stub(element, '_changeChanged');
+    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(
+        () => Promise.resolve({
+          id: '123456789',
+          labels: {},
+          current_revision: 'foo',
+          revisions: {foo: {commit: {}}},
+        }));
+
+    element._getChangeDetail().then(() => {
+      assert.equal('foo', element._commitInfo.commit);
+      done();
+    });
+  });
+
+  test('edit is added to change', () => {
+    sinon.stub(element, '_changeChanged');
+    sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(
+        () => Promise.resolve({
+          id: '123456789',
+          labels: {},
+          current_revision: 'foo',
+          revisions: {foo: {commit: {}}},
+        }));
+    sinon.stub(element, '_getEdit').callsFake(() => Promise.resolve({
+      base_patch_set_number: 1,
+      commit: {commit: 'bar'},
+    }));
+    element._patchRange = {};
+
+    return element._getChangeDetail().then(() => {
+      const revs = element._change.revisions;
+      assert.equal(Object.keys(revs).length, 2);
+      assert.deepEqual(revs['foo'], {commit: {commit: 'foo'}});
+      assert.deepEqual(revs['bar'], {
+        _number: SPECIAL_PATCH_SET_NUM.EDIT,
+        basePatchNum: 1,
+        commit: {commit: 'bar'},
+        fetch: undefined,
+      });
+    });
+  });
+
+  test('_getBasePatchNum', () => {
+    const _change = {
+      _number: 42,
+      revisions: {
+        '98da160735fb81604b4c40e93c368f380539dd0e': {
+          _number: 1,
+          commit: {
+            parents: [],
+          },
+        },
+      },
+    };
+    const _patchRange = {
+      basePatchNum: 'PARENT',
+    };
+    assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
+
+    element._prefs = {
+      default_base_for_merges: 'FIRST_PARENT',
+    };
+
+    const _change2 = {
+      _number: 42,
+      revisions: {
+        '98da160735fb81604b4c40e93c368f380539dd0e': {
+          _number: 1,
+          commit: {
+            parents: [
+              {
+                commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8',
+                subject: 'test',
+              },
+              {
+                commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841',
+                subject: 'test3',
+              },
+            ],
+          },
+        },
+      },
+    };
+    assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
+
+    _patchRange.patchNum = 1;
+    assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
+  });
+
+  test('_openReplyDialog called with `ANY` when coming from tap event',
+      () => {
+        const openStub = sinon.stub(element, '_openReplyDialog');
+        element._serverConfig = {};
+        MockInteractions.tap(element.$.replyBtn);
+        assert(openStub.lastCall.calledWithExactly(
+            element.$.replyDialog.FocusTarget.ANY),
+        '_openReplyDialog should have been passed ANY');
+        assert.equal(openStub.callCount, 1);
+      });
+
+  test('_openReplyDialog called with `BODY` when coming from message reply' +
+      'event', done => {
+    flush(() => {
+      const openStub = sinon.stub(element, '_openReplyDialog');
+      element.messagesList.dispatchEvent(
+          new CustomEvent('reply', {
+            detail:
+          {message: {message: 'text'}},
+            composed: true, bubbles: true,
+          }));
+      assert(openStub.lastCall.calledWithExactly(
+          element.$.replyDialog.FocusTarget.BODY),
+      '_openReplyDialog should have been passed BODY');
+      assert.equal(openStub.callCount, 1);
+      done();
+    });
+  });
+
+  test('reply dialog focus can be controlled', () => {
+    const FocusTarget = element.$.replyDialog.FocusTarget;
+    const openStub = sinon.stub(element, '_openReplyDialog');
+
+    const e = {detail: {}};
+    element._handleShowReplyDialog(e);
+    assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
+        '_openReplyDialog should have been passed REVIEWERS');
+    assert.equal(openStub.callCount, 1);
+
+    e.detail.value = {ccsOnly: true};
+    element._handleShowReplyDialog(e);
+    assert(openStub.lastCall.calledWithExactly(FocusTarget.CCS),
+        '_openReplyDialog should have been passed CCS');
+    assert.equal(openStub.callCount, 2);
+  });
+
+  test('getUrlParameter functionality', () => {
+    const locationStub = sinon.stub(element, '_getLocationSearch');
+
+    locationStub.returns('?test');
+    assert.equal(element._getUrlParameter('test'), 'test');
+    locationStub.returns('?test2=12&test=3');
+    assert.equal(element._getUrlParameter('test'), 'test');
+    locationStub.returns('');
+    assert.isNull(element._getUrlParameter('test'));
+    locationStub.returns('?');
+    assert.isNull(element._getUrlParameter('test'));
+    locationStub.returns('?test2');
+    assert.isNull(element._getUrlParameter('test'));
+  });
+
+  test('revert dialog opened with revert param', done => {
+    sinon.stub(element.$.restAPI, 'getLoggedIn')
+        .callsFake(() => Promise.resolve(true));
+    sinon.stub(pluginLoader, 'awaitPluginsLoaded')
+        .callsFake(() => Promise.resolve());
+
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 2,
+    };
+    element._change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1, commit: {parents: []}},
+        rev2: {_number: 2, commit: {parents: []}},
+      },
+      current_revision: 'rev1',
+      status: ChangeStatus.MERGED,
+      labels: {},
+      actions: {},
+    };
+
+    sinon.stub(element, '_getUrlParameter').callsFake(
+        param => {
+          assert.equal(param, 'revert');
+          return param;
+        });
+
+    sinon.stub(element.$.actions, 'showRevertDialog').callsFake(
+        done);
+
+    element._maybeShowRevertDialog();
+    assert.isTrue(pluginLoader.awaitPluginsLoaded.called);
+  });
+
+  suite('scroll related tests', () => {
+    test('document scrolling calls function to set scroll height', done => {
+      const originalHeight = document.body.scrollHeight;
+      const scrollStub = sinon.stub(element, '_handleScroll').callsFake(
+          () => {
+            assert.isTrue(scrollStub.called);
+            document.body.style.height = originalHeight + 'px';
+            scrollStub.restore();
+            done();
+          });
+      document.body.style.height = '10000px';
+      element._handleScroll();
+    });
+
+    test('scrollTop is set correctly', () => {
+      element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
+
+      sinon.stub(element, '_reload').callsFake(() => {
+        // When element is reloaded, ensure that the history
+        // state has the scrollTop set earlier. This will then
+        // be reset.
+        assert.isTrue(element.viewState.scrollTop == TEST_SCROLL_TOP_PX);
+        return Promise.resolve({});
+      });
+
+      // simulate reloading component, which is done when route
+      // changes to match a regex of change view type.
+      element._paramsChanged({view: GerritNav.View.CHANGE});
+    });
+
+    test('scrollTop is reset when new change is loaded', () => {
+      element._resetFileListViewState();
+      assert.equal(element.viewState.scrollTop, 0);
+    });
+  });
+
+  suite('reply dialog tests', () => {
+    setup(() => {
+      sinon.stub(element.$.replyDialog, '_draftChanged');
+      element._change = generateChange({
+        revisionsCount: 1,
+        messagesCount: 1,
+      });
+      element._change.labels = {};
+      sinon.stub(element.$.restAPI, 'getChangeDetail')
+          .callsFake(() => Promise.resolve(generateChange({
+            // element has latest info
+            revisionsCount: 1,
+            messagesCount: 1,
+          })));
+    });
+
+    test('show reply dialog on open-reply-dialog event', done => {
+      sinon.stub(element, '_openReplyDialog');
+      element.dispatchEvent(
+          new CustomEvent('open-reply-dialog', {
+            composed: true,
+            bubbles: true,
+            detail: {},
+          }));
+      flush(() => {
+        assert.isTrue(element._openReplyDialog.calledOnce);
+        done();
+      });
+    });
+
+    test('reply from comment adds quote text', () => {
+      const e = {detail: {message: {message: 'quote text'}}};
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    test('reply from comment replaces quote text', () => {
+      element.$.replyDialog.draft = '> old quote text\n\n some draft text';
+      element.$.replyDialog.quote = '> old quote text\n\n';
+      const e = {detail: {message: {message: 'quote text'}}};
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    test('reply from same comment preserves quote text', () => {
+      element.$.replyDialog.draft = '> quote text\n\n some draft text';
+      element.$.replyDialog.quote = '> quote text\n\n';
+      const e = {detail: {message: {message: 'quote text'}}};
+      element._handleMessageReply(e);
+      assert.equal(element.$.replyDialog.draft,
+          '> quote text\n\n some draft text');
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+
+    test('reply from top of page contains previous draft', () => {
+      const div = document.createElement('div');
+      element.$.replyDialog.draft = '> quote text\n\n some draft text';
+      element.$.replyDialog.quote = '> quote text\n\n';
+      const e = {target: div, preventDefault: sinon.spy()};
+      element._handleReplyTap(e);
+      assert.equal(element.$.replyDialog.draft,
+          '> quote text\n\n some draft text');
+      assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+    });
+  });
+
+  test('reply button is disabled until server config is loaded', () => {
+    assert.isTrue(element._replyDisabled);
+    element._serverConfig = {};
+    assert.isFalse(element._replyDisabled);
+  });
+
+  suite('commit message expand/collapse', () => {
+    setup(() => {
+      element._change = generateChange({
+        revisionsCount: 1,
+        messagesCount: 1,
+      });
+      element._change.labels = {};
+      sinon.stub(element.$.restAPI, 'getChangeDetail')
+          .callsFake(() => Promise.resolve(generateChange({
+            // new patchset was uploaded
+            revisionsCount: 2,
+            messagesCount: 1,
+          })));
+    });
+
+    test('commitCollapseToggle hidden for short commit message', () => {
+      element._latestCommitMessage = '';
+      assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
+    });
+
+    test('commitCollapseToggle shown for long commit message', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
+    });
+
+    test('commitCollapseToggle functions', () => {
+      element._latestCommitMessage = _.times(35, String).join('\n');
+      assert.isTrue(element._commitCollapsed);
+      assert.isTrue(element._commitCollapsible);
+      assert.isTrue(
+          element.$.commitMessageEditor.hasAttribute('collapsed'));
+      MockInteractions.tap(element.$.commitCollapseToggleButton);
+      assert.isFalse(element._commitCollapsed);
+      assert.isTrue(element._commitCollapsible);
+      assert.isFalse(
+          element.$.commitMessageEditor.hasAttribute('collapsed'));
+    });
+  });
+
+  suite('related changes expand/collapse', () => {
+    let updateHeightSpy;
+    setup(() => {
+      updateHeightSpy = sinon.spy(element, '_updateRelatedChangeMaxHeight');
+    });
+
+    test('relatedChangesToggle shown height greater than changeInfo height',
+        () => {
+          assert.isFalse(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+          sinon.stub(element, '_getScrollHeight').callsFake(() => 60);
+          sinon.stub(element, '_getLineHeight').callsFake(() => 5);
+          sinon.stub(window, 'matchMedia')
+              .callsFake(() => { return {matches: true}; });
+          element.$.relatedChanges.dispatchEvent(
+              new CustomEvent('new-section-loaded'));
+          assert.isTrue(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          assert.equal(updateHeightSpy.callCount, 1);
+        });
+
+    test('relatedChangesToggle hidden height less than changeInfo height',
+        () => {
+          assert.isFalse(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+          sinon.stub(element, '_getScrollHeight').callsFake(() => 40);
+          sinon.stub(element, '_getLineHeight').callsFake(() => 5);
+          sinon.stub(window, 'matchMedia')
+              .callsFake(() => { return {matches: true}; });
+          element.$.relatedChanges.dispatchEvent(
+              new CustomEvent('new-section-loaded'));
+          assert.isFalse(element.$.relatedChangesToggle.classList
+              .contains('showToggle'));
+          assert.equal(updateHeightSpy.callCount, 1);
+        });
+
+    test('relatedChangesToggle functions', () => {
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(window, 'matchMedia')
+          .callsFake(() => { return {matches: false}; });
+      element._relatedChangesLoading = false;
+      assert.isTrue(element._relatedChangesCollapsed);
+      assert.isTrue(
+          element.$.relatedChanges.classList.contains('collapsed'));
+      MockInteractions.tap(element.$.relatedChangesToggleButton);
+      assert.isFalse(element._relatedChangesCollapsed);
+      assert.isFalse(
+          element.$.relatedChanges.classList.contains('collapsed'));
+    });
+
+    test('_updateRelatedChangeMaxHeight without commit toggle', () => {
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+      sinon.stub(window, 'matchMedia')
+          .callsFake(() => { return {matches: false}; });
+
+      // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
+      // 20 (max existing height)  % 12 (line height) = 6 (remainder).
+      // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
+
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '12px');
+      assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
+          '');
+    });
+
+    test('_updateRelatedChangeMaxHeight with commit toggle', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+      sinon.stub(window, 'matchMedia')
+          .callsFake(() => { return {matches: false}; });
+
+      // 50 (existing height) % 12 (line height) = 2 (remainder).
+      // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
+
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '48px');
+      assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
+          '2px');
+    });
+
+    test('_updateRelatedChangeMaxHeight in small screen mode', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+      sinon.stub(window, 'matchMedia')
+          .callsFake(() => { return {matches: true}; });
+
+      element._updateRelatedChangeMaxHeight();
+
+      // 400 (new height) % 12 (line height) = 4 (remainder).
+      // 400 (new height) - 4 (remainder) = 396.
+
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '396px');
+    });
+
+    test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
+      element._latestCommitMessage = _.times(31, String).join('\n');
+      sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+      sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+      sinon.stub(window, 'matchMedia').callsFake(() => {
+        if (window.matchMedia.lastCall.args[0] === '(max-width: 75em)') {
+          return {matches: true};
+        } else {
+          return {matches: false};
+        }
+      });
+
+      // 100 (new height) % 12 (line height) = 4 (remainder).
+      // 100 (new height) - 4 (remainder) = 96.
+      element._updateRelatedChangeMaxHeight();
+      assert.equal(getCustomCssValue('--relation-chain-max-height'),
+          '96px');
+    });
+
+    suite('update checks', () => {
+      setup(() => {
+        sinon.spy(element, '_startUpdateCheckTimer');
+        sinon.stub(element, 'async').callsFake( f => {
+          // Only fire the async callback one time.
+          if (element.async.callCount > 1) { return; }
+          f.call(element);
+        });
+        element._change = generateChange({
+          revisionsCount: 1,
+          messagesCount: 1,
+        });
+      });
+
+      test('_startUpdateCheckTimer negative delay', () => {
+        const getChangeDetailStub =
+            sinon.stub(element.$.restAPI, 'getChangeDetail')
+                .callsFake(() => Promise.resolve(generateChange({
+                  // element has latest info
+                  revisionsCount: 1,
+                  messagesCount: 1,
+                })));
+
+        element._serverConfig = {change: {update_delay: -1}};
+
+        assert.isTrue(element._startUpdateCheckTimer.called);
+        assert.isFalse(getChangeDetailStub.called);
+      });
+
+      test('_startUpdateCheckTimer up-to-date', () => {
+        const getChangeDetailStub =
+            sinon.stub(element.$.restAPI, 'getChangeDetail')
+                .callsFake(() => Promise.resolve(generateChange({
+                  // element has latest info
+                  revisionsCount: 1,
+                  messagesCount: 1,
+                })));
+
+        element._serverConfig = {change: {update_delay: 12345}};
+
+        assert.isTrue(element._startUpdateCheckTimer.called);
+        assert.isTrue(getChangeDetailStub.called);
+        assert.equal(element.async.lastCall.args[1], 12345 * 1000);
+      });
+
+      test('_startUpdateCheckTimer out-of-date shows an alert', done => {
+        sinon.stub(element.$.restAPI, 'getChangeDetail')
+            .callsFake(() => Promise.resolve(generateChange({
+              // new patchset was uploaded
+              revisionsCount: 2,
+              messagesCount: 1,
+            })));
+
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message,
+              'A newer patch set has been uploaded');
+          done();
+        });
+        element._serverConfig = {change: {update_delay: 12345}};
+      });
+
+      test('_startUpdateCheckTimer new status shows an alert', done => {
+        sinon.stub(element.$.restAPI, 'getChangeDetail')
+            .callsFake(() => Promise.resolve(generateChange({
+              // element has latest info
+              revisionsCount: 1,
+              messagesCount: 1,
+              status: ChangeStatus.MERGED,
+            })));
+
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message, 'This change has been merged');
+          done();
+        });
+        element._serverConfig = {change: {update_delay: 12345}};
+      });
+
+      test('_startUpdateCheckTimer new messages shows an alert', done => {
+        sinon.stub(element.$.restAPI, 'getChangeDetail')
+            .callsFake(() => Promise.resolve(generateChange({
+              revisionsCount: 1,
+              // element has new message
+              messagesCount: 2,
+            })));
+        element.addEventListener('show-alert', e => {
+          assert.equal(e.detail.message,
+              'There are new messages on this change');
+          done();
+        });
+        element._serverConfig = {change: {update_delay: 12345}};
+      });
+    });
+
+    test('canStartReview computation', () => {
+      const change1 = {};
+      const change2 = {
+        actions: {
+          ready: {
+            enabled: true,
+          },
+        },
+      };
+      const change3 = {
+        actions: {
+          ready: {
+            label: 'Ready for Review',
+          },
+        },
+      };
+      assert.isFalse(element._computeCanStartReview(change1));
+      assert.isTrue(element._computeCanStartReview(change2));
+      assert.isFalse(element._computeCanStartReview(change3));
+    });
+  });
+
+  test('header class computation', () => {
+    assert.equal(element._computeHeaderClass(), 'header');
+    assert.equal(element._computeHeaderClass(true), 'header editMode');
+  });
+
+  test('_maybeScrollToMessage', done => {
+    flush(() => {
+      const scrollStub = sinon.stub(element.messagesList,
+          'scrollToMessage');
+
+      element._maybeScrollToMessage('');
+      assert.isFalse(scrollStub.called);
+      element._maybeScrollToMessage('message');
+      assert.isFalse(scrollStub.called);
+      element._maybeScrollToMessage('#message-TEST');
+      assert.isTrue(scrollStub.called);
+      assert.equal(scrollStub.lastCall.args[0], 'TEST');
+      done();
+    });
+  });
+
+  test('topic update reloads related changes', () => {
+    sinon.stub(element.$.relatedChanges, 'reload');
+    element.dispatchEvent(new CustomEvent('topic-changed'));
+    assert.isTrue(element.$.relatedChanges.reload.calledOnce);
+  });
+
+  test('_computeEditMode', () => {
+    const callCompute = (range, params) =>
+      element._computeEditMode({base: range}, {base: params});
+    assert.isFalse(callCompute({}, {}));
+    assert.isTrue(callCompute({}, {edit: true}));
+    assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}, {}));
+    assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}, {}));
+    assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}, {}));
+  });
+
+  test('_processEdit', () => {
+    element._patchRange = {};
+    const change = {
+      current_revision: 'foo',
+      revisions: {foo: {commit: {}, actions: {cherrypick: {enabled: true}}}},
+    };
+    let mockChange;
+
+    // With no edit, mockChange should be unmodified.
+    element._processEdit(mockChange = _.cloneDeep(change), null);
+    assert.deepEqual(mockChange, change);
+
+    // When edit is not based on the latest PS, current_revision should be
+    // unmodified.
+    const edit = {
+      base_patch_set_number: 1,
+      commit: {commit: 'bar'},
+      fetch: true,
+    };
+    element._processEdit(mockChange = _.cloneDeep(change), edit);
+    assert.notDeepEqual(mockChange, change);
+    assert.equal(mockChange.revisions.bar._number, SPECIAL_PATCH_SET_NUM.EDIT);
+    assert.equal(mockChange.current_revision, change.current_revision);
+    assert.deepEqual(mockChange.revisions.bar.commit, {commit: 'bar'});
+    assert.notOk(mockChange.revisions.bar.actions);
+
+    edit.base_revision = 'foo';
+    element._processEdit(mockChange = _.cloneDeep(change), edit);
+    assert.notDeepEqual(mockChange, change);
+    assert.equal(mockChange.current_revision, 'bar');
+    assert.deepEqual(mockChange.revisions.bar.actions,
+        mockChange.revisions.foo.actions);
+
+    // If _patchRange.patchNum is defined, do not load edit.
+    element._patchRange.patchNum = 'baz';
+    change.current_revision = 'baz';
+    element._processEdit(mockChange = _.cloneDeep(change), edit);
+    assert.equal(element._patchRange.patchNum, 'baz');
+    assert.notOk(mockChange.revisions.bar.actions);
+  });
+
+  test('file-action-tap handling', () => {
+    element._patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: 1,
+    };
+    const fileList = element.$.fileList;
+    const Actions = GrEditConstants.Actions;
+    element.$.fileListHeader.editMode = true;
+    flushAsynchronousOperations();
+    const controls = element.$.fileListHeader
+        .shadowRoot.querySelector('#editControls');
+    sinon.stub(controls, 'openDeleteDialog');
+    sinon.stub(controls, 'openRenameDialog');
+    sinon.stub(controls, 'openRestoreDialog');
+    sinon.stub(GerritNav, 'getEditUrlForDiff');
+    sinon.stub(GerritNav, 'navigateToRelativeUrl');
+
+    // Delete
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.DELETE.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(controls.openDeleteDialog.called);
+    assert.equal(controls.openDeleteDialog.lastCall.args[0], 'foo');
+
+    // Restore
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.RESTORE.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(controls.openRestoreDialog.called);
+    assert.equal(controls.openRestoreDialog.lastCall.args[0], 'foo');
+
+    // Rename
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.RENAME.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(controls.openRenameDialog.called);
+    assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
+
+    // Open
+    fileList.dispatchEvent(new CustomEvent('file-action-tap', {
+      detail: {action: Actions.OPEN.id, path: 'foo'},
+      bubbles: true,
+      composed: true,
+    }));
+    flushAsynchronousOperations();
+
+    assert.isTrue(GerritNav.getEditUrlForDiff.called);
+    assert.equal(GerritNav.getEditUrlForDiff.lastCall.args[1], 'foo');
+    assert.equal(GerritNav.getEditUrlForDiff.lastCall.args[2], '1');
+    assert.isTrue(GerritNav.navigateToRelativeUrl.called);
+  });
+
+  test('_selectedRevision updates when patchNum is changed', () => {
+    const revision1 = {_number: 1, commit: {parents: []}};
+    const revision2 = {_number: 2, commit: {parents: []}};
+    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
+        Promise.resolve({
+          revisions: {
+            aaa: revision1,
+            bbb: revision2,
+          },
+          labels: {},
+          actions: {},
+          current_revision: 'bbb',
+          change_id: 'loremipsumdolorsitamet',
+        }));
+    sinon.stub(element, '_getEdit').returns(Promise.resolve());
+    sinon.stub(element, '_getPreferences').returns(Promise.resolve({}));
+    element._patchRange = {patchNum: '2'};
+    return element._getChangeDetail().then(() => {
+      assert.strictEqual(element._selectedRevision, revision2);
+
+      element.set('_patchRange.patchNum', '1');
+      assert.strictEqual(element._selectedRevision, revision1);
+    });
+  });
+
+  test('_selectedRevision is assigned when patchNum is edit', () => {
+    const revision1 = {_number: 1, commit: {parents: []}};
+    const revision2 = {_number: 2, commit: {parents: []}};
+    const revision3 = {_number: 'edit', commit: {parents: []}};
+    sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
+        Promise.resolve({
+          revisions: {
+            aaa: revision1,
+            bbb: revision2,
+            ccc: revision3,
+          },
+          labels: {},
+          actions: {},
+          current_revision: 'ccc',
+          change_id: 'loremipsumdolorsitamet',
+        }));
+    sinon.stub(element, '_getEdit').returns(Promise.resolve());
+    sinon.stub(element, '_getPreferences').returns(Promise.resolve({}));
+    element._patchRange = {patchNum: 'edit'};
+    return element._getChangeDetail().then(() => {
+      assert.strictEqual(element._selectedRevision, revision3);
+    });
+  });
+
+  test('_sendShowChangeEvent', () => {
+    element._change = {labels: {}};
+    element._patchRange = {patchNum: 4};
+    element._mergeable = true;
+    const showStub = sinon.stub(element.$.jsAPI, 'handleEvent');
+    element._sendShowChangeEvent();
+    assert.isTrue(showStub.calledOnce);
+    assert.equal(
+        showStub.lastCall.args[0], element.$.jsAPI.EventType.SHOW_CHANGE);
+    assert.deepEqual(showStub.lastCall.args[1], {
+      change: {labels: {}},
+      patchNum: 4,
+      info: {mergeable: true},
+    });
+  });
+
+  suite('_handleEditTap', () => {
+    let fireEdit;
+
+    setup(() => {
+      fireEdit = () => {
+        element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
+      };
+      navigateToChangeStub.restore();
+
+      element._change = {revisions: {rev1: {_number: 1}}};
+    });
+
+    test('edit exists in revisions', done => {
+      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+        assert.equal(args.length, 2);
+        assert.equal(args[1], SPECIAL_PATCH_SET_NUM.EDIT); // patchNum
+        done();
+      });
+
+      element.set('_change.revisions.rev2',
+          {_number: SPECIAL_PATCH_SET_NUM.EDIT});
+      flushAsynchronousOperations();
+
+      fireEdit();
+    });
+
+    test('no edit exists in revisions, non-latest patchset', done => {
+      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+        assert.equal(args.length, 4);
+        assert.equal(args[1], 1); // patchNum
+        assert.equal(args[3], true); // opt_isEdit
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: 2});
+      element._patchRange = {patchNum: 1};
+      flushAsynchronousOperations();
+
+      fireEdit();
+    });
+
+    test('no edit exists in revisions, latest patchset', done => {
+      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+        assert.equal(args.length, 4);
+        // No patch should be specified when patchNum == latest.
+        assert.isNotOk(args[1]); // patchNum
+        assert.equal(args[3], true); // opt_isEdit
+        done();
+      });
+
+      element.set('_change.revisions.rev2', {_number: 2});
+      element._patchRange = {patchNum: 2};
+      flushAsynchronousOperations();
+
+      fireEdit();
+    });
+  });
+
+  test('_handleStopEditTap', done => {
+    sinon.stub(element.$.metadata, '_computeLabelNames');
+    navigateToChangeStub.restore();
+    sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+      assert.equal(args.length, 2);
+      assert.equal(args[1], 1); // patchNum
+      done();
+    });
+
+    element._patchRange = {patchNum: 1};
+    element.$.actions.dispatchEvent(new CustomEvent('stop-edit-tap',
+        {bubbles: false}));
+  });
+
+  suite('plugin endpoints', () => {
+    test('endpoint params', done => {
+      element._change = {labels: {}};
+      element._selectedRevision = {};
+      let hookEl;
+      let plugin;
+      pluginApi.install(
+          p => {
+            plugin = p;
+            plugin.hook('change-view-integration').getLastAttached()
+                .then(
+                    el => hookEl = el);
+          },
+          '0.1',
+          'http://some/plugins/url.html');
+      flush(() => {
+        assert.strictEqual(hookEl.plugin, plugin);
+        assert.strictEqual(hookEl.change, element._change);
+        assert.strictEqual(hookEl.revision, element._selectedRevision);
+        done();
+      });
+    });
+  });
+
+  suite('_getMergeability', () => {
+    let getMergeableStub;
+
+    setup(() => {
+      element._change = {labels: {}};
+      getMergeableStub = sinon.stub(element.$.restAPI, 'getMergeable')
+          .returns(Promise.resolve({mergeable: true}));
+    });
+
+    test('merged change', () => {
+      element._mergeable = null;
+      element._change.status = ChangeStatus.MERGED;
+      return element._getMergeability().then(() => {
+        assert.isFalse(element._mergeable);
+        assert.isFalse(getMergeableStub.called);
+      });
+    });
+
+    test('abandoned change', () => {
+      element._mergeable = null;
+      element._change.status = ChangeStatus.ABANDONED;
+      return element._getMergeability().then(() => {
+        assert.isFalse(element._mergeable);
+        assert.isFalse(getMergeableStub.called);
+      });
+    });
+
+    test('open change', () => {
+      element._mergeable = null;
+      return element._getMergeability().then(() => {
+        assert.isTrue(element._mergeable);
+        assert.isTrue(getMergeableStub.called);
+      });
+    });
+  });
+
+  test('_paramsChanged sets in projectLookup', () => {
+    sinon.stub(element.$.relatedChanges, 'reload');
+    sinon.stub(element, '_reload').returns(Promise.resolve());
+    const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
+    element._paramsChanged({
+      view: GerritNav.View.CHANGE,
+      changeNum: 101,
+      project: 'test-project',
+    });
+    assert.isTrue(setStub.calledOnce);
+    assert.isTrue(setStub.calledWith(101, 'test-project'));
+  });
+
+  test('_handleToggleStar called when star is tapped', () => {
+    element._change = {
+      owner: {_account_id: 1},
+      starred: false,
+    };
+    element._loggedIn = true;
+    const stub = sinon.stub(element, '_handleToggleStar');
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(element.$.changeStar.shadowRoot
+        .querySelector('button'));
+    assert.isTrue(stub.called);
+  });
+
+  suite('gr-reporting tests', () => {
+    setup(() => {
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve());
+      sinon.stub(element, '_getProjectConfig').returns(Promise.resolve());
+      sinon.stub(element, '_reloadComments').returns(Promise.resolve());
+      sinon.stub(element, '_getMergeability').returns(Promise.resolve());
+      sinon.stub(element, '_getLatestCommitMessage')
+          .returns(Promise.resolve());
+    });
+
+    test('don\'t report changedDisplayed on reply', done => {
+      const changeDisplayStub =
+        sinon.stub(element.reporting, 'changeDisplayed');
+      const changeFullyLoadedStub =
+        sinon.stub(element.reporting, 'changeFullyLoaded');
+      element._handleReplySent();
+      flush(() => {
+        assert.isFalse(changeDisplayStub.called);
+        assert.isFalse(changeFullyLoadedStub.called);
+        done();
+      });
+    });
+
+    test('report changedDisplayed on _paramsChanged', done => {
+      const changeDisplayStub =
+        sinon.stub(element.reporting, 'changeDisplayed');
+      const changeFullyLoadedStub =
+        sinon.stub(element.reporting, 'changeFullyLoaded');
+      element._paramsChanged({
+        view: GerritNav.View.CHANGE,
+        changeNum: 101,
+        project: 'test-project',
+      });
+      flush(() => {
+        assert.isTrue(changeDisplayStub.called);
+        assert.isTrue(changeFullyLoadedStub.called);
+        done();
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
deleted file mode 100644
index 7ca9d6b..0000000
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
+++ /dev/null
@@ -1,110 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-/*
-  The custom CSS property `--gr-formatted-text-prose-max-width` controls the max
-  width of formatted text blocks that are not code.
-*/
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
-import '../../../scripts/bundled-polymer.js';
-import '../../shared/gr-formatted-text/gr-formatted-text.js';
-import '../../../styles/shared-styles.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-comment-list_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/**
- * @extends Polymer.Element
- */
-class GrCommentList extends mixinBehaviors( [
-  BaseUrlBehavior,
-  PathListBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-comment-list'; }
-
-  static get properties() {
-    return {
-      changeNum: Number,
-      comments: Object,
-      patchNum: Number,
-      projectName: String,
-      /** @type {?} */
-      projectConfig: Object,
-    };
-  }
-
-  _computeFilesFromComments(comments) {
-    const arr = Object.keys(comments || {});
-    return arr.sort(this.specialFilePathCompare);
-  }
-
-  _isOnParent(comment) {
-    return comment.side === 'PARENT';
-  }
-
-  _computeDiffURL(filePath, changeNum, allComments) {
-    if ([filePath, changeNum, allComments].some(arg => arg === undefined)) {
-      return;
-    }
-    const fileComments = this._computeCommentsForFile(allComments, filePath);
-    // This can happen for files that don't exist anymore in the current ps.
-    if (fileComments.length === 0) return;
-    return GerritNav.getUrlForDiffById(changeNum, this.projectName,
-        filePath, fileComments[0].patch_set);
-  }
-
-  _computeDiffLineURL(filePath, changeNum, patchNum, comment) {
-    const basePatchNum = comment.hasOwnProperty('parent') ?
-      -comment.parent : null;
-    return GerritNav.getUrlForDiffById(changeNum, this.projectName,
-        filePath, patchNum, basePatchNum, comment.line,
-        this._isOnParent(comment));
-  }
-
-  _computeCommentsForFile(comments, filePath) {
-    // Changes are not picked up by the dom-repeat due to the array instance
-    // identity not changing even when it has elements added/removed from it.
-    return (comments[filePath] || []).slice();
-  }
-
-  _computePatchDisplayName(comment) {
-    if (this._isOnParent(comment)) {
-      return 'Base, ';
-    }
-    if (comment.patch_set != this.patchNum) {
-      return `PS${comment.patch_set}, `;
-    }
-    return '';
-  }
-}
-
-customElements.define(GrCommentList.is, GrCommentList);
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_html.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_html.js
deleted file mode 100644
index 60b83ee..0000000
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_html.js
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      word-wrap: break-word;
-    }
-    .file {
-      padding: var(--spacing-s) 0;
-    }
-    .container {
-      display: flex;
-      padding: var(--spacing-s) 0;
-    }
-    .lineNum {
-      margin-right: var(--spacing-s);
-      min-width: 135px;
-      text-align: right;
-    }
-    .message {
-      flex: 1;
-      --gr-formatted-text-prose-max-width: 80ch;
-    }
-    @media screen and (max-width: 50em) {
-      .container {
-        flex-direction: column;
-      }
-      .lineNum {
-        margin-right: 0;
-        min-width: initial;
-        text-align: left;
-      }
-    }
-  </style>
-  <template
-    is="dom-repeat"
-    items="[[_computeFilesFromComments(comments)]]"
-    as="file"
-  >
-    <div class="file">
-      <a class="fileLink" href="[[_computeDiffURL(file, changeNum, comments)]]"
-        >[[computeDisplayPath(file)]]</a
-      >
-    </div>
-    <template
-      is="dom-repeat"
-      items="[[_computeCommentsForFile(comments, file)]]"
-      as="comment"
-    >
-      <div class="container">
-        <a
-          class="lineNum"
-          href$="[[_computeDiffLineURL(file, changeNum, comment.patch_set, comment)]]"
-        >
-          <span hidden$="[[!comment.line]]">
-            <span>[[_computePatchDisplayName(comment)]]</span>
-            Line <span>[[comment.line]]</span>
-          </span>
-          <span hidden$="[[comment.line]]">
-            File comment:
-          </span>
-        </a>
-        <gr-formatted-text
-          class="message"
-          no-trailing-margin=""
-          content="[[comment.message]]"
-          config="[[projectConfig.commentlinks]]"
-        ></gr-formatted-text>
-      </div>
-    </template>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
deleted file mode 100644
index 075b883..0000000
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ /dev/null
@@ -1,132 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-comment-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-comment-list></gr-comment-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-comment-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-comment-list tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    sandbox.stub(GerritNav, 'mapCommentlinks', x => x);
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('_computeFilesFromComments w/ special file path sorting', () => {
-    const comments = {
-      'file_b.html': [],
-      'file_c.css': [],
-      'file_a.js': [],
-      'test.cc': [],
-      'test.h': [],
-    };
-    const expected = [
-      'file_a.js',
-      'file_b.html',
-      'file_c.css',
-      'test.h',
-      'test.cc',
-    ];
-    const actual = element._computeFilesFromComments(comments);
-    assert.deepEqual(actual, expected);
-
-    assert.deepEqual(element._computeFilesFromComments(null), []);
-  });
-
-  test('_computePatchDisplayName', () => {
-    const comment = {line: 123, side: 'REVISION', patch_set: 10};
-
-    element.patchNum = 10;
-    assert.equal(element._computePatchDisplayName(comment), '');
-
-    element.patchNum = 9;
-    assert.equal(element._computePatchDisplayName(comment), 'PS10, ');
-
-    comment.side = 'PARENT';
-    assert.equal(element._computePatchDisplayName(comment), 'Base, ');
-  });
-
-  test('config commentlinks propagate to formatted text', () => {
-    element.comments = {
-      'test.h': [{
-        author: {name: 'foo'},
-        patch_set: 4,
-        line: 10,
-        updated: '2017-10-30 20:48:40.000000000',
-        message: 'Ideadbeefdeadbeef',
-        unresolved: true,
-      }],
-    };
-    element.projectConfig = {
-      commentlinks: {foo: {link: '#/q/$1', match: '(I[0-9a-f]{8,40})'}},
-    };
-    flushAsynchronousOperations();
-    const formattedText = dom(element.root).querySelector(
-        'gr-formatted-text.message');
-    assert.isOk(formattedText.config);
-    assert.deepEqual(formattedText.config,
-        element.projectConfig.commentlinks);
-  });
-
-  test('_computeDiffLineURL', () => {
-    const getUrlStub = sandbox.stub(GerritNav, 'getUrlForDiffById');
-    element.projectName = 'proj';
-    element.changeNum = 123;
-
-    const comment = {line: 456};
-    element._computeDiffLineURL('foo.cc', 123, 4, comment);
-    assert.isTrue(getUrlStub.calledOnce);
-    assert.deepEqual(getUrlStub.lastCall.args,
-        [123, 'proj', 'foo.cc', 4, null, 456, false]);
-
-    comment.side = 'PARENT';
-    element._computeDiffLineURL('foo.cc', 123, 4, comment);
-    assert.isTrue(getUrlStub.calledTwice);
-    assert.deepEqual(getUrlStub.lastCall.args,
-        [123, 'proj', 'foo.cc', 4, null, 456, true]);
-
-    comment.parent = 12;
-    element._computeDiffLineURL('foo.cc', 123, 4, comment);
-    assert.isTrue(getUrlStub.calledThrice);
-    assert.deepEqual(getUrlStub.lastCall.args,
-        [123, 'proj', 'foo.cc', 4, -12, 456, true]);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
index 4dba3af..3ed48e7 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
@@ -24,7 +22,7 @@
 import {htmlTemplate} from './gr-commit-info_html.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrCommitInfo extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -61,7 +59,7 @@
 
   _computeShowWebLink(change, commitInfo, serverConfig) {
     // Polymer 2: check for undefined
-    if ([change, commitInfo, serverConfig].some(arg => arg === undefined)) {
+    if ([change, commitInfo, serverConfig].includes(undefined)) {
       return undefined;
     }
 
@@ -71,7 +69,7 @@
 
   _computeWebLink(change, commitInfo, serverConfig) {
     // Polymer 2: check for undefined
-    if ([change, commitInfo, serverConfig].some(arg => arg === undefined)) {
+    if ([change, commitInfo, serverConfig].includes(undefined)) {
       return undefined;
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.js
deleted file mode 100644
index 608d12b..0000000
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.js
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .container {
-      align-items: center;
-      display: flex;
-    }
-  </style>
-  <div class="container">
-    <template is="dom-if" if="[[_showWebLink]]">
-      <a target="_blank" rel="noopener" href$="[[_webLink]]"
-        >[[_computeShortHash(commitInfo)]]</a
-      >
-    </template>
-    <template is="dom-if" if="[[!_showWebLink]]">
-      [[_computeShortHash(commitInfo)]]
-    </template>
-    <gr-copy-clipboard
-      has-tooltip=""
-      button-title="Copy full SHA to clipboard"
-      hide-input=""
-      text="[[commitInfo.commit]]"
-    >
-    </gr-copy-clipboard>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
new file mode 100644
index 0000000..e350593
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
@@ -0,0 +1,43 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .container {
+      align-items: center;
+      display: flex;
+    }
+  </style>
+  <div class="container">
+    <template is="dom-if" if="[[_showWebLink]]">
+      <a target="_blank" rel="noopener" href$="[[_webLink]]"
+        >[[_computeShortHash(commitInfo)]]</a
+      >
+    </template>
+    <template is="dom-if" if="[[!_showWebLink]]">
+      [[_computeShortHash(commitInfo)]]
+    </template>
+    <gr-copy-clipboard
+      has-tooltip=""
+      button-title="Copy full SHA to clipboard"
+      hide-input=""
+      text="[[commitInfo.commit]]"
+    >
+    </gr-copy-clipboard>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
deleted file mode 100644
index d9664ec..0000000
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
+++ /dev/null
@@ -1,139 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-commit-info</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-commit-info></gr-commit-info>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../core/gr-router/gr-router.js';
-import './gr-commit-info.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-commit-info tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('weblinks use GerritNav interface', () => {
-    const weblinksStub = sandbox.stub(GerritNav, '_generateWeblinks')
-        .returns([{name: 'stubb', url: '#s'}]);
-    element.change = {};
-    element.commitInfo = {};
-    element.serverConfig = {};
-    assert.isTrue(weblinksStub.called);
-  });
-
-  test('no web link when unavailable', () => {
-    element.commitInfo = {};
-    element.serverConfig = {};
-    element.change = {labels: [], project: ''};
-
-    assert.isNotOk(element._computeShowWebLink(element.change,
-        element.commitInfo, element.serverConfig));
-  });
-
-  test('use web link when available', () => {
-    const router = document.createElement('gr-router');
-    sandbox.stub(GerritNav, '_generateWeblinks',
-        router._generateWeblinks.bind(router));
-
-    element.change = {labels: [], project: ''};
-    element.commitInfo =
-        {commit: 'commitsha', web_links: [{name: 'gitweb', url: 'link-url'}]};
-    element.serverConfig = {};
-
-    assert.isOk(element._computeShowWebLink(element.change,
-        element.commitInfo, element.serverConfig));
-    assert.equal(element._computeWebLink(element.change, element.commitInfo,
-        element.serverConfig), 'link-url');
-  });
-
-  test('does not relativize web links that begin with scheme', () => {
-    const router = document.createElement('gr-router');
-    sandbox.stub(GerritNav, '_generateWeblinks',
-        router._generateWeblinks.bind(router));
-
-    element.change = {labels: [], project: ''};
-    element.commitInfo = {
-      commit: 'commitsha',
-      web_links: [{name: 'gitweb', url: 'https://link-url'}],
-    };
-    element.serverConfig = {};
-
-    assert.isOk(element._computeShowWebLink(element.change,
-        element.commitInfo, element.serverConfig));
-    assert.equal(element._computeWebLink(element.change, element.commitInfo,
-        element.serverConfig), 'https://link-url');
-  });
-
-  test('ignore web links that are neither gitweb nor gitiles', () => {
-    const router = document.createElement('gr-router');
-    sandbox.stub(GerritNav, '_generateWeblinks',
-        router._generateWeblinks.bind(router));
-
-    element.change = {project: 'project-name'};
-    element.commitInfo = {
-      commit: 'commit-sha',
-      web_links: [
-        {
-          name: 'ignore',
-          url: 'ignore',
-        },
-        {
-          name: 'gitiles',
-          url: 'https://link-url',
-        },
-      ],
-    };
-    element.serverConfig = {};
-
-    assert.isOk(element._computeShowWebLink(element.change,
-        element.commitInfo, element.serverConfig));
-    assert.equal(element._computeWebLink(element.change, element.commitInfo,
-        element.serverConfig), 'https://link-url');
-
-    // Remove gitiles link.
-    element.commitInfo.web_links.splice(1, 1);
-    assert.isNotOk(element._computeShowWebLink(element.change,
-        element.commitInfo, element.serverConfig));
-    assert.isNotOk(element._computeWebLink(element.change, element.commitInfo,
-        element.serverConfig));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.js
new file mode 100644
index 0000000..c120c33
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.js
@@ -0,0 +1,118 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../core/gr-router/gr-router.js';
+import './gr-commit-info.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-commit-info');
+
+suite('gr-commit-info tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('weblinks use GerritNav interface', () => {
+    const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
+        .returns([{name: 'stubb', url: '#s'}]);
+    element.change = {};
+    element.commitInfo = {};
+    element.serverConfig = {};
+    assert.isTrue(weblinksStub.called);
+  });
+
+  test('no web link when unavailable', () => {
+    element.commitInfo = {};
+    element.serverConfig = {};
+    element.change = {labels: [], project: ''};
+
+    assert.isNotOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+  });
+
+  test('use web link when available', () => {
+    const router = document.createElement('gr-router');
+    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
+        router._generateWeblinks.bind(router));
+
+    element.change = {labels: [], project: ''};
+    element.commitInfo =
+        {commit: 'commitsha', web_links: [{name: 'gitweb', url: 'link-url'}]};
+    element.serverConfig = {};
+
+    assert.isOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+    assert.equal(element._computeWebLink(element.change, element.commitInfo,
+        element.serverConfig), 'link-url');
+  });
+
+  test('does not relativize web links that begin with scheme', () => {
+    const router = document.createElement('gr-router');
+    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
+        router._generateWeblinks.bind(router));
+
+    element.change = {labels: [], project: ''};
+    element.commitInfo = {
+      commit: 'commitsha',
+      web_links: [{name: 'gitweb', url: 'https://link-url'}],
+    };
+    element.serverConfig = {};
+
+    assert.isOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+    assert.equal(element._computeWebLink(element.change, element.commitInfo,
+        element.serverConfig), 'https://link-url');
+  });
+
+  test('ignore web links that are neither gitweb nor gitiles', () => {
+    const router = document.createElement('gr-router');
+    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
+        router._generateWeblinks.bind(router));
+
+    element.change = {project: 'project-name'};
+    element.commitInfo = {
+      commit: 'commit-sha',
+      web_links: [
+        {
+          name: 'ignore',
+          url: 'ignore',
+        },
+        {
+          name: 'gitiles',
+          url: 'https://link-url',
+        },
+      ],
+    };
+    element.serverConfig = {};
+
+    assert.isOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+    assert.equal(element._computeWebLink(element.change, element.commitInfo,
+        element.serverConfig), 'https://link-url');
+
+    // Remove gitiles link.
+    element.commitInfo.web_links.splice(1, 1);
+    assert.isNotOk(element._computeShowWebLink(element.change,
+        element.commitInfo, element.serverConfig));
+    assert.isNotOk(element._computeWebLink(element.change, element.commitInfo,
+        element.serverConfig));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
index d28e2b7..a8f1903 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
@@ -15,25 +15,21 @@
  * limitations under the License.
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-
-import '../../../scripts/bundled-polymer.js';
 import '../../shared/gr-dialog/gr-dialog.js';
 import '../../../styles/shared-styles.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-confirm-abandon-dialog_html.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrConfirmAbandonDialog extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrConfirmAbandonDialog extends KeyboardShortcutMixin(
+    GestureEventListeners(
+        LegacyElementMixin(
+            PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-confirm-abandon-dialog'; }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.js
deleted file mode 100644
index 050df25..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Abandon"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Abandon Change</div>
-    <div class="main" slot="main">
-      <label for="messageInput">Abandon Message</label>
-      <iron-autogrow-textarea
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        placeholder="<Insert reasoning here>"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.ts
new file mode 100644
index 0000000..7c1b725
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.ts
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    .main {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+    }
+    label {
+      cursor: pointer;
+      display: block;
+      width: 100%;
+    }
+    iron-autogrow-textarea {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      width: 73ch; /* Add a char to account for the border. */
+    }
+  </style>
+  <gr-dialog
+    confirm-label="Abandon"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header" slot="header">Abandon Change</div>
+    <div class="main" slot="main">
+      <label for="messageInput">Abandon Message</label>
+      <iron-autogrow-textarea
+        id="messageInput"
+        class="message"
+        autocomplete="on"
+        placeholder="<Insert reasoning here>"
+        bind-value="{{message}}"
+      ></iron-autogrow-textarea>
+    </div>
+  </gr-dialog>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
deleted file mode 100644
index 8010814..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html
+++ /dev/null
@@ -1,83 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-confirm-abandon-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-confirm-abandon-dialog></gr-confirm-abandon-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-confirm-abandon-dialog.js';
-suite('gr-confirm-abandon-dialog tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_handleConfirmTap', () => {
-    const confirmHandler = sandbox.stub();
-    element.addEventListener('confirm', confirmHandler);
-    sandbox.spy(element, '_handleConfirmTap');
-    sandbox.spy(element, '_confirm');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('confirm', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(confirmHandler.called);
-    assert.isTrue(confirmHandler.calledOnce);
-    assert.isTrue(element._handleConfirmTap.called);
-    assert.isTrue(element._confirm.called);
-    assert.isTrue(element._confirm.called);
-    assert.isTrue(element._confirm.calledOnce);
-  });
-
-  test('_handleCancelTap', () => {
-    const cancelHandler = sandbox.stub();
-    element.addEventListener('cancel', cancelHandler);
-    sandbox.spy(element, '_handleCancelTap');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(cancelHandler.called);
-    assert.isTrue(cancelHandler.calledOnce);
-    assert.isTrue(element._handleCancelTap.called);
-    assert.isTrue(element._handleCancelTap.calledOnce);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.js
new file mode 100644
index 0000000..14d16f5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.js
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-abandon-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-abandon-dialog');
+
+suite('gr-confirm-abandon-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sinon.stub();
+    element.addEventListener('confirm', confirmHandler);
+    sinon.spy(element, '_handleConfirmTap');
+    sinon.spy(element, '_confirm');
+    element.shadowRoot
+        .querySelector('gr-dialog').dispatchEvent(
+            new CustomEvent('confirm', {
+              composed: true, bubbles: true,
+            }));
+    assert.isTrue(confirmHandler.called);
+    assert.isTrue(confirmHandler.calledOnce);
+    assert.isTrue(element._handleConfirmTap.called);
+    assert.isTrue(element._confirm.called);
+    assert.isTrue(element._confirm.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sinon.stub();
+    element.addEventListener('cancel', cancelHandler);
+    sinon.spy(element, '_handleCancelTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').dispatchEvent(
+            new CustomEvent('cancel', {
+              composed: true, bubbles: true,
+            }));
+    assert.isTrue(cancelHandler.called);
+    assert.isTrue(cancelHandler.calledOnce);
+    assert.isTrue(element._handleCancelTap.called);
+    assert.isTrue(element._handleCancelTap.calledOnce);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
index 480e6cf..34a3dcc 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-dialog/gr-dialog.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
@@ -24,7 +22,7 @@
 import {htmlTemplate} from './gr-confirm-cherrypick-conflict-dialog_html.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrConfirmCherrypickConflictDialog extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.js
deleted file mode 100644
index c7fb70c..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Continue"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Cherry Pick Conflict!</div>
-    <div class="main" slot="main">
-      <span>Cherry Pick failed! (merge conflicts)</span>
-
-      <span
-        >Please select "Continue" to continue with conflicts or select "cancel"
-        to close the dialog.</span
-      >
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.ts
new file mode 100644
index 0000000..5cf56b5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.ts
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    .main {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+    }
+  </style>
+  <gr-dialog
+    confirm-label="Continue"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header" slot="header">Cherry Pick Conflict!</div>
+    <div class="main" slot="main">
+      <span>Cherry Pick failed! (merge conflicts)</span>
+
+      <span
+        >Please select "Continue" to continue with conflicts or select "cancel"
+        to close the dialog.</span
+      >
+    </div>
+  </gr-dialog>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
deleted file mode 100644
index e0016f0..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html
+++ /dev/null
@@ -1,78 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-confirm-cherrypick-conflict-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-confirm-cherrypick-conflict-dialog></gr-confirm-cherrypick-conflict-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-confirm-cherrypick-conflict-dialog.js';
-suite('gr-confirm-cherrypick-conflict-dialog tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('_handleConfirmTap', () => {
-    const confirmHandler = sandbox.stub();
-    element.addEventListener('confirm', confirmHandler);
-    sandbox.spy(element, '_handleConfirmTap');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('confirm', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(confirmHandler.called);
-    assert.isTrue(confirmHandler.calledOnce);
-    assert.isTrue(element._handleConfirmTap.called);
-    assert.isTrue(element._handleConfirmTap.calledOnce);
-  });
-
-  test('_handleCancelTap', () => {
-    const cancelHandler = sandbox.stub();
-    element.addEventListener('cancel', cancelHandler);
-    sandbox.spy(element, '_handleCancelTap');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(cancelHandler.called);
-    assert.isTrue(cancelHandler.calledOnce);
-    assert.isTrue(element._handleCancelTap.called);
-    assert.isTrue(element._handleCancelTap.calledOnce);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.js
new file mode 100644
index 0000000..c98353b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.js
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-cherrypick-conflict-dialog.js';
+
+const basicFixture =
+    fixtureFromElement('gr-confirm-cherrypick-conflict-dialog');
+
+suite('gr-confirm-cherrypick-conflict-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sinon.stub();
+    element.addEventListener('confirm', confirmHandler);
+    sinon.spy(element, '_handleConfirmTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').dispatchEvent(
+            new CustomEvent('confirm', {
+              composed: true, bubbles: true,
+            }));
+    assert.isTrue(confirmHandler.called);
+    assert.isTrue(confirmHandler.calledOnce);
+    assert.isTrue(element._handleConfirmTap.called);
+    assert.isTrue(element._handleConfirmTap.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sinon.stub();
+    element.addEventListener('cancel', cancelHandler);
+    sinon.spy(element, '_handleCancelTap');
+    element.shadowRoot
+        .querySelector('gr-dialog').dispatchEvent(
+            new CustomEvent('cancel', {
+              composed: true, bubbles: true,
+            }));
+    assert.isTrue(cancelHandler.called);
+    assert.isTrue(cancelHandler.calledOnce);
+    assert.isTrue(element._handleCancelTap.called);
+    assert.isTrue(element._handleCancelTap.calledOnce);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
index 2802046..cb15d97 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
@@ -14,20 +14,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 import '@polymer/iron-input/iron-input.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-autocomplete/gr-autocomplete.js';
 import '../../shared/gr-dialog/gr-dialog.js';
-import '../../core/gr-reporting/gr-reporting.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-confirm-cherrypick-dialog_html.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {appContext} from '../../../services/app-context.js';
 
 const SUGGESTIONS_LIMIT = 15;
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -37,7 +35,7 @@
 };
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrConfirmCherrypickDialog extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -97,6 +95,11 @@
     };
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   static get observers() {
     return [
       '_computeMessage(changeStatus, commitNum, commitMessage)',
@@ -130,7 +133,7 @@
   }
 
   updateStatus(change, status) {
-    this._statuses = Object.assign({}, this._statuses, {[change.id]: status});
+    this._statuses = {...this._statuses, [change.id]: status};
   }
 
   _computeStatus(change, statuses) {
@@ -199,7 +202,7 @@
       changeStatus,
       commitNum,
       commitMessage,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return;
     }
 
@@ -260,7 +263,7 @@
     e.preventDefault();
     e.stopPropagation();
     if (this._cherryPickType === CHERRY_PICK_TYPES.TOPIC) {
-      this.$.reporting.reportInteraction('cherry-pick-topic-clicked');
+      this.reporting.reportInteraction('cherry-pick-topic-clicked');
       this._handleCherryPickTopic();
       return;
     }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.js
deleted file mode 100644
index aeb8061..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.js
+++ /dev/null
@@ -1,220 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    .main label,
-    .main input[type='text'] {
-      display: block;
-      width: 100%;
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-    .cherryPickTopicLayout {
-      display: flex;
-    }
-    .cherryPickSingleChange,
-    .cherryPickTopic {
-      margin-left: var(--spacing-m);
-      margin-bottom: var(--spacing-m);
-    }
-    .cherry-pick-topic-message {
-      margin-bottom: var(--spacing-m);
-    }
-    label[for='messageInput'],
-    label[for='baseInput'] {
-      margin-top: var(--spacing-m);
-    }
-    .title {
-      font-weight: var(--font-weight-bold);
-    }
-    tr > td {
-      padding: var(--spacing-m);
-    }
-    th {
-      color: var(--deemphasized-text-color);
-    }
-    table {
-      border-collapse: collapse;
-    }
-    tr {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .error {
-      color: var(--error-text-color);
-    }
-    .error-message {
-      color: var(--error-text-color);
-      margin: var(--spacing-m) 0 var(--spacing-m) 0;
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Cherry Pick"
-    cancel-label="[[_computeCancelLabel(_statuses)]]"
-    disabled$="[[_computeDisableCherryPick(_cherryPickType, _duplicateProjectChanges, _statuses)]]"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header title" slot="header">
-      Cherry Pick Change to Another Branch
-    </div>
-    <div class="main" slot="main">
-      <template is="dom-if" if="[[_showCherryPickTopic]]">
-        <div class="cherryPickTopicLayout">
-          <input
-            name="cherryPickOptions"
-            type="radio"
-            id="cherryPickSingleChange"
-            on-change="_handlecherryPickSingleChangeClicked"
-            checked=""
-          />
-          <label for="cherryPickSingleChange" class="cherryPickSingleChange">
-            Cherry Pick single change
-          </label>
-        </div>
-        <div class="cherryPickTopicLayout">
-          <input
-            name="cherryPickOptions"
-            type="radio"
-            id="cherryPickTopic"
-            on-change="_handlecherryPickTopicClicked"
-          />
-          <label for="cherryPickTopic" class="cherryPickTopic">
-            Cherry Pick entire topic ([[_changesCount]] Changes)
-          </label>
-        </div></template
-      >
-
-      <label for="branchInput">
-        Cherry Pick to branch
-      </label>
-      <gr-autocomplete
-        id="branchInput"
-        text="{{branch}}"
-        query="[[_query]]"
-        placeholder="Destination branch"
-      >
-      </gr-autocomplete>
-      <template is="dom-if" if="[[_invalidBranch]]">
-        <span class="error"> Branch name cannot contain space or commas. </span>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_computeIfSinglecherryPick(_cherryPickType)]]"
-      >
-        <label for="baseInput">
-          Provide base commit sha1 for cherry-pick
-        </label>
-        <iron-input
-          maxlength="40"
-          placeholder="(optional)"
-          bind-value="{{baseCommit}}"
-        >
-          <input
-            is="iron-input"
-            id="baseCommitInput"
-            maxlength="40"
-            placeholder="(optional)"
-            bind-value="{{baseCommit}}"
-          />
-        </iron-input>
-        <label for="messageInput">
-          Cherry Pick Commit Message
-        </label>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_computeIfSinglecherryPick(_cherryPickType)]]"
-      >
-        <iron-autogrow-textarea
-          id="messageInput"
-          class="message"
-          autocomplete="on"
-          rows="4"
-          max-rows="15"
-          bind-value="{{message}}"
-        ></iron-autogrow-textarea>
-      </template>
-      <template is="dom-if" if="[[_computeIfCherryPickTopic(_cherryPickType)]]">
-        <span class="error-message"
-          >[[_computeTopicErrorMessage(_duplicateProjectChanges)]]</span
-        >
-        <span class="cherry-pick-topic-message">
-          Commit Message will be auto generated
-        </span>
-        <table>
-          <thead>
-            <tr>
-              <th>Change</th>
-              <th>Subject</th>
-              <th>Project</th>
-              <th>Status</th>
-              <!-- Error Message -->
-              <th></th>
-            </tr>
-          </thead>
-          <tbody>
-            <template is="dom-repeat" items="[[changes]]">
-              <tr>
-                <td><span> [[_getChangeId(item)]] </span></td>
-                <td>
-                  <span> [[_getTrimmedChangeSubject(item.subject)]] </span>
-                </td>
-                <td><span> [[item.project]] </span></td>
-                <td>
-                  <span class$="[[_computeStatusClass(item, _statuses)]]">
-                    [[_computeStatus(item, _statuses)]]
-                  </span>
-                </td>
-                <td>
-                  <span class="error">
-                    [[_computeError(item, _statuses)]]
-                  </span>
-                </td>
-              </tr>
-            </template>
-          </tbody>
-        </table>
-      </template>
-    </div>
-  </gr-dialog>
-  <gr-reporting
-    id="reporting"
-    category="confirm-cherry-pick-dialog"
-  ></gr-reporting>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
new file mode 100644
index 0000000..072f110
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
@@ -0,0 +1,216 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    label {
+      cursor: pointer;
+    }
+    .main {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+    }
+    .main label,
+    .main input[type='text'] {
+      display: block;
+      width: 100%;
+    }
+    iron-autogrow-textarea {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      width: 73ch; /* Add a char to account for the border. */
+    }
+    .cherryPickTopicLayout {
+      display: flex;
+    }
+    .cherryPickSingleChange,
+    .cherryPickTopic {
+      margin-left: var(--spacing-m);
+      margin-bottom: var(--spacing-m);
+    }
+    .cherry-pick-topic-message {
+      margin-bottom: var(--spacing-m);
+    }
+    label[for='messageInput'],
+    label[for='baseInput'] {
+      margin-top: var(--spacing-m);
+    }
+    .title {
+      font-weight: var(--font-weight-bold);
+    }
+    tr > td {
+      padding: var(--spacing-m);
+    }
+    th {
+      color: var(--deemphasized-text-color);
+    }
+    table {
+      border-collapse: collapse;
+    }
+    tr {
+      border-bottom: 1px solid var(--border-color);
+    }
+    .error {
+      color: var(--error-text-color);
+    }
+    .error-message {
+      color: var(--error-text-color);
+      margin: var(--spacing-m) 0 var(--spacing-m) 0;
+    }
+  </style>
+  <gr-dialog
+    confirm-label="Cherry Pick"
+    cancel-label="[[_computeCancelLabel(_statuses)]]"
+    disabled$="[[_computeDisableCherryPick(_cherryPickType, _duplicateProjectChanges, _statuses)]]"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header title" slot="header">
+      Cherry Pick Change to Another Branch
+    </div>
+    <div class="main" slot="main">
+      <template is="dom-if" if="[[_showCherryPickTopic]]">
+        <div class="cherryPickTopicLayout">
+          <input
+            name="cherryPickOptions"
+            type="radio"
+            id="cherryPickSingleChange"
+            on-change="_handlecherryPickSingleChangeClicked"
+            checked=""
+          />
+          <label for="cherryPickSingleChange" class="cherryPickSingleChange">
+            Cherry Pick single change
+          </label>
+        </div>
+        <div class="cherryPickTopicLayout">
+          <input
+            name="cherryPickOptions"
+            type="radio"
+            id="cherryPickTopic"
+            on-change="_handlecherryPickTopicClicked"
+          />
+          <label for="cherryPickTopic" class="cherryPickTopic">
+            Cherry Pick entire topic ([[_changesCount]] Changes)
+          </label>
+        </div></template
+      >
+
+      <label for="branchInput">
+        Cherry Pick to branch
+      </label>
+      <gr-autocomplete
+        id="branchInput"
+        text="{{branch}}"
+        query="[[_query]]"
+        placeholder="Destination branch"
+      >
+      </gr-autocomplete>
+      <template is="dom-if" if="[[_invalidBranch]]">
+        <span class="error"> Branch name cannot contain space or commas. </span>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_computeIfSinglecherryPick(_cherryPickType)]]"
+      >
+        <label for="baseInput">
+          Provide base commit sha1 for cherry-pick
+        </label>
+        <iron-input
+          maxlength="40"
+          placeholder="(optional)"
+          bind-value="{{baseCommit}}"
+        >
+          <input
+            is="iron-input"
+            id="baseCommitInput"
+            maxlength="40"
+            placeholder="(optional)"
+            bind-value="{{baseCommit}}"
+          />
+        </iron-input>
+        <label for="messageInput">
+          Cherry Pick Commit Message
+        </label>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_computeIfSinglecherryPick(_cherryPickType)]]"
+      >
+        <iron-autogrow-textarea
+          id="messageInput"
+          class="message"
+          autocomplete="on"
+          rows="4"
+          max-rows="15"
+          bind-value="{{message}}"
+        ></iron-autogrow-textarea>
+      </template>
+      <template is="dom-if" if="[[_computeIfCherryPickTopic(_cherryPickType)]]">
+        <span class="error-message"
+          >[[_computeTopicErrorMessage(_duplicateProjectChanges)]]</span
+        >
+        <span class="cherry-pick-topic-message">
+          Commit Message will be auto generated
+        </span>
+        <table>
+          <thead>
+            <tr>
+              <th>Change</th>
+              <th>Subject</th>
+              <th>Project</th>
+              <th>Status</th>
+              <!-- Error Message -->
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <template is="dom-repeat" items="[[changes]]">
+              <tr>
+                <td><span> [[_getChangeId(item)]] </span></td>
+                <td>
+                  <span> [[_getTrimmedChangeSubject(item.subject)]] </span>
+                </td>
+                <td><span> [[item.project]] </span></td>
+                <td>
+                  <span class$="[[_computeStatusClass(item, _statuses)]]">
+                    [[_computeStatus(item, _statuses)]]
+                  </span>
+                </td>
+                <td>
+                  <span class="error">
+                    [[_computeError(item, _statuses)]]
+                  </span>
+                </td>
+              </tr>
+            </template>
+          </tbody>
+        </table>
+      </template>
+    </div>
+  </gr-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
deleted file mode 100644
index 000718b..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
+++ /dev/null
@@ -1,185 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-confirm-cherrypick-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-confirm-cherrypick-dialog></gr-confirm-cherrypick-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-confirm-cherrypick-dialog.js';
-
-const CHERRY_PICK_TYPES = {
-  SINGLE_CHANGE: 1,
-  TOPIC: 2,
-};
-suite('gr-confirm-cherrypick-dialog tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getRepoBranches(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              ref: 'refs/heads/test-branch',
-              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-              can_delete: true,
-            },
-          ]);
-        } else {
-          return Promise.resolve({});
-        }
-      },
-    });
-    element = fixture('basic');
-    element.project = 'test-project';
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('with merged change', () => {
-    element.changeStatus = 'MERGED';
-    element.commitMessage = 'message\n';
-    element.commitNum = '123';
-    element.branch = 'master';
-    flushAsynchronousOperations();
-    const expectedMessage = 'message\n(cherry picked from commit 123)';
-    assert.equal(element.message, expectedMessage);
-  });
-
-  test('with unmerged change', () => {
-    element.changeStatus = 'OPEN';
-    element.commitMessage = 'message\n';
-    element.commitNum = '123';
-    element.branch = 'master';
-    flushAsynchronousOperations();
-    const expectedMessage = 'message\n';
-    assert.equal(element.message, expectedMessage);
-  });
-
-  test('with updated commit message', () => {
-    element.changeStatus = 'OPEN';
-    element.commitMessage = 'message\n';
-    element.commitNum = '123';
-    element.branch = 'master';
-    const myNewMessage = 'updated commit message';
-    element.message = myNewMessage;
-    flushAsynchronousOperations();
-    assert.equal(element.message, myNewMessage);
-  });
-
-  test('_getProjectBranchesSuggestions empty', done => {
-    element._getProjectBranchesSuggestions('nonexistent').then(branches => {
-      assert.equal(branches.length, 0);
-      done();
-    });
-  });
-
-  suite('cherry pick topic', () => {
-    const changes = [
-      {
-        change_id: '12345678901234', topic: 'T', subject: 'random',
-        project: 'A',
-        _number: 1,
-        revisions: {
-          a: {_number: 1},
-        },
-        current_revision: 'a',
-      },
-      {
-        change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
-        project: 'B',
-        _number: 2,
-        revisions: {
-          a: {_number: 1},
-        },
-        current_revision: 'a',
-      },
-    ];
-    setup(() => {
-      element.updateChanges(changes);
-      element._cherryPickType = CHERRY_PICK_TYPES.TOPIC;
-    });
-
-    test('cherry pick topic submit', done => {
-      element.branch = 'master';
-      const executeChangeActionStub = sandbox.stub(element.$.restAPI,
-          'executeChangeAction').returns(Promise.resolve([]));
-      MockInteractions.tap(element.shadowRoot.
-          querySelector('gr-dialog').$.confirm);
-      flush(() => {
-        const args = executeChangeActionStub.args[0];
-        assert.equal(args[0], 1);
-        assert.equal(args[1], 'POST');
-        assert.equal(args[2], '/cherrypick');
-        assert.equal(args[4].destination, 'master');
-        assert.isTrue(args[4].allow_conflicts);
-        assert.isTrue(args[4].allow_empty);
-        done();
-      });
-    });
-
-    test('_computeStatusClass', () => {
-      assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'RUNNING'},
-      }), '');
-      assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'FAILED'}}
-      ), 'error');
-    });
-
-    test('submit button is blocked while cherry picks is running', done => {
-      console.log(element);
-      const confirmButton = element.shadowRoot.querySelector('gr-dialog').$
-          .confirm;
-      assert.isFalse(confirmButton.hasAttribute('disabled'));
-      element.updateStatus(changes[0], {status: 'RUNNING'});
-      flush(() => {
-        assert.isTrue(confirmButton.hasAttribute('disabled'));
-        done();
-      });
-    });
-  });
-
-  test('resetFocus', () => {
-    const focusStub = sandbox.stub(element.$.branchInput, 'focus');
-    element.resetFocus();
-    assert.isTrue(focusStub.called);
-  });
-
-  test('_getProjectBranchesSuggestions non-empty', done => {
-    element._getProjectBranchesSuggestions('test-branch').then(branches => {
-      assert.equal(branches.length, 1);
-      assert.equal(branches[0].name, 'test-branch');
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
new file mode 100644
index 0000000..900412a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
@@ -0,0 +1,165 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-cherrypick-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-cherrypick-dialog');
+
+const CHERRY_PICK_TYPES = {
+  SINGLE_CHANGE: 1,
+  TOPIC: 2,
+};
+suite('gr-confirm-cherrypick-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getRepoBranches(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve([
+            {
+              ref: 'refs/heads/test-branch',
+              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+              can_delete: true,
+            },
+          ]);
+        } else {
+          return Promise.resolve({});
+        }
+      },
+    });
+    element = basicFixture.instantiate();
+    element.project = 'test-project';
+  });
+
+  test('with merged change', () => {
+    element.changeStatus = 'MERGED';
+    element.commitMessage = 'message\n';
+    element.commitNum = '123';
+    element.branch = 'master';
+    flushAsynchronousOperations();
+    const expectedMessage = 'message\n(cherry picked from commit 123)';
+    assert.equal(element.message, expectedMessage);
+  });
+
+  test('with unmerged change', () => {
+    element.changeStatus = 'OPEN';
+    element.commitMessage = 'message\n';
+    element.commitNum = '123';
+    element.branch = 'master';
+    flushAsynchronousOperations();
+    const expectedMessage = 'message\n';
+    assert.equal(element.message, expectedMessage);
+  });
+
+  test('with updated commit message', () => {
+    element.changeStatus = 'OPEN';
+    element.commitMessage = 'message\n';
+    element.commitNum = '123';
+    element.branch = 'master';
+    const myNewMessage = 'updated commit message';
+    element.message = myNewMessage;
+    flushAsynchronousOperations();
+    assert.equal(element.message, myNewMessage);
+  });
+
+  test('_getProjectBranchesSuggestions empty', done => {
+    element._getProjectBranchesSuggestions('nonexistent').then(branches => {
+      assert.equal(branches.length, 0);
+      done();
+    });
+  });
+
+  suite('cherry pick topic', () => {
+    const changes = [
+      {
+        change_id: '12345678901234', topic: 'T', subject: 'random',
+        project: 'A',
+        _number: 1,
+        revisions: {
+          a: {_number: 1},
+        },
+        current_revision: 'a',
+      },
+      {
+        change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
+        project: 'B',
+        _number: 2,
+        revisions: {
+          a: {_number: 1},
+        },
+        current_revision: 'a',
+      },
+    ];
+    setup(() => {
+      element.updateChanges(changes);
+      element._cherryPickType = CHERRY_PICK_TYPES.TOPIC;
+    });
+
+    test('cherry pick topic submit', done => {
+      element.branch = 'master';
+      const executeChangeActionStub = sinon.stub(element.$.restAPI,
+          'executeChangeAction').returns(Promise.resolve([]));
+      MockInteractions.tap(element.shadowRoot.
+          querySelector('gr-dialog').$.confirm);
+      flush(() => {
+        const args = executeChangeActionStub.args[0];
+        assert.equal(args[0], 1);
+        assert.equal(args[1], 'POST');
+        assert.equal(args[2], '/cherrypick');
+        assert.equal(args[4].destination, 'master');
+        assert.isTrue(args[4].allow_conflicts);
+        assert.isTrue(args[4].allow_empty);
+        done();
+      });
+    });
+
+    test('_computeStatusClass', () => {
+      assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'RUNNING'},
+      }), '');
+      assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'FAILED'}}
+      ), 'error');
+    });
+
+    test('submit button is blocked while cherry picks is running', done => {
+      const confirmButton = element.shadowRoot.querySelector('gr-dialog').$
+          .confirm;
+      assert.isFalse(confirmButton.hasAttribute('disabled'));
+      element.updateStatus(changes[0], {status: 'RUNNING'});
+      flush(() => {
+        assert.isTrue(confirmButton.hasAttribute('disabled'));
+        done();
+      });
+    });
+  });
+
+  test('resetFocus', () => {
+    const focusStub = sinon.stub(element.$.branchInput, 'focus');
+    element.resetFocus();
+    assert.isTrue(focusStub.called);
+  });
+
+  test('_getProjectBranchesSuggestions non-empty', done => {
+    element._getProjectBranchesSuggestions('test-branch').then(branches => {
+      assert.equal(branches.length, 1);
+      assert.equal(branches[0].name, 'test-branch');
+      done();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
index 25beb2d..61cd78d2b3 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
@@ -14,30 +14,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-
-import '../../../scripts/bundled-polymer.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-autocomplete/gr-autocomplete.js';
 import '../../shared/gr-dialog/gr-dialog.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-confirm-move-dialog_html.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 
 const SUGGESTIONS_LIMIT = 15;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrConfirmMoveDialog extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrConfirmMoveDialog extends KeyboardShortcutMixin(
+    GestureEventListeners(
+        LegacyElementMixin(
+            PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-confirm-move-dialog'; }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.js
deleted file mode 100644
index f5ddf41..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      width: 30em;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    .main label,
-    .main input[type='text'] {
-      display: block;
-      width: 100%;
-    }
-    .main .message {
-      width: 100%;
-    }
-    .warning {
-      color: var(--error-text-color);
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Move Change"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Move Change to Another Branch</div>
-    <div class="main" slot="main">
-      <p class="warning">
-        Warning: moving a change will not change its parents.
-      </p>
-      <label for="branchInput">
-        Move change to branch
-      </label>
-      <gr-autocomplete
-        id="branchInput"
-        text="{{branch}}"
-        query="[[_query]]"
-        placeholder="Destination branch"
-      >
-      </gr-autocomplete>
-      <label for="messageInput">
-        Move Change Message
-      </label>
-      <iron-autogrow-textarea
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        rows="4"
-        max-rows="15"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
new file mode 100644
index 0000000..b5b46d6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
@@ -0,0 +1,83 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      width: 30em;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    label {
+      cursor: pointer;
+    }
+    .main {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+    }
+    .main label,
+    .main input[type='text'] {
+      display: block;
+      width: 100%;
+    }
+    .main .message {
+      width: 100%;
+    }
+    .warning {
+      color: var(--error-text-color);
+    }
+  </style>
+  <gr-dialog
+    confirm-label="Move Change"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header" slot="header">Move Change to Another Branch</div>
+    <div class="main" slot="main">
+      <p class="warning">
+        Warning: moving a change will not change its parents.
+      </p>
+      <label for="branchInput">
+        Move change to branch
+      </label>
+      <gr-autocomplete
+        id="branchInput"
+        text="{{branch}}"
+        query="[[_query]]"
+        placeholder="Destination branch"
+      >
+      </gr-autocomplete>
+      <label for="messageInput">
+        Move Change Message
+      </label>
+      <iron-autogrow-textarea
+        id="messageInput"
+        class="message"
+        autocomplete="on"
+        rows="4"
+        max-rows="15"
+        bind-value="{{message}}"
+      ></iron-autogrow-textarea>
+    </div>
+  </gr-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
deleted file mode 100644
index a8392aa..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
+++ /dev/null
@@ -1,83 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-confirm-move-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-confirm-move-dialog></gr-confirm-move-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-confirm-move-dialog.js';
-suite('gr-confirm-move-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getRepoBranches(input) {
-        if (input.startsWith('test')) {
-          return Promise.resolve([
-            {
-              ref: 'refs/heads/test-branch',
-              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-              can_delete: true,
-            },
-          ]);
-        } else {
-          return Promise.resolve({});
-        }
-      },
-    });
-    element = fixture('basic');
-    element.project = 'test-project';
-  });
-
-  test('with updated commit message', () => {
-    element.branch = 'master';
-    const myNewMessage = 'updated commit message';
-    element.message = myNewMessage;
-    flushAsynchronousOperations();
-    assert.equal(element.message, myNewMessage);
-  });
-
-  test('_getProjectBranchesSuggestions empty', done => {
-    element._getProjectBranchesSuggestions('nonexistent').then(branches => {
-      assert.equal(branches.length, 0);
-      done();
-    });
-  });
-
-  test('_getProjectBranchesSuggestions non-empty', done => {
-    element._getProjectBranchesSuggestions('test-branch').then(branches => {
-      assert.equal(branches.length, 1);
-      assert.equal(branches[0].name, 'test-branch');
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
new file mode 100644
index 0000000..0241112
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-move-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-move-dialog');
+
+suite('gr-confirm-move-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getRepoBranches(input) {
+        if (input.startsWith('test')) {
+          return Promise.resolve([
+            {
+              ref: 'refs/heads/test-branch',
+              revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+              can_delete: true,
+            },
+          ]);
+        } else {
+          return Promise.resolve({});
+        }
+      },
+    });
+    element = basicFixture.instantiate();
+    element.project = 'test-project';
+  });
+
+  test('with updated commit message', () => {
+    element.branch = 'master';
+    const myNewMessage = 'updated commit message';
+    element.message = myNewMessage;
+    flushAsynchronousOperations();
+    assert.equal(element.message, myNewMessage);
+  });
+
+  test('_getProjectBranchesSuggestions empty', done => {
+    element._getProjectBranchesSuggestions('nonexistent').then(branches => {
+      assert.equal(branches.length, 0);
+      done();
+    });
+  });
+
+  test('_getProjectBranchesSuggestions non-empty', done => {
+    element._getProjectBranchesSuggestions('test-branch').then(branches => {
+      assert.equal(branches.length, 1);
+      assert.equal(branches[0].name, 'test-branch');
+      done();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
index e451034..bfcc477 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-autocomplete/gr-autocomplete.js';
 import '../../shared/gr-dialog/gr-dialog.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
@@ -25,7 +23,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-confirm-rebase-dialog_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrConfirmRebaseDialog extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -162,7 +160,7 @@
    */
   _updateSelectedOption(rebaseOnCurrent, hasParent) {
     // Polymer 2: check for undefined
-    if ([rebaseOnCurrent, hasParent].some(arg => arg === undefined)) {
+    if ([rebaseOnCurrent, hasParent].includes(undefined)) {
       return;
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.js
deleted file mode 100644
index e9a8424..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.js
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      width: 30em;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-    }
-    .message {
-      font-style: italic;
-    }
-    .parentRevisionContainer label,
-    .parentRevisionContainer input[type='text'] {
-      display: block;
-      width: 100%;
-    }
-    .parentRevisionContainer label {
-      margin-bottom: var(--spacing-xs);
-    }
-    .rebaseOption {
-      margin: var(--spacing-m) 0;
-    }
-  </style>
-  <gr-dialog
-    id="confirmDialog"
-    confirm-label="Rebase"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Confirm rebase</div>
-    <div class="main" slot="main">
-      <div
-        id="rebaseOnParent"
-        class="rebaseOption"
-        hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]"
-      >
-        <input
-          id="rebaseOnParentInput"
-          name="rebaseOptions"
-          type="radio"
-          on-click="_handleRebaseOnParent"
-        />
-        <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
-          Rebase on parent change
-        </label>
-      </div>
-      <div
-        id="parentUpToDateMsg"
-        class="message"
-        hidden$="[[!_displayParentUpToDateMsg(rebaseOnCurrent, hasParent)]]"
-      >
-        This change is up to date with its parent.
-      </div>
-      <div
-        id="rebaseOnTip"
-        class="rebaseOption"
-        hidden$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
-      >
-        <input
-          id="rebaseOnTipInput"
-          name="rebaseOptions"
-          type="radio"
-          disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
-          on-click="_handleRebaseOnTip"
-        />
-        <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
-          Rebase on top of the [[branch]] branch<span hidden$="[[!hasParent]]">
-            (breaks relation chain)
-          </span>
-        </label>
-      </div>
-      <div
-        id="tipUpToDateMsg"
-        class="message"
-        hidden$="[[_displayTipOption(rebaseOnCurrent, hasParent)]]"
-      >
-        Change is up to date with the target branch already ([[branch]])
-      </div>
-      <div id="rebaseOnOther" class="rebaseOption">
-        <input
-          id="rebaseOnOtherInput"
-          name="rebaseOptions"
-          type="radio"
-          on-click="_handleRebaseOnOther"
-        />
-        <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
-          Rebase on a specific change, ref, or commit
-          <span hidden$="[[!hasParent]]">
-            (breaks relation chain)
-          </span>
-        </label>
-      </div>
-      <div class="parentRevisionContainer">
-        <gr-autocomplete
-          id="parentInput"
-          query="[[_query]]"
-          no-debounce=""
-          text="{{_text}}"
-          on-click="_handleEnterChangeNumberClick"
-          allow-non-suggested-values=""
-          placeholder="Change number, ref, or commit hash"
-        >
-        </gr-autocomplete>
-      </div>
-    </div>
-  </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
new file mode 100644
index 0000000..687d31f
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
@@ -0,0 +1,131 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      width: 30em;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    label {
+      cursor: pointer;
+    }
+    .message {
+      font-style: italic;
+    }
+    .parentRevisionContainer label,
+    .parentRevisionContainer input[type='text'] {
+      display: block;
+      width: 100%;
+    }
+    .parentRevisionContainer label {
+      margin-bottom: var(--spacing-xs);
+    }
+    .rebaseOption {
+      margin: var(--spacing-m) 0;
+    }
+  </style>
+  <gr-dialog
+    id="confirmDialog"
+    confirm-label="Rebase"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header" slot="header">Confirm rebase</div>
+    <div class="main" slot="main">
+      <div
+        id="rebaseOnParent"
+        class="rebaseOption"
+        hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]"
+      >
+        <input
+          id="rebaseOnParentInput"
+          name="rebaseOptions"
+          type="radio"
+          on-click="_handleRebaseOnParent"
+        />
+        <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
+          Rebase on parent change
+        </label>
+      </div>
+      <div
+        id="parentUpToDateMsg"
+        class="message"
+        hidden$="[[!_displayParentUpToDateMsg(rebaseOnCurrent, hasParent)]]"
+      >
+        This change is up to date with its parent.
+      </div>
+      <div
+        id="rebaseOnTip"
+        class="rebaseOption"
+        hidden$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
+      >
+        <input
+          id="rebaseOnTipInput"
+          name="rebaseOptions"
+          type="radio"
+          disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
+          on-click="_handleRebaseOnTip"
+        />
+        <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
+          Rebase on top of the [[branch]] branch<span hidden$="[[!hasParent]]">
+            (breaks relation chain)
+          </span>
+        </label>
+      </div>
+      <div
+        id="tipUpToDateMsg"
+        class="message"
+        hidden$="[[_displayTipOption(rebaseOnCurrent, hasParent)]]"
+      >
+        Change is up to date with the target branch already ([[branch]])
+      </div>
+      <div id="rebaseOnOther" class="rebaseOption">
+        <input
+          id="rebaseOnOtherInput"
+          name="rebaseOptions"
+          type="radio"
+          on-click="_handleRebaseOnOther"
+        />
+        <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
+          Rebase on a specific change, ref, or commit
+          <span hidden$="[[!hasParent]]">
+            (breaks relation chain)
+          </span>
+        </label>
+      </div>
+      <div class="parentRevisionContainer">
+        <gr-autocomplete
+          id="parentInput"
+          query="[[_query]]"
+          no-debounce=""
+          text="{{_text}}"
+          on-click="_handleEnterChangeNumberClick"
+          allow-non-suggested-values=""
+          placeholder="Change number, ref, or commit hash"
+        >
+        </gr-autocomplete>
+      </div>
+    </div>
+  </gr-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
deleted file mode 100644
index 080a7e0..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
+++ /dev/null
@@ -1,204 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-confirm-rebase-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-confirm-rebase-dialog></gr-confirm-rebase-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-confirm-rebase-dialog.js';
-suite('gr-confirm-rebase-dialog tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('controls with parent and rebase on current available', () => {
-    element.rebaseOnCurrent = true;
-    element.hasParent = true;
-    flushAsynchronousOperations();
-    assert.isTrue(element.$.rebaseOnParentInput.checked);
-    assert.isFalse(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
-  });
-
-  test('controls with parent rebase on current not available', () => {
-    element.rebaseOnCurrent = false;
-    element.hasParent = true;
-    flushAsynchronousOperations();
-    assert.isTrue(element.$.rebaseOnTipInput.checked);
-    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isFalse(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
-  });
-
-  test('controls without parent and rebase on current available', () => {
-    element.rebaseOnCurrent = true;
-    element.hasParent = false;
-    flushAsynchronousOperations();
-    assert.isTrue(element.$.rebaseOnTipInput.checked);
-    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
-  });
-
-  test('controls without parent rebase on current not available', () => {
-    element.rebaseOnCurrent = false;
-    element.hasParent = false;
-    flushAsynchronousOperations();
-    assert.isTrue(element.$.rebaseOnOtherInput.checked);
-    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isTrue(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isFalse(element.$.tipUpToDateMsg.hasAttribute('hidden'));
-  });
-
-  test('input cleared on cancel or submit', () => {
-    element._text = '123';
-    element.$.confirmDialog.dispatchEvent(
-        new CustomEvent('confirm', {
-          composed: true, bubbles: true,
-        }));
-    assert.equal(element._text, '');
-
-    element._text = '123';
-    element.$.confirmDialog.dispatchEvent(
-        new CustomEvent('cancel', {
-          composed: true, bubbles: true,
-        }));
-    assert.equal(element._text, '');
-  });
-
-  test('_getSelectedBase', () => {
-    element._text = '5fab321c';
-    element.$.rebaseOnParentInput.checked = true;
-    assert.equal(element._getSelectedBase(), null);
-    element.$.rebaseOnParentInput.checked = false;
-    element.$.rebaseOnTipInput.checked = true;
-    assert.equal(element._getSelectedBase(), '');
-    element.$.rebaseOnTipInput.checked = false;
-    assert.equal(element._getSelectedBase(), element._text);
-    element._text = '101: Test';
-    assert.equal(element._getSelectedBase(), '101');
-  });
-
-  suite('parent suggestions', () => {
-    let recentChanges;
-    setup(() => {
-      recentChanges = [
-        {
-          name: '123: my first awesome change',
-          value: 123,
-        },
-        {
-          name: '124: my second awesome change',
-          value: 124,
-        },
-        {
-          name: '245: my third awesome change',
-          value: 245,
-        },
-      ];
-
-      sandbox.stub(element.$.restAPI, 'getChanges').returns(Promise.resolve(
-          [
-            {
-              _number: 123,
-              subject: 'my first awesome change',
-            },
-            {
-              _number: 124,
-              subject: 'my second awesome change',
-            },
-            {
-              _number: 245,
-              subject: 'my third awesome change',
-            },
-          ]
-      ));
-    });
-
-    test('_getRecentChanges', () => {
-      sandbox.spy(element, '_getRecentChanges');
-      return element._getRecentChanges()
-          .then(() => {
-            assert.deepEqual(element._recentChanges, recentChanges);
-            assert.equal(element.$.restAPI.getChanges.callCount, 1);
-            // When called a second time, should not re-request recent changes.
-            element._getRecentChanges();
-          })
-          .then(() => {
-            assert.equal(element._getRecentChanges.callCount, 2);
-            assert.equal(element.$.restAPI.getChanges.callCount, 1);
-          });
-    });
-
-    test('_filterChanges', () => {
-      assert.equal(element._filterChanges('123', recentChanges).length, 1);
-      assert.equal(element._filterChanges('12', recentChanges).length, 2);
-      assert.equal(element._filterChanges('awesome', recentChanges).length,
-          3);
-      assert.equal(element._filterChanges('third', recentChanges).length,
-          1);
-
-      element.changeNumber = 123;
-      assert.equal(element._filterChanges('123', recentChanges).length, 0);
-      assert.equal(element._filterChanges('124', recentChanges).length, 1);
-      assert.equal(element._filterChanges('awesome', recentChanges).length,
-          2);
-    });
-
-    test('input text change triggers function', () => {
-      sandbox.spy(element, '_getRecentChanges');
-      element.$.parentInput.noDebounce = true;
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.parentInput.$.input,
-          13,
-          null,
-          'enter');
-      element._text = '1';
-      assert.isTrue(element._getRecentChanges.calledOnce);
-      element._text = '12';
-      assert.isTrue(element._getRecentChanges.calledTwice);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
new file mode 100644
index 0000000..498d31c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
@@ -0,0 +1,184 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-rebase-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-rebase-dialog');
+
+suite('gr-confirm-rebase-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('controls with parent and rebase on current available', () => {
+    element.rebaseOnCurrent = true;
+    element.hasParent = true;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.rebaseOnParentInput.checked);
+    assert.isFalse(element.$.rebaseOnParent.hasAttribute('hidden'));
+    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
+    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+  });
+
+  test('controls with parent rebase on current not available', () => {
+    element.rebaseOnCurrent = false;
+    element.hasParent = true;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.rebaseOnTipInput.checked);
+    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
+    assert.isFalse(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
+    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+  });
+
+  test('controls without parent and rebase on current available', () => {
+    element.rebaseOnCurrent = true;
+    element.hasParent = false;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.rebaseOnTipInput.checked);
+    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
+    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
+    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+  });
+
+  test('controls without parent rebase on current not available', () => {
+    element.rebaseOnCurrent = false;
+    element.hasParent = false;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.rebaseOnOtherInput.checked);
+    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
+    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
+    assert.isTrue(element.$.rebaseOnTip.hasAttribute('hidden'));
+    assert.isFalse(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+  });
+
+  test('input cleared on cancel or submit', () => {
+    element._text = '123';
+    element.$.confirmDialog.dispatchEvent(
+        new CustomEvent('confirm', {
+          composed: true, bubbles: true,
+        }));
+    assert.equal(element._text, '');
+
+    element._text = '123';
+    element.$.confirmDialog.dispatchEvent(
+        new CustomEvent('cancel', {
+          composed: true, bubbles: true,
+        }));
+    assert.equal(element._text, '');
+  });
+
+  test('_getSelectedBase', () => {
+    element._text = '5fab321c';
+    element.$.rebaseOnParentInput.checked = true;
+    assert.equal(element._getSelectedBase(), null);
+    element.$.rebaseOnParentInput.checked = false;
+    element.$.rebaseOnTipInput.checked = true;
+    assert.equal(element._getSelectedBase(), '');
+    element.$.rebaseOnTipInput.checked = false;
+    assert.equal(element._getSelectedBase(), element._text);
+    element._text = '101: Test';
+    assert.equal(element._getSelectedBase(), '101');
+  });
+
+  suite('parent suggestions', () => {
+    let recentChanges;
+    setup(() => {
+      recentChanges = [
+        {
+          name: '123: my first awesome change',
+          value: 123,
+        },
+        {
+          name: '124: my second awesome change',
+          value: 124,
+        },
+        {
+          name: '245: my third awesome change',
+          value: 245,
+        },
+      ];
+
+      sinon.stub(element.$.restAPI, 'getChanges').returns(Promise.resolve(
+          [
+            {
+              _number: 123,
+              subject: 'my first awesome change',
+            },
+            {
+              _number: 124,
+              subject: 'my second awesome change',
+            },
+            {
+              _number: 245,
+              subject: 'my third awesome change',
+            },
+          ]
+      ));
+    });
+
+    test('_getRecentChanges', () => {
+      sinon.spy(element, '_getRecentChanges');
+      return element._getRecentChanges()
+          .then(() => {
+            assert.deepEqual(element._recentChanges, recentChanges);
+            assert.equal(element.$.restAPI.getChanges.callCount, 1);
+            // When called a second time, should not re-request recent changes.
+            element._getRecentChanges();
+          })
+          .then(() => {
+            assert.equal(element._getRecentChanges.callCount, 2);
+            assert.equal(element.$.restAPI.getChanges.callCount, 1);
+          });
+    });
+
+    test('_filterChanges', () => {
+      assert.equal(element._filterChanges('123', recentChanges).length, 1);
+      assert.equal(element._filterChanges('12', recentChanges).length, 2);
+      assert.equal(element._filterChanges('awesome', recentChanges).length,
+          3);
+      assert.equal(element._filterChanges('third', recentChanges).length,
+          1);
+
+      element.changeNumber = 123;
+      assert.equal(element._filterChanges('123', recentChanges).length, 0);
+      assert.equal(element._filterChanges('124', recentChanges).length, 1);
+      assert.equal(element._filterChanges('awesome', recentChanges).length,
+          2);
+    });
+
+    test('input text change triggers function', () => {
+      sinon.spy(element, '_getRecentChanges');
+      element.$.parentInput.noDebounce = true;
+      MockInteractions.pressAndReleaseKeyOn(
+          element.$.parentInput.$.input,
+          13,
+          null,
+          'enter');
+      element._text = '1';
+      assert.isTrue(element._getRecentChanges.calledOnce);
+      element._text = '12';
+      assert.isTrue(element._getRecentChanges.calledTwice);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
index 6eb4c82..9489b94 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
@@ -14,9 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-
-import '../../../scripts/bundled-polymer.js';
 import '../../shared/gr-dialog/gr-dialog.js';
 import '../../../styles/shared-styles.js';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
@@ -37,7 +34,7 @@
 };
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrConfirmRevertDialog extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.js
deleted file mode 100644
index 7875fa7..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.js
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    .revertSubmissionLayout {
-      display: flex;
-    }
-    .label {
-      margin-left: var(--spacing-m);
-      margin-bottom: var(--spacing-m);
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-    .error {
-      color: var(--error-text-color);
-      margin-bottom: var(--spacing-m);
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Revert"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">
-      Revert Merged Change
-    </div>
-    <div class="main" slot="main">
-      <div class="error" hidden$="[[!_showErrorMessage]]">
-        <span> A reason is required </span>
-      </div>
-      <template is="dom-if" if="[[_showRevertSubmission]]">
-        <div class="revertSubmissionLayout">
-          <input
-            name="revertOptions"
-            type="radio"
-            id="revertSingleChange"
-            on-change="_handleRevertSingleChangeClicked"
-            checked="[[_computeIfSingleRevert(_revertType)]]"
-          />
-          <label for="revertSingleChange" class="label revertSingleChange">
-            Revert single change
-          </label>
-        </div>
-        <div class="revertSubmissionLayout">
-          <input
-            name="revertOptions"
-            type="radio"
-            id="revertSubmission"
-            on-change="_handleRevertSubmissionClicked"
-            checked="[[_computeIfRevertSubmission(_revertType)]]"
-          />
-          <label for="revertSubmission" class="label revertSubmission">
-            Revert entire submission ([[_changesCount]] Changes)
-          </label>
-        </div></template
-      >
-      <gr-endpoint-decorator name="confirm-revert-change">
-        <label for="messageInput">
-          Revert Commit Message
-        </label>
-        <iron-autogrow-textarea
-          id="messageInput"
-          class="message"
-          autocomplete="on"
-          max-rows="15"
-          bind-value="{{_message}}"
-        ></iron-autogrow-textarea>
-      </gr-endpoint-decorator>
-    </div>
-  </gr-dialog>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
new file mode 100644
index 0000000..f5561fc
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
@@ -0,0 +1,104 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    label {
+      cursor: pointer;
+      display: block;
+      width: 100%;
+    }
+    .revertSubmissionLayout {
+      display: flex;
+    }
+    .label {
+      margin-left: var(--spacing-m);
+      margin-bottom: var(--spacing-m);
+    }
+    iron-autogrow-textarea {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      width: 73ch; /* Add a char to account for the border. */
+    }
+    .error {
+      color: var(--error-text-color);
+      margin-bottom: var(--spacing-m);
+    }
+  </style>
+  <gr-dialog
+    confirm-label="Revert"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header" slot="header">
+      Revert Merged Change
+    </div>
+    <div class="main" slot="main">
+      <div class="error" hidden$="[[!_showErrorMessage]]">
+        <span> A reason is required </span>
+      </div>
+      <template is="dom-if" if="[[_showRevertSubmission]]">
+        <div class="revertSubmissionLayout">
+          <input
+            name="revertOptions"
+            type="radio"
+            id="revertSingleChange"
+            on-change="_handleRevertSingleChangeClicked"
+            checked="[[_computeIfSingleRevert(_revertType)]]"
+          />
+          <label for="revertSingleChange" class="label revertSingleChange">
+            Revert single change
+          </label>
+        </div>
+        <div class="revertSubmissionLayout">
+          <input
+            name="revertOptions"
+            type="radio"
+            id="revertSubmission"
+            on-change="_handleRevertSubmissionClicked"
+            checked="[[_computeIfRevertSubmission(_revertType)]]"
+          />
+          <label for="revertSubmission" class="label revertSubmission">
+            Revert entire submission ([[_changesCount]] Changes)
+          </label>
+        </div></template
+      >
+      <gr-endpoint-decorator name="confirm-revert-change">
+        <label for="messageInput">
+          Revert Commit Message
+        </label>
+        <iron-autogrow-textarea
+          id="messageInput"
+          class="message"
+          autocomplete="on"
+          max-rows="15"
+          bind-value="{{_message}}"
+        ></iron-autogrow-textarea>
+      </gr-endpoint-decorator>
+    </div>
+  </gr-dialog>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
deleted file mode 100644
index 3a341c5..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
+++ /dev/null
@@ -1,101 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-confirm-revert-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-confirm-revert-dialog></gr-confirm-revert-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-confirm-revert-dialog.js';
-suite('gr-confirm-revert-dialog tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox =sinon.sandbox.create();
-  });
-
-  teardown(() => sandbox.restore());
-
-  test('no match', () => {
-    assert.isNotOk(element._message);
-    const alertStub = sandbox.stub();
-    element.addEventListener('show-alert', alertStub);
-    element._populateRevertSingleChangeMessage({},
-        'not a commitHash in sight', undefined);
-    assert.isTrue(alertStub.calledOnce);
-  });
-
-  test('single line', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage({},
-        'one line commit\n\nChange-Id: abcdefg\n',
-        'abcd123');
-    const expected = 'Revert "one line commit"\n\n' +
-        'This reverts commit abcd123.\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
-  });
-
-  test('multi line', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage({},
-        'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
-        'abcd123');
-    const expected = 'Revert "many lines"\n\n' +
-        'This reverts commit abcd123.\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
-  });
-
-  test('issue above change id', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage({},
-        'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
-        'abcd123');
-    const expected = 'Revert "much lines"\n\n' +
-        'This reverts commit abcd123.\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
-  });
-
-  test('revert a revert', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage({},
-        'Revert "one line commit"\n\nChange-Id: abcdefg\n',
-        'abcd123');
-    const expected = 'Revert "Revert "one line commit""\n\n' +
-        'This reverts commit abcd123.\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.js
new file mode 100644
index 0000000..7c84043
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.js
@@ -0,0 +1,83 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-revert-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-revert-dialog');
+
+suite('gr-confirm-revert-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('no match', () => {
+    assert.isNotOk(element._message);
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    element._populateRevertSingleChangeMessage({},
+        'not a commitHash in sight', undefined);
+    assert.isTrue(alertStub.calledOnce);
+  });
+
+  test('single line', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage({},
+        'one line commit\n\nChange-Id: abcdefg\n',
+        'abcd123');
+    const expected = 'Revert "one line commit"\n\n' +
+        'This reverts commit abcd123.\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+
+  test('multi line', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage({},
+        'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
+        'abcd123');
+    const expected = 'Revert "many lines"\n\n' +
+        'This reverts commit abcd123.\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+
+  test('issue above change id', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage({},
+        'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
+        'abcd123');
+    const expected = 'Revert "much lines"\n\n' +
+        'This reverts commit abcd123.\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+
+  test('revert a revert', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage({},
+        'Revert "one line commit"\n\nChange-Id: abcdefg\n',
+        'abcd123');
+    const expected = 'Revert "Revert "one line commit""\n\n' +
+        'This reverts commit abcd123.\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
index 1437c76..a639a61 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
@@ -14,9 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-
-import '../../../scripts/bundled-polymer.js';
 import '../../shared/gr-dialog/gr-dialog.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
@@ -30,7 +27,7 @@
 const CHANGE_SUBJECT_LIMIT = 50;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrConfirmRevertSubmissionDialog extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.js
deleted file mode 100644
index 48051a0..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <!-- TODO(taoalpha): move all shared styles to a style module. -->
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Revert Submission"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Revert Submission</div>
-    <div class="main" slot="main">
-      <label for="messageInput">
-        Revert Commit Message
-      </label>
-      <iron-autogrow-textarea
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        max-rows="15"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
new file mode 100644
index 0000000..cae4e1f
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <!-- TODO(taoalpha): move all shared styles to a style module. -->
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    label {
+      cursor: pointer;
+      display: block;
+      width: 100%;
+    }
+    iron-autogrow-textarea {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      width: 73ch; /* Add a char to account for the border. */
+    }
+  </style>
+  <gr-dialog
+    confirm-label="Revert Submission"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <div class="header" slot="header">Revert Submission</div>
+    <div class="main" slot="main">
+      <label for="messageInput">
+        Revert Commit Message
+      </label>
+      <iron-autogrow-textarea
+        id="messageInput"
+        class="message"
+        autocomplete="on"
+        max-rows="15"
+        bind-value="{{message}}"
+      ></iron-autogrow-textarea>
+    </div>
+  </gr-dialog>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html
deleted file mode 100644
index a11d996..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html
+++ /dev/null
@@ -1,99 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-confirm-revert-submission-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-confirm-revert-submission-dialog>
-    </gr-confirm-revert-submission-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-confirm-revert-submission-dialog.js';
-suite('gr-confirm-revert-submission-dialog tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => sandbox.restore());
-
-  test('no match', () => {
-    assert.isNotOk(element.message);
-    const alertStub = sandbox.stub();
-    element.addEventListener('show-alert', alertStub);
-    element._populateRevertSubmissionMessage(
-        'not a commitHash in sight', {}
-    );
-    assert.isTrue(alertStub.calledOnce);
-  });
-
-  test('single line', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        'one line commit\n\nChange-Id: abcdefg\n',
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-
-  test('multi line', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-
-  test('issue above change id', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        'test \nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-
-  test('revert a revert', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        'Revert "one line commit"\n\nChange-Id: abcdefg\n',
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js
new file mode 100644
index 0000000..e2f2e9e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js
@@ -0,0 +1,80 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-revert-submission-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-revert-submission-dialog');
+
+suite('gr-confirm-revert-submission-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('no match', () => {
+    assert.isNotOk(element.message);
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    element._populateRevertSubmissionMessage(
+        'not a commitHash in sight', {}
+    );
+    assert.isTrue(alertStub.calledOnce);
+  });
+
+  test('single line', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        'one line commit\n\nChange-Id: abcdefg\n',
+        {current_revision: 'abcd123', submission_id: '111'});
+    const expected = 'Revert submission 111\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('multi line', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
+        {current_revision: 'abcd123', submission_id: '111'});
+    const expected = 'Revert submission 111\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('issue above change id', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        'test \nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
+        {current_revision: 'abcd123', submission_id: '111'});
+    const expected = 'Revert submission 111\n\n' +
+        'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('revert a revert', () => {
+    assert.isNotOk(element.message);
+    element._populateRevertSubmissionMessage(
+        'Revert "one line commit"\n\nChange-Id: abcdefg\n',
+        {current_revision: 'abcd123', submission_id: '111'});
+    const expected = 'Revert submission 111\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
index 5d599b7..42afcc2 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-icon/iron-icon.js';
 import '../../shared/gr-icons/gr-icons.js';
 import '../../shared/gr-dialog/gr-dialog.js';
@@ -28,7 +26,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-confirm-submit-dialog_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrConfirmSubmitDialog extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.js
deleted file mode 100644
index cf1a332..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.js
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    #dialog {
-      min-width: 40em;
-    }
-    p {
-      margin-bottom: var(--spacing-l);
-    }
-    .warningBeforeSubmit {
-      color: var(--error-text-color);
-      vertical-align: top;
-      margin-right: var(--spacing-s);
-    }
-    @media screen and (max-width: 50em) {
-      #dialog {
-        min-width: inherit;
-        width: 100%;
-      }
-    }
-  </style>
-  <gr-dialog
-    id="dialog"
-    confirm-label="Continue"
-    confirm-on-enter=""
-    on-cancel="_handleCancelTap"
-    on-confirm="_handleConfirmTap"
-  >
-    <div class="header" slot="header">
-      [[action.label]]
-    </div>
-    <div class="main" slot="main">
-      <gr-endpoint-decorator name="confirm-submit-change">
-        <p>Ready to submit “<strong>[[change.subject]]</strong>”?</p>
-        <template is="dom-if" if="[[change.is_private]]">
-          <p>
-            <iron-icon
-              icon="gr-icons:error"
-              class="warningBeforeSubmit"
-            ></iron-icon>
-            <strong>Heads Up!</strong>
-            Submitting this private change will also make it public.
-          </p>
-        </template>
-        <template is="dom-if" if="[[change.unresolved_comment_count]]">
-          <p>
-            <iron-icon
-              icon="gr-icons:error"
-              class="warningBeforeSubmit"
-            ></iron-icon>
-            [[_computeUnresolvedCommentsWarning(change)]]
-          </p>
-        </template>
-        <template is="dom-if" if="[[_computeHasChangeEdit(change)]]">
-          <iron-icon
-            icon="gr-icons:error"
-            class="warningBeforeSubmit"
-          ></iron-icon>
-          Your unpublished edit will not be submitted. Did you forget to click
-          <b>PUBLISH</b>?
-        </template>
-        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-        <gr-endpoint-param name="action" value="[[action]]"></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </div>
-  </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
new file mode 100644
index 0000000..84668ed
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    #dialog {
+      min-width: 40em;
+    }
+    p {
+      margin-bottom: var(--spacing-l);
+    }
+    .warningBeforeSubmit {
+      color: var(--error-text-color);
+      vertical-align: top;
+      margin-right: var(--spacing-s);
+    }
+    @media screen and (max-width: 50em) {
+      #dialog {
+        min-width: inherit;
+        width: 100%;
+      }
+    }
+  </style>
+  <gr-dialog
+    id="dialog"
+    confirm-label="Continue"
+    confirm-on-enter=""
+    on-cancel="_handleCancelTap"
+    on-confirm="_handleConfirmTap"
+  >
+    <div class="header" slot="header">
+      [[action.label]]
+    </div>
+    <div class="main" slot="main">
+      <gr-endpoint-decorator name="confirm-submit-change">
+        <p>Ready to submit “<strong>[[change.subject]]</strong>”?</p>
+        <template is="dom-if" if="[[change.is_private]]">
+          <p>
+            <iron-icon
+              icon="gr-icons:error"
+              class="warningBeforeSubmit"
+            ></iron-icon>
+            <strong>Heads Up!</strong>
+            Submitting this private change will also make it public.
+          </p>
+        </template>
+        <template is="dom-if" if="[[change.unresolved_comment_count]]">
+          <p>
+            <iron-icon
+              icon="gr-icons:error"
+              class="warningBeforeSubmit"
+            ></iron-icon>
+            [[_computeUnresolvedCommentsWarning(change)]]
+          </p>
+        </template>
+        <template is="dom-if" if="[[_computeHasChangeEdit(change)]]">
+          <iron-icon
+            icon="gr-icons:error"
+            class="warningBeforeSubmit"
+          ></iron-icon>
+          Your unpublished edit will not be submitted. Did you forget to click
+          <b>PUBLISH</b>?
+        </template>
+        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+        <gr-endpoint-param name="action" value="[[action]]"></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    </div>
+  </gr-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
deleted file mode 100644
index 30699d5..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html
+++ /dev/null
@@ -1,100 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-confirm-submit-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script src="/node_modules/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-confirm-submit-dialog></gr-confirm-submit-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-confirm-submit-dialog.js';
-suite('gr-file-list-header tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('display', () => {
-    element.action = {label: 'my-label'};
-    element.change = {
-      subject: 'my-subject',
-      revisions: {},
-    };
-    flushAsynchronousOperations();
-    const header = element.shadowRoot
-        .querySelector('.header');
-    assert.equal(header.textContent.trim(), 'my-label');
-
-    const message = element.shadowRoot
-        .querySelector('.main p');
-    assert.notEqual(message.textContent.length, 0);
-    assert.notEqual(message.textContent.indexOf('my-subject'), -1);
-  });
-
-  test('_computeUnresolvedCommentsWarning', () => {
-    const change = {unresolved_comment_count: 1};
-    assert.equal(element._computeUnresolvedCommentsWarning(change),
-        'Heads Up! 1 unresolved comment.');
-
-    const change2 = {unresolved_comment_count: 2};
-    assert.equal(element._computeUnresolvedCommentsWarning(change2),
-        'Heads Up! 2 unresolved comments.');
-  });
-
-  test('_computeHasChangeEdit', () => {
-    const change = {
-      revisions: {
-        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
-          _number: 'edit',
-        },
-      },
-      unresolved_comment_count: 0,
-    };
-
-    assert.equal(element._computeHasChangeEdit(change), true);
-
-    const change2 = {
-      revisions: {
-        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
-          _number: 2,
-        },
-      },
-    };
-    assert.equal(element._computeHasChangeEdit(change2), false);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
new file mode 100644
index 0000000..77331f7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
@@ -0,0 +1,79 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-confirm-submit-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-confirm-submit-dialog');
+
+suite('gr-file-list-header tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('display', () => {
+    element.action = {label: 'my-label'};
+    element.change = {
+      subject: 'my-subject',
+      revisions: {},
+    };
+    flushAsynchronousOperations();
+    const header = element.shadowRoot
+        .querySelector('.header');
+    assert.equal(header.textContent.trim(), 'my-label');
+
+    const message = element.shadowRoot
+        .querySelector('.main p');
+    assert.notEqual(message.textContent.length, 0);
+    assert.notEqual(message.textContent.indexOf('my-subject'), -1);
+  });
+
+  test('_computeUnresolvedCommentsWarning', () => {
+    const change = {unresolved_comment_count: 1};
+    assert.equal(element._computeUnresolvedCommentsWarning(change),
+        'Heads Up! 1 unresolved comment.');
+
+    const change2 = {unresolved_comment_count: 2};
+    assert.equal(element._computeUnresolvedCommentsWarning(change2),
+        'Heads Up! 2 unresolved comments.');
+  });
+
+  test('_computeHasChangeEdit', () => {
+    const change = {
+      revisions: {
+        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
+          _number: 'edit',
+        },
+      },
+      unresolved_comment_count: 0,
+    };
+
+    assert.equal(element._computeHasChangeEdit(change), true);
+
+    const change2 = {
+      revisions: {
+        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
+          _number: 2,
+        },
+      },
+    };
+    assert.equal(element._computeHasChangeEdit(change2), false);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index 4c457a1..8c11129 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -14,27 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-download-commands/gr-download-commands.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-download-dialog_html.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
-import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {patchNumEquals} from '../../../utils/patch-set-util.js';
+import {changeBaseURL} from '../../../utils/change-util.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrDownloadDialog extends mixinBehaviors( [
-  PatchSetBehavior,
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrDownloadDialog extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-download-dialog'; }
@@ -89,7 +82,7 @@
     let commandObj;
     if (!change) return [];
     for (const rev of Object.values(change.revisions || {})) {
-      if (this.patchNumEquals(rev._number, patchNum) &&
+      if (patchNumEquals(rev._number, patchNum) &&
           rev && rev.fetch && rev.fetch.hasOwnProperty(_selectedScheme)) {
         commandObj = rev.fetch[_selectedScheme].commands;
         break;
@@ -135,10 +128,10 @@
    */
   _computeDownloadLink(change, patchNum, opt_zip) {
     // Polymer 2: check for undefined
-    if ([change, patchNum].some(arg => arg === undefined)) {
+    if ([change, patchNum].includes(undefined)) {
       return '';
     }
-    return this.changeBaseURL(change.project, change._number, patchNum) +
+    return changeBaseURL(change.project, change._number, patchNum) +
         '/patch?' + (opt_zip ? 'zip' : 'download');
   }
 
@@ -151,13 +144,13 @@
    */
   _computeDownloadFilename(change, patchNum, opt_zip) {
     // Polymer 2: check for undefined
-    if ([change, patchNum].some(arg => arg === undefined)) {
+    if ([change, patchNum].includes(undefined)) {
       return '';
     }
 
     let shortRev = '';
     for (const rev in change.revisions) {
-      if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
+      if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
         shortRev = rev.substr(0, 7);
         break;
       }
@@ -167,11 +160,11 @@
 
   _computeHidePatchFile(change, patchNum) {
     // Polymer 2: check for undefined
-    if ([change, patchNum].some(arg => arg === undefined)) {
+    if ([change, patchNum].includes(undefined)) {
       return false;
     }
     for (const rev of Object.values(change.revisions || {})) {
-      if (this.patchNumEquals(rev._number, patchNum)) {
+      if (patchNumEquals(rev._number, patchNum)) {
         const parentLength = rev.commit && rev.commit.parents ?
           rev.commit.parents.length : 0;
         return parentLength == 0;
@@ -182,21 +175,21 @@
 
   _computeArchiveDownloadLink(change, patchNum, format) {
     // Polymer 2: check for undefined
-    if ([change, patchNum, format].some(arg => arg === undefined)) {
+    if ([change, patchNum, format].includes(undefined)) {
       return '';
     }
-    return this.changeBaseURL(change.project, change._number, patchNum) +
+    return changeBaseURL(change.project, change._number, patchNum) +
         '/archive?format=' + format;
   }
 
   _computeSchemes(change, patchNum) {
     // Polymer 2: check for undefined
-    if ([change, patchNum].some(arg => arg === undefined)) {
+    if ([change, patchNum].includes(undefined)) {
       return [];
     }
 
     for (const rev of Object.values(change.revisions || {})) {
-      if (this.patchNumEquals(rev._number, patchNum)) {
+      if (patchNumEquals(rev._number, patchNum)) {
         const fetch = rev.fetch;
         if (fetch) {
           return Object.keys(fetch).sort();
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js
deleted file mode 100644
index 9569c03..0000000
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.js
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      padding: var(--spacing-m) 0;
-    }
-    section {
-      display: flex;
-      padding: var(--spacing-m) var(--spacing-xl);
-    }
-    .flexContainer {
-      display: flex;
-      justify-content: space-between;
-      padding-top: var(--spacing-m);
-    }
-    .footer {
-      justify-content: flex-end;
-    }
-    .closeButtonContainer {
-      align-items: flex-end;
-      display: flex;
-      flex: 0;
-      justify-content: flex-end;
-    }
-    .patchFiles,
-    .archivesContainer {
-      padding-bottom: var(--spacing-m);
-    }
-    .patchFiles {
-      margin-right: var(--spacing-xxl);
-    }
-    .patchFiles a,
-    .archives a {
-      display: inline-block;
-      margin-right: var(--spacing-l);
-    }
-    .patchFiles a:last-of-type,
-    .archives a:last-of-type {
-      margin-right: 0;
-    }
-    .title {
-      flex: 1;
-      font-weight: var(--font-weight-bold);
-    }
-    .hidden {
-      display: none;
-    }
-  </style>
-  <section>
-    <h3 class="title">
-      Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
-    </h3>
-  </section>
-  <section class$="[[_computeShowDownloadCommands(_schemes)]]">
-    <gr-download-commands
-      id="downloadCommands"
-      commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
-      schemes="[[_schemes]]"
-      selected-scheme="{{_selectedScheme}}"
-    ></gr-download-commands>
-  </section>
-  <section class="flexContainer">
-    <div
-      class="patchFiles"
-      hidden="[[_computeHidePatchFile(change, patchNum)]]"
-    >
-      <label>Patch file</label>
-      <div>
-        <a
-          id="download"
-          href$="[[_computeDownloadLink(change, patchNum)]]"
-          download=""
-        >
-          [[_computeDownloadFilename(change, patchNum)]]
-        </a>
-        <a href$="[[_computeZipDownloadLink(change, patchNum)]]" download="">
-          [[_computeZipDownloadFilename(change, patchNum)]]
-        </a>
-      </div>
-    </div>
-    <div
-      class="archivesContainer"
-      hidden$="[[!config.archives.length]]"
-      hidden=""
-    >
-      <label>Archive</label>
-      <div id="archives" class="archives">
-        <template is="dom-repeat" items="[[config.archives]]" as="format">
-          <a
-            href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]"
-            download=""
-          >
-            [[format]]
-          </a>
-        </template>
-      </div>
-    </div>
-  </section>
-  <section class="footer">
-    <span class="closeButtonContainer">
-      <gr-button id="closeButton" link="" on-click="_handleCloseTap"
-        >Close</gr-button
-      >
-    </span>
-  </section>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
new file mode 100644
index 0000000..90185a6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
@@ -0,0 +1,120 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      padding: var(--spacing-m) 0;
+    }
+    section {
+      display: flex;
+      padding: var(--spacing-m) var(--spacing-xl);
+    }
+    .flexContainer {
+      display: flex;
+      justify-content: space-between;
+      padding-top: var(--spacing-m);
+    }
+    .footer {
+      justify-content: flex-end;
+    }
+    .closeButtonContainer {
+      align-items: flex-end;
+      display: flex;
+      flex: 0;
+      justify-content: flex-end;
+    }
+    .patchFiles,
+    .archivesContainer {
+      padding-bottom: var(--spacing-m);
+    }
+    .patchFiles {
+      margin-right: var(--spacing-xxl);
+    }
+    .patchFiles a,
+    .archives a {
+      display: inline-block;
+      margin-right: var(--spacing-l);
+    }
+    .patchFiles a:last-of-type,
+    .archives a:last-of-type {
+      margin-right: 0;
+    }
+    .hidden {
+      display: none;
+    }
+  </style>
+  <section>
+    <h3 class="heading-3">
+      Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
+    </h3>
+  </section>
+  <section class$="[[_computeShowDownloadCommands(_schemes)]]">
+    <gr-download-commands
+      id="downloadCommands"
+      commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
+      schemes="[[_schemes]]"
+      selected-scheme="{{_selectedScheme}}"
+    ></gr-download-commands>
+  </section>
+  <section class="flexContainer">
+    <div
+      class="patchFiles"
+      hidden="[[_computeHidePatchFile(change, patchNum)]]"
+    >
+      <label>Patch file</label>
+      <div>
+        <a
+          id="download"
+          href$="[[_computeDownloadLink(change, patchNum)]]"
+          download=""
+        >
+          [[_computeDownloadFilename(change, patchNum)]]
+        </a>
+        <a href$="[[_computeZipDownloadLink(change, patchNum)]]" download="">
+          [[_computeZipDownloadFilename(change, patchNum)]]
+        </a>
+      </div>
+    </div>
+    <div
+      class="archivesContainer"
+      hidden$="[[!config.archives.length]]"
+      hidden=""
+    >
+      <label>Archive</label>
+      <div id="archives" class="archives">
+        <template is="dom-repeat" items="[[config.archives]]" as="format">
+          <a
+            href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]"
+            download=""
+          >
+            [[format]]
+          </a>
+        </template>
+      </div>
+    </div>
+  </section>
+  <section class="footer">
+    <span class="closeButtonContainer">
+      <gr-button id="closeButton" link="" on-click="_handleCloseTap"
+        >Close</gr-button
+      >
+    </span>
+  </section>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
deleted file mode 100644
index 46c57fe..0000000
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ /dev/null
@@ -1,222 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-download-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-download-dialog></gr-download-dialog>
-  </template>
-</test-fixture>
-
-<test-fixture id="loggedIn">
-  <template>
-    <gr-download-dialog logged-in></gr-download-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-download-dialog.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-function getChangeObject() {
-  return {
-    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
-    revisions: {
-      '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
-        _number: 1,
-        commit: {
-          parents: [],
-        },
-        fetch: {
-          repo: {
-            commands: {
-              repo: 'repo download test-project 5/1',
-            },
-          },
-          ssh: {
-            commands: {
-              'Checkout':
-                'git fetch ' +
-                'ssh://andybons@localhost:29418/test-project ' +
-                'refs/changes/05/5/1 && git checkout FETCH_HEAD',
-              'Cherry Pick':
-                'git fetch ' +
-                'ssh://andybons@localhost:29418/test-project ' +
-                'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
-              'Format Patch':
-                'git fetch ' +
-                'ssh://andybons@localhost:29418/test-project ' +
-                'refs/changes/05/5/1 ' +
-                '&& git format-patch -1 --stdout FETCH_HEAD',
-              'Pull':
-                'git pull ' +
-                'ssh://andybons@localhost:29418/test-project ' +
-                'refs/changes/05/5/1',
-            },
-          },
-          http: {
-            commands: {
-              'Checkout':
-                'git fetch ' +
-                'http://andybons@localhost:8080/a/test-project ' +
-                'refs/changes/05/5/1 && git checkout FETCH_HEAD',
-              'Cherry Pick':
-                'git fetch ' +
-                'http://andybons@localhost:8080/a/test-project ' +
-                'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
-              'Format Patch':
-                'git fetch ' +
-                'http://andybons@localhost:8080/a/test-project ' +
-                'refs/changes/05/5/1 && ' +
-                'git format-patch -1 --stdout FETCH_HEAD',
-              'Pull':
-                'git pull ' +
-                'http://andybons@localhost:8080/a/test-project ' +
-                'refs/changes/05/5/1',
-            },
-          },
-        },
-      },
-    },
-  };
-}
-
-function getChangeObjectNoFetch() {
-  return {
-    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
-    revisions: {
-      '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
-        _number: 1,
-        commit: {
-          parents: [],
-        },
-        fetch: {},
-      },
-    },
-  };
-}
-
-suite('gr-download-dialog', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-
-    element = fixture('basic');
-    element.patchNum = '1';
-    element.config = {
-      schemes: {
-        'anonymous http': {},
-        'http': {},
-        'repo': {},
-        'ssh': {},
-      },
-      archives: ['tgz', 'tar', 'tbz2', 'txz'],
-    };
-
-    flushAsynchronousOperations();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('anchors use download attribute', () => {
-    const anchors = Array.from(
-        dom(element.root).querySelectorAll('a'));
-    assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
-  });
-
-  suite('gr-download-dialog tests with no fetch options', () => {
-    setup(() => {
-      element.change = getChangeObjectNoFetch();
-      flushAsynchronousOperations();
-    });
-
-    test('focuses on first download link if no copy links', () => {
-      const focusStub = sandbox.stub(element.$.download, 'focus');
-      element.focus();
-      assert.isTrue(focusStub.called);
-      focusStub.restore();
-    });
-  });
-
-  suite('gr-download-dialog with fetch options', () => {
-    setup(() => {
-      element.change = getChangeObject();
-      flushAsynchronousOperations();
-    });
-
-    test('focuses on first copy link', () => {
-      const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
-      element.focus();
-      flushAsynchronousOperations();
-      assert.isTrue(focusStub.called);
-      focusStub.restore();
-    });
-
-    test('computed fields', () => {
-      assert.equal(element._computeArchiveDownloadLink(
-          {project: 'test/project', _number: 123}, 2, 'tgz'),
-      '/changes/test%2Fproject~123/revisions/2/archive?format=tgz');
-    });
-
-    test('close event', done => {
-      element.addEventListener('close', () => {
-        done();
-      });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.closeButtonContainer gr-button'));
-    });
-  });
-
-  test('_computeShowDownloadCommands', () => {
-    assert.equal(element._computeShowDownloadCommands([]), 'hidden');
-    assert.equal(element._computeShowDownloadCommands(['test']), '');
-  });
-
-  test('_computeHidePatchFile', () => {
-    const patchNum = '1';
-
-    const change1 = {
-      revisions: {
-        r1: {_number: 1, commit: {parents: []}},
-      },
-    };
-    assert.isTrue(element._computeHidePatchFile(change1, patchNum));
-
-    const change2 = {
-      revisions: {
-        r1: {_number: 1, commit: {parents: [
-          {commit: 'p1'},
-        ]}},
-      },
-    };
-    assert.isFalse(element._computeHidePatchFile(change2, patchNum));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
new file mode 100644
index 0000000..5dd5de7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
@@ -0,0 +1,195 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-download-dialog.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-download-dialog');
+
+function getChangeObject() {
+  return {
+    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+    revisions: {
+      '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
+        _number: 1,
+        commit: {
+          parents: [],
+        },
+        fetch: {
+          repo: {
+            commands: {
+              repo: 'repo download test-project 5/1',
+            },
+          },
+          ssh: {
+            commands: {
+              'Checkout':
+                'git fetch ' +
+                'ssh://andybons@localhost:29418/test-project ' +
+                'refs/changes/05/5/1 && git checkout FETCH_HEAD',
+              'Cherry Pick':
+                'git fetch ' +
+                'ssh://andybons@localhost:29418/test-project ' +
+                'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
+              'Format Patch':
+                'git fetch ' +
+                'ssh://andybons@localhost:29418/test-project ' +
+                'refs/changes/05/5/1 ' +
+                '&& git format-patch -1 --stdout FETCH_HEAD',
+              'Pull':
+                'git pull ' +
+                'ssh://andybons@localhost:29418/test-project ' +
+                'refs/changes/05/5/1',
+            },
+          },
+          http: {
+            commands: {
+              'Checkout':
+                'git fetch ' +
+                'http://andybons@localhost:8080/a/test-project ' +
+                'refs/changes/05/5/1 && git checkout FETCH_HEAD',
+              'Cherry Pick':
+                'git fetch ' +
+                'http://andybons@localhost:8080/a/test-project ' +
+                'refs/changes/05/5/1 && git cherry-pick FETCH_HEAD',
+              'Format Patch':
+                'git fetch ' +
+                'http://andybons@localhost:8080/a/test-project ' +
+                'refs/changes/05/5/1 && ' +
+                'git format-patch -1 --stdout FETCH_HEAD',
+              'Pull':
+                'git pull ' +
+                'http://andybons@localhost:8080/a/test-project ' +
+                'refs/changes/05/5/1',
+            },
+          },
+        },
+      },
+    },
+  };
+}
+
+function getChangeObjectNoFetch() {
+  return {
+    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72',
+    revisions: {
+      '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
+        _number: 1,
+        commit: {
+          parents: [],
+        },
+        fetch: {},
+      },
+    },
+  };
+}
+
+suite('gr-download-dialog', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.patchNum = '1';
+    element.config = {
+      schemes: {
+        'anonymous http': {},
+        'http': {},
+        'repo': {},
+        'ssh': {},
+      },
+      archives: ['tgz', 'tar', 'tbz2', 'txz'],
+    };
+
+    flushAsynchronousOperations();
+  });
+
+  test('anchors use download attribute', () => {
+    const anchors = Array.from(
+        dom(element.root).querySelectorAll('a'));
+    assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
+  });
+
+  suite('gr-download-dialog tests with no fetch options', () => {
+    setup(() => {
+      element.change = getChangeObjectNoFetch();
+      flushAsynchronousOperations();
+    });
+
+    test('focuses on first download link if no copy links', () => {
+      const focusStub = sinon.stub(element.$.download, 'focus');
+      element.focus();
+      assert.isTrue(focusStub.called);
+      focusStub.restore();
+    });
+  });
+
+  suite('gr-download-dialog with fetch options', () => {
+    setup(() => {
+      element.change = getChangeObject();
+      flushAsynchronousOperations();
+    });
+
+    test('focuses on first copy link', () => {
+      const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
+      element.focus();
+      flushAsynchronousOperations();
+      assert.isTrue(focusStub.called);
+      focusStub.restore();
+    });
+
+    test('computed fields', () => {
+      assert.equal(element._computeArchiveDownloadLink(
+          {project: 'test/project', _number: 123}, 2, 'tgz'),
+      '/changes/test%2Fproject~123/revisions/2/archive?format=tgz');
+    });
+
+    test('close event', done => {
+      element.addEventListener('close', () => {
+        done();
+      });
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.closeButtonContainer gr-button'));
+    });
+  });
+
+  test('_computeShowDownloadCommands', () => {
+    assert.equal(element._computeShowDownloadCommands([]), 'hidden');
+    assert.equal(element._computeShowDownloadCommands(['test']), '');
+  });
+
+  test('_computeHidePatchFile', () => {
+    const patchNum = '1';
+
+    const change1 = {
+      revisions: {
+        r1: {_number: 1, commit: {parents: []}},
+      },
+    };
+    assert.isTrue(element._computeHidePatchFile(change1, patchNum));
+
+    const change2 = {
+      revisions: {
+        r1: {_number: 1, commit: {parents: [
+          {commit: 'p1'},
+        ]}},
+      },
+    };
+    assert.isFalse(element._computeHidePatchFile(change2, patchNum));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-constants.js b/polygerrit-ui/app/elements/change/gr-file-list-constants.js
deleted file mode 100644
index 5bba786..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-constants.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-export const GrFileListConstants = {
-  FilesExpandedState: {
-    ALL: 'all',
-    NONE: 'none',
-    SOME: 'some',
-  },
-};
-
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-constants.ts b/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
new file mode 100644
index 0000000..7c07e68
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
@@ -0,0 +1,24 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export const GrFileListConstants = {
+  FilesExpandedState: {
+    ALL: 'all',
+    NONE: 'none',
+    SOME: 'some',
+  },
+};
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
index 73c6721..05f2ab0 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector.js';
 import '../../diff/gr-patch-range-select/gr-patch-range-select.js';
@@ -28,29 +26,30 @@
 import '../../shared/gr-icons/gr-icons.js';
 import '../gr-commit-info/gr-commit-info.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-file-list-header_html.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {GrFileListConstants} from '../gr-file-list-constants.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {
+  computeLatestPatchNum,
+  getRevisionByPatchNum,
+  patchNumEquals,
+} from '../../../utils/patch-set-util.js';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
 const MERGED_STATUS = 'MERGED';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrFileListHeader extends mixinBehaviors( [
-  PatchSetBehavior,
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrFileListHeader extends KeyboardShortcutMixin(
+    GestureEventListeners(
+        LegacyElementMixin(
+            PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-file-list-header'; }
@@ -162,7 +161,7 @@
 
   _computeDescriptionReadOnly(loggedIn, change, account) {
     // Polymer 2: check for undefined
-    if ([loggedIn, change, account].some(arg => arg === undefined)) {
+    if ([loggedIn, change, account].includes(undefined)) {
       return undefined;
     }
 
@@ -171,11 +170,11 @@
 
   _computePatchSetDescription(change, patchNum) {
     // Polymer 2: check for undefined
-    if ([change, patchNum].some(arg => arg === undefined)) {
+    if ([change, patchNum].includes(undefined)) {
       return;
     }
 
-    const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
+    const rev = getRevisionByPatchNum(change.revisions, patchNum);
     this._patchsetDescription = (rev && rev.description) ?
       rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
   }
@@ -213,7 +212,7 @@
   _updateDescription(desc, e) {
     const target = dom(e).rootTarget;
     if (target) { target.disabled = true; }
-    const rev = this.getRevisionByPatchNum(this.change.revisions,
+    const rev = getRevisionByPatchNum(this.change.revisions,
         this.patchNum);
     const sha = this._getPatchsetHash(this.change.revisions, rev);
     return this.$.restAPI.setDescription(this.changeNum, this.patchNum, desc)
@@ -240,8 +239,8 @@
 
   _handlePatchChange(e) {
     const {basePatchNum, patchNum} = e.detail;
-    if (this.patchNumEquals(basePatchNum, this.basePatchNum) &&
-        this.patchNumEquals(patchNum, this.patchNum)) { return; }
+    if (patchNumEquals(basePatchNum, this.basePatchNum) &&
+        patchNumEquals(patchNum, this.patchNum)) { return; }
     GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
   }
 
@@ -271,8 +270,8 @@
   }
 
   _computePatchInfoClass(patchNum, allPatchSets) {
-    const latestNum = this.computeLatestPatchNum(allPatchSets);
-    if (this.patchNumEquals(patchNum, latestNum)) {
+    const latestNum = computeLatestPatchNum(allPatchSets);
+    if (patchNumEquals(patchNum, latestNum)) {
       return '';
     }
     return 'patchInfoOldPatchSet';
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js
deleted file mode 100644
index 10b8606..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.js
+++ /dev/null
@@ -1,270 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .prefsButton {
-      float: right;
-    }
-    .collapseToggleButton {
-      text-decoration: none;
-    }
-    .patchInfoOldPatchSet.patchInfo-header {
-      background-color: var(--emphasis-color);
-    }
-    .patchInfo-header {
-      align-items: center;
-      border-top: 1px solid var(--border-color);
-      display: flex;
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    .patchInfo-left {
-      align-items: baseline;
-      display: flex;
-    }
-    .patchInfoContent {
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-    }
-    .patchInfo-header .container.latestPatchContainer {
-      display: none;
-    }
-    .patchInfoOldPatchSet .container.latestPatchContainer {
-      display: initial;
-    }
-    .latestPatchContainer a {
-      text-decoration: none;
-    }
-    gr-editable-label.descriptionLabel {
-      max-width: 100%;
-    }
-    .mobile {
-      display: none;
-    }
-    .patchInfo-header .container {
-      align-items: center;
-      display: flex;
-    }
-    .downloadContainer,
-    .uploadContainer,
-    .includedInContainer {
-      margin-right: 16px;
-    }
-    .includedInContainer.hide,
-    .uploadContainer.hide {
-      display: none;
-    }
-    .rightControls {
-      align-self: flex-end;
-      margin: auto 0 auto auto;
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-      font-weight: var(--font-weight-normal);
-      justify-content: flex-end;
-    }
-    #collapseBtn,
-    .expanded #expandBtn,
-    .fileViewActions {
-      display: none;
-    }
-    .expanded #expandBtn {
-      display: none;
-    }
-    gr-linked-chip {
-      --linked-chip-text-color: var(--primary-text-color);
-    }
-    .expanded #collapseBtn,
-    .openFile .fileViewActions {
-      align-items: center;
-      display: flex;
-    }
-    .rightControls gr-button,
-    gr-patch-range-select {
-      margin: 0 -4px;
-    }
-    .fileViewActions gr-button {
-      margin: 0;
-      --gr-button: {
-        padding: 2px 4px;
-      }
-    }
-    .editMode .hideOnEdit {
-      display: none;
-    }
-    .showOnEdit {
-      display: none;
-    }
-    .editMode .showOnEdit {
-      display: initial;
-    }
-    .editMode .showOnEdit.flexContainer {
-      align-items: center;
-      display: flex;
-    }
-    .label {
-      font-weight: var(--font-weight-bold);
-      margin-right: 24px;
-    }
-    gr-commit-info,
-    gr-edit-controls {
-      margin-right: -5px;
-    }
-    .fileViewActionsLabel {
-      margin-right: var(--spacing-xs);
-    }
-    @media screen and (max-width: 50em) {
-      .patchInfo-header .desktop {
-        display: none;
-      }
-    }
-  </style>
-  <div
-    class$="patchInfo-header [[_computeEditModeClass(editMode)]] [[_computePatchInfoClass(patchNum, allPatchSets)]]"
-  >
-    <div class="patchInfo-left">
-      <div class="patchInfoContent">
-        <gr-patch-range-select
-          id="rangeSelect"
-          change-comments="[[changeComments]]"
-          change-num="[[changeNum]]"
-          patch-num="[[patchNum]]"
-          base-patch-num="[[basePatchNum]]"
-          available-patches="[[allPatchSets]]"
-          revisions="[[change.revisions]]"
-          revision-info="[[revisionInfo]]"
-          on-patch-range-change="_handlePatchChange"
-        >
-        </gr-patch-range-select>
-        <span class="separator"></span>
-        <gr-commit-info
-          change="[[change]]"
-          server-config="[[serverConfig]]"
-          commit-info="[[commitInfo]]"
-        ></gr-commit-info>
-        <span class="container latestPatchContainer">
-          <span class="separator"></span>
-          <a href$="[[changeUrl]]">Go to latest patch set</a>
-        </span>
-        <span class="container descriptionContainer hideOnEdit">
-          <span class="separator"></span>
-          <template is="dom-if" if="[[_patchsetDescription]]">
-            <gr-linked-chip
-              id="descriptionChip"
-              text="[[_patchsetDescription]]"
-              removable="[[!_descriptionReadOnly]]"
-              on-remove="_handleDescriptionRemoved"
-            ></gr-linked-chip>
-          </template>
-          <template is="dom-if" if="[[!_patchsetDescription]]">
-            <gr-editable-label
-              id="descriptionLabel"
-              uppercase=""
-              class="descriptionLabel"
-              label-text="Add patchset description"
-              value="[[_patchsetDescription]]"
-              placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
-              read-only="[[_descriptionReadOnly]]"
-              on-changed="_handleDescriptionChanged"
-            ></gr-editable-label>
-          </template>
-        </span>
-      </div>
-    </div>
-    <div class$="rightControls [[_computeExpandedClass(filesExpanded)]]">
-      <span class="showOnEdit flexContainer">
-        <gr-edit-controls
-          id="editControls"
-          patch-num="[[patchNum]]"
-          change="[[change]]"
-        ></gr-edit-controls>
-        <span class="separator"></span>
-      </span>
-      <span class$="[[_computeUploadHelpContainerClass(change, account)]]">
-        <gr-button link="" class="upload" on-click="_handleUploadTap"
-          >Update Change</gr-button
-        >
-      </span>
-      <span class="downloadContainer desktop">
-        <gr-button
-          link=""
-          class="download"
-          title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
-                ShortcutSection.ACTIONS)]]"
-          on-click="_handleDownloadTap"
-          >Download</gr-button
-        >
-      </span>
-      <span class$="includedInContainer [[_hideIncludedIn(change)]] desktop">
-        <gr-button link="" class="includedIn" on-click="_handleIncludedInTap"
-          >Included In</gr-button
-        >
-      </span>
-      <template
-        is="dom-if"
-        if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
-      >
-        <gr-button
-          id="expandBtn"
-          link=""
-          title="[[createTitle(Shortcut.EXPAND_ALL_DIFF_CONTEXT,
-                ShortcutSection.DIFFS)]]"
-          on-click="_expandAllDiffs"
-          >Expand All</gr-button
-        >
-        <gr-button id="collapseBtn" link="" on-click="_collapseAllDiffs"
-          >Collapse All</gr-button
-        >
-      </template>
-      <template
-        is="dom-if"
-        if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
-      >
-        <div class="warning">
-          Bulk actions disabled because there are too many files.
-        </div>
-      </template>
-      <div class="fileViewActions">
-        <span class="separator"></span>
-        <span class="fileViewActionsLabel">Diff view:</span>
-        <gr-diff-mode-selector
-          id="modeSelect"
-          mode="{{diffViewMode}}"
-          save-on-change="[[!diffPrefsDisabled]]"
-        ></gr-diff-mode-selector>
-        <span
-          id="diffPrefsContainer"
-          class="hideOnEdit"
-          hidden$="[[_computePrefsButtonHidden(diffPrefs, diffPrefsDisabled)]]"
-          hidden=""
-        >
-          <gr-button
-            link=""
-            has-tooltip=""
-            title="Diff preferences"
-            class="prefsButton desktop"
-            on-click="_handlePrefsTap"
-            ><iron-icon icon="gr-icons:settings"></iron-icon
-          ></gr-button>
-        </span>
-      </div>
-    </div>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
new file mode 100644
index 0000000..beabeef
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
@@ -0,0 +1,272 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .prefsButton {
+      float: right;
+    }
+    .collapseToggleButton {
+      text-decoration: none;
+    }
+    .patchInfoOldPatchSet.patchInfo-header {
+      background-color: var(--emphasis-color);
+    }
+    .patchInfo-header {
+      align-items: center;
+      border-top: 1px solid var(--border-color);
+      display: flex;
+      padding: var(--spacing-s) var(--spacing-l);
+    }
+    .patchInfo-left {
+      align-items: baseline;
+      display: flex;
+    }
+    .patchInfoContent {
+      align-items: center;
+      display: flex;
+      flex-wrap: wrap;
+    }
+    .patchInfo-header .container.latestPatchContainer {
+      display: none;
+    }
+    .patchInfoOldPatchSet .container.latestPatchContainer {
+      display: initial;
+    }
+    .latestPatchContainer a {
+      text-decoration: none;
+    }
+    gr-editable-label.descriptionLabel {
+      max-width: 100%;
+    }
+    .mobile {
+      display: none;
+    }
+    .patchInfo-header .container {
+      align-items: center;
+      display: flex;
+    }
+    .downloadContainer,
+    .uploadContainer,
+    .includedInContainer {
+      margin-right: 16px;
+    }
+    .includedInContainer.hide,
+    .uploadContainer.hide {
+      display: none;
+    }
+    .rightControls {
+      align-self: flex-end;
+      margin: auto 0 auto auto;
+      align-items: center;
+      display: flex;
+      flex-wrap: wrap;
+      font-weight: var(--font-weight-normal);
+      justify-content: flex-end;
+    }
+    #collapseBtn,
+    .expanded #expandBtn,
+    .fileViewActions {
+      display: none;
+    }
+    .expanded #expandBtn {
+      display: none;
+    }
+    gr-linked-chip {
+      --linked-chip-text-color: var(--primary-text-color);
+    }
+    .expanded #collapseBtn,
+    .openFile .fileViewActions {
+      align-items: center;
+      display: flex;
+    }
+    .rightControls gr-button,
+    gr-patch-range-select {
+      margin: 0 -4px;
+    }
+    .fileViewActions gr-button {
+      margin: 0;
+      --gr-button: {
+        padding: 2px 4px;
+      }
+    }
+    .editMode .hideOnEdit {
+      display: none;
+    }
+    .showOnEdit {
+      display: none;
+    }
+    .editMode .showOnEdit {
+      display: initial;
+    }
+    .editMode .showOnEdit.flexContainer {
+      align-items: center;
+      display: flex;
+    }
+    .label {
+      font-weight: var(--font-weight-bold);
+      margin-right: 24px;
+    }
+    gr-commit-info,
+    gr-edit-controls {
+      margin-right: -5px;
+    }
+    .fileViewActionsLabel {
+      margin-right: var(--spacing-xs);
+    }
+    @media screen and (max-width: 50em) {
+      .patchInfo-header .desktop {
+        display: none;
+      }
+    }
+  </style>
+  <div
+    class$="patchInfo-header [[_computeEditModeClass(editMode)]] [[_computePatchInfoClass(patchNum, allPatchSets)]]"
+  >
+    <div class="patchInfo-left">
+      <div class="patchInfoContent">
+        <gr-patch-range-select
+          id="rangeSelect"
+          change-comments="[[changeComments]]"
+          change-num="[[changeNum]]"
+          patch-num="[[patchNum]]"
+          base-patch-num="[[basePatchNum]]"
+          available-patches="[[allPatchSets]]"
+          revisions="[[change.revisions]]"
+          revision-info="[[revisionInfo]]"
+          on-patch-range-change="_handlePatchChange"
+        >
+        </gr-patch-range-select>
+        <span class="separator"></span>
+        <gr-commit-info
+          change="[[change]]"
+          server-config="[[serverConfig]]"
+          commit-info="[[commitInfo]]"
+        ></gr-commit-info>
+        <span class="container latestPatchContainer">
+          <span class="separator"></span>
+          <a href$="[[changeUrl]]">Go to latest patch set</a>
+        </span>
+        <span class="container descriptionContainer hideOnEdit">
+          <span class="separator"></span>
+          <template is="dom-if" if="[[_patchsetDescription]]">
+            <gr-linked-chip
+              id="descriptionChip"
+              text="[[_patchsetDescription]]"
+              removable="[[!_descriptionReadOnly]]"
+              on-remove="_handleDescriptionRemoved"
+            ></gr-linked-chip>
+          </template>
+          <template is="dom-if" if="[[!_patchsetDescription]]">
+            <gr-editable-label
+              id="descriptionLabel"
+              uppercase=""
+              class="descriptionLabel"
+              label-text="Add patchset description"
+              value="[[_patchsetDescription]]"
+              placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
+              read-only="[[_descriptionReadOnly]]"
+              on-changed="_handleDescriptionChanged"
+            ></gr-editable-label>
+          </template>
+        </span>
+      </div>
+    </div>
+    <div class$="rightControls [[_computeExpandedClass(filesExpanded)]]">
+      <template is="dom-if" if="[[editMode]]">
+        <span class="showOnEdit flexContainer">
+          <gr-edit-controls
+            id="editControls"
+            patch-num="[[patchNum]]"
+            change="[[change]]"
+          ></gr-edit-controls>
+          <span class="separator"></span>
+        </span>
+      </template>
+      <span class$="[[_computeUploadHelpContainerClass(change, account)]]">
+        <gr-button link="" class="upload" on-click="_handleUploadTap"
+          >Update Change</gr-button
+        >
+      </span>
+      <span class="downloadContainer desktop">
+        <gr-button
+          link=""
+          class="download"
+          title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
+                ShortcutSection.ACTIONS)]]"
+          on-click="_handleDownloadTap"
+          >Download</gr-button
+        >
+      </span>
+      <span class$="includedInContainer [[_hideIncludedIn(change)]] desktop">
+        <gr-button link="" class="includedIn" on-click="_handleIncludedInTap"
+          >Included In</gr-button
+        >
+      </span>
+      <template
+        is="dom-if"
+        if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
+      >
+        <gr-button
+          id="expandBtn"
+          link=""
+          title="[[createTitle(Shortcut.EXPAND_ALL_DIFF_CONTEXT,
+                ShortcutSection.DIFFS)]]"
+          on-click="_expandAllDiffs"
+          >Expand All</gr-button
+        >
+        <gr-button id="collapseBtn" link="" on-click="_collapseAllDiffs"
+          >Collapse All</gr-button
+        >
+      </template>
+      <template
+        is="dom-if"
+        if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
+      >
+        <div class="warning">
+          Bulk actions disabled because there are too many files.
+        </div>
+      </template>
+      <div class="fileViewActions">
+        <span class="separator"></span>
+        <span class="fileViewActionsLabel">Diff view:</span>
+        <gr-diff-mode-selector
+          id="modeSelect"
+          mode="{{diffViewMode}}"
+          save-on-change="[[!diffPrefsDisabled]]"
+        ></gr-diff-mode-selector>
+        <span
+          id="diffPrefsContainer"
+          class="hideOnEdit"
+          hidden$="[[_computePrefsButtonHidden(diffPrefs, diffPrefsDisabled)]]"
+          hidden=""
+        >
+          <gr-button
+            link=""
+            has-tooltip=""
+            title="Diff preferences"
+            class="prefsButton desktop"
+            on-click="_handlePrefsTap"
+            ><iron-icon icon="gr-icons:settings"></iron-icon
+          ></gr-button>
+        </span>
+      </div>
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
deleted file mode 100644
index 19362d5..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
+++ /dev/null
@@ -1,323 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-file-list-header</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script src="/node_modules/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-file-list-header></gr-file-list-header>
-  </template>
-</test-fixture>
-
-<test-fixture id="blank">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-file-list-header.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GrFileListConstants} from '../gr-file-list-constants.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-file-list-header tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({test: 'config'}); },
-      getAccount() { return Promise.resolve(null); },
-      _fetchSharedCacheURL() { return Promise.resolve({}); },
-    });
-    element = fixture('basic');
-  });
-
-  teardown(done => {
-    flush(() => {
-      sandbox.restore();
-      done();
-    });
-  });
-
-  test('Diff preferences hidden when no prefs or diffPrefsDisabled', () => {
-    element.diffPrefsDisabled = true;
-    flushAsynchronousOperations();
-    assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-    element.diffPrefsDisabled = false;
-    flushAsynchronousOperations();
-    assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-    element.diffPrefsDisabled = true;
-    element.diffPrefs = {font_size: '12'};
-    flushAsynchronousOperations();
-    assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-    element.diffPrefsDisabled = false;
-    flushAsynchronousOperations();
-    assert.isFalse(element.$.diffPrefsContainer.hidden);
-  });
-
-  test('_computeDescriptionReadOnly', () => {
-    assert.equal(element._computeDescriptionReadOnly(false,
-        {owner: {_account_id: 1}}, {_account_id: 1}), true);
-    assert.equal(element._computeDescriptionReadOnly(true,
-        {owner: {_account_id: 0}}, {_account_id: 1}), true);
-    assert.equal(element._computeDescriptionReadOnly(true,
-        {owner: {_account_id: 1}}, {_account_id: 1}), false);
-  });
-
-  test('_computeDescriptionPlaceholder', () => {
-    assert.equal(element._computeDescriptionPlaceholder(true),
-        'No patchset description');
-    assert.equal(element._computeDescriptionPlaceholder(false),
-        'Add patchset description');
-  });
-
-  test('description editing', () => {
-    const putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
-        .returns(Promise.resolve({ok: true}));
-
-    element.changeNum = '42';
-    element.basePatchNum = 'PARENT';
-    element.patchNum = 1;
-
-    element.change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1, description: 'test', commit: {commit: 'rev1'}},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      actions: {},
-      owner: {_account_id: 1},
-    };
-    element.account = {_account_id: 1};
-    element.loggedIn = true;
-
-    flushAsynchronousOperations();
-
-    // The element has a description, so the account chip should be visible
-    // and the description label should not exist.
-    const chip = dom(element.root).querySelector('#descriptionChip');
-    let label = dom(element.root).querySelector('#descriptionLabel');
-
-    assert.equal(chip.text, 'test');
-    assert.isNotOk(label);
-
-    // Simulate tapping the remove button, but call function directly so that
-    // can determine what happens after the promise is resolved.
-    return element._handleDescriptionRemoved()
-        .then(() => {
-          // The API stub should be called with an empty string for the new
-          // description.
-          assert.equal(putDescStub.lastCall.args[2], '');
-          assert.equal(element.change.revisions.rev1.description, '');
-
-          flushAsynchronousOperations();
-          // The editable label should now be visible and the chip hidden.
-          label = dom(element.root).querySelector('#descriptionLabel');
-          assert.isOk(label);
-          assert.equal(getComputedStyle(chip).display, 'none');
-          assert.notEqual(getComputedStyle(label).display, 'none');
-          assert.isFalse(label.readOnly);
-          // Edit the label to have a new value of test2, and save.
-          label.editing = true;
-          label._inputText = 'test2';
-          label._save();
-          flushAsynchronousOperations();
-          // The API stub should be called with an `test2` for the new
-          // description.
-          assert.equal(putDescStub.callCount, 2);
-          assert.equal(putDescStub.lastCall.args[2], 'test2');
-        })
-        .then(() => {
-          flushAsynchronousOperations();
-          // The chip should be visible again, and the label hidden.
-          assert.equal(element.change.revisions.rev1.description, 'test2');
-          assert.equal(getComputedStyle(label).display, 'none');
-          assert.notEqual(getComputedStyle(chip).display, 'none');
-        });
-  });
-
-  test('expandAllDiffs called when expand button clicked', () => {
-    element.shownFileCount = 1;
-    flushAsynchronousOperations();
-    sandbox.stub(element, '_expandAllDiffs');
-    MockInteractions.tap(dom(element.root).querySelector(
-        '#expandBtn'));
-    assert.isTrue(element._expandAllDiffs.called);
-  });
-
-  test('collapseAllDiffs called when expand button clicked', () => {
-    element.shownFileCount = 1;
-    flushAsynchronousOperations();
-    sandbox.stub(element, '_collapseAllDiffs');
-    MockInteractions.tap(dom(element.root).querySelector(
-        '#collapseBtn'));
-    assert.isTrue(element._collapseAllDiffs.called);
-  });
-
-  test('show/hide diffs disabled for large amounts of files', done => {
-    const computeSpy = sandbox.spy(element, '_fileListActionsVisible');
-    element._files = [];
-    element.changeNum = '42';
-    element.basePatchNum = 'PARENT';
-    element.patchNum = '2';
-    element.shownFileCount = 1;
-    flush(() => {
-      assert.isTrue(computeSpy.lastCall.returnValue);
-      _.times(element._maxFilesForBulkActions + 1, () => {
-        element.shownFileCount = element.shownFileCount + 1;
-      });
-      assert.isFalse(computeSpy.lastCall.returnValue);
-      done();
-    });
-  });
-
-  test('fileViewActions are properly hidden', () => {
-    const actions = element.shadowRoot
-        .querySelector('.fileViewActions');
-    assert.equal(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
-    flushAsynchronousOperations();
-    assert.notEqual(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
-    flushAsynchronousOperations();
-    assert.notEqual(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
-    flushAsynchronousOperations();
-    assert.equal(getComputedStyle(actions).display, 'none');
-  });
-
-  test('expand/collapse buttons are toggled correctly', () => {
-    element.shownFileCount = 10;
-    flushAsynchronousOperations();
-    const expandBtn = element.shadowRoot.querySelector('#expandBtn');
-    const collapseBtn = element.shadowRoot.querySelector('#collapseBtn');
-    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-    assert.equal(getComputedStyle(collapseBtn).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
-    flushAsynchronousOperations();
-    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-    assert.equal(getComputedStyle(collapseBtn).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
-    flushAsynchronousOperations();
-    assert.equal(getComputedStyle(expandBtn).display, 'none');
-    assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
-    element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
-    flushAsynchronousOperations();
-    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-    assert.equal(getComputedStyle(collapseBtn).display, 'none');
-  });
-
-  test('navigateToChange called when range select changes', () => {
-    const navigateToChangeStub = sandbox.stub(GerritNav, 'navigateToChange');
-    element.change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev2: {_number: 2},
-        rev1: {_number: 1},
-        rev13: {_number: 13},
-        rev3: {_number: 3},
-      },
-      status: 'NEW',
-      labels: {},
-    };
-    element.basePatchNum = 1;
-    element.patchNum = 2;
-
-    element._handlePatchChange({detail: {basePatchNum: 1, patchNum: 3}});
-    assert.equal(navigateToChangeStub.callCount, 1);
-    assert.isTrue(navigateToChangeStub.lastCall
-        .calledWithExactly(element.change, 3, 1));
-  });
-
-  test('class is applied to file list on old patch set', () => {
-    const allPatchSets = [{num: 4}, {num: 2}, {num: 1}];
-    assert.equal(element._computePatchInfoClass('1', allPatchSets),
-        'patchInfoOldPatchSet');
-    assert.equal(element._computePatchInfoClass('2', allPatchSets),
-        'patchInfoOldPatchSet');
-    assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
-  });
-
-  suite('editMode behavior', () => {
-    setup(() => {
-      element.diffPrefsDisabled = false;
-      element.diffPrefs = {};
-    });
-
-    const isVisible = el => {
-      assert.ok(el);
-      return getComputedStyle(el).getPropertyValue('display') !== 'none';
-    };
-
-    test('patch specific elements', () => {
-      element.editMode = true;
-      sandbox.stub(element, 'computeLatestPatchNum').returns('2');
-      flushAsynchronousOperations();
-
-      assert.isFalse(isVisible(element.$.diffPrefsContainer));
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.descriptionContainer')));
-
-      element.editMode = false;
-      flushAsynchronousOperations();
-
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.descriptionContainer')));
-      assert.isTrue(isVisible(element.$.diffPrefsContainer));
-    });
-
-    test('edit-controls visibility', () => {
-      element.editMode = true;
-      flushAsynchronousOperations();
-      assert.isTrue(isVisible(element.$.editControls.parentElement));
-
-      element.editMode = false;
-      flushAsynchronousOperations();
-      assert.isFalse(isVisible(element.$.editControls.parentElement));
-    });
-
-    test('_computeUploadHelpContainerClass', () => {
-      // Only show the upload helper button when an unmerged change is viewed
-      // by its owner.
-      const accountA = {_account_id: 1};
-      const accountB = {_account_id: 2};
-      assert.notInclude(element._computeUploadHelpContainerClass(
-          {owner: accountA}, accountA), 'hide');
-      assert.include(element._computeUploadHelpContainerClass(
-          {owner: accountA}, accountB), 'hide');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
new file mode 100644
index 0000000..d0155d6
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
@@ -0,0 +1,308 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-file-list-header.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrFileListConstants} from '../gr-file-list-constants.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {generateChange} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-file-list-header');
+
+suite('gr-file-list-header tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({test: 'config'}); },
+      getAccount() { return Promise.resolve(null); },
+      _fetchSharedCacheURL() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  teardown(done => {
+    flush(() => {
+      done();
+    });
+  });
+
+  test('Diff preferences hidden when no prefs or diffPrefsDisabled', () => {
+    element.diffPrefsDisabled = true;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+    element.diffPrefsDisabled = false;
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+    element.diffPrefsDisabled = true;
+    element.diffPrefs = {font_size: '12'};
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+    element.diffPrefsDisabled = false;
+    flushAsynchronousOperations();
+    assert.isFalse(element.$.diffPrefsContainer.hidden);
+  });
+
+  test('_computeDescriptionReadOnly', () => {
+    assert.equal(element._computeDescriptionReadOnly(false,
+        {owner: {_account_id: 1}}, {_account_id: 1}), true);
+    assert.equal(element._computeDescriptionReadOnly(true,
+        {owner: {_account_id: 0}}, {_account_id: 1}), true);
+    assert.equal(element._computeDescriptionReadOnly(true,
+        {owner: {_account_id: 1}}, {_account_id: 1}), false);
+  });
+
+  test('_computeDescriptionPlaceholder', () => {
+    assert.equal(element._computeDescriptionPlaceholder(true),
+        'No patchset description');
+    assert.equal(element._computeDescriptionPlaceholder(false),
+        'Add patchset description');
+  });
+
+  test('description editing', () => {
+    const putDescStub = sinon.stub(element.$.restAPI, 'setDescription')
+        .returns(Promise.resolve({ok: true}));
+
+    element.changeNum = '42';
+    element.basePatchNum = 'PARENT';
+    element.patchNum = 1;
+
+    element.change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1, description: 'test', commit: {commit: 'rev1'}},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      actions: {},
+      owner: {_account_id: 1},
+    };
+    element.account = {_account_id: 1};
+    element.loggedIn = true;
+
+    flushAsynchronousOperations();
+
+    // The element has a description, so the account chip should be visible
+    // and the description label should not exist.
+    const chip = dom(element.root).querySelector('#descriptionChip');
+    let label = dom(element.root).querySelector('#descriptionLabel');
+
+    assert.equal(chip.text, 'test');
+    assert.isNotOk(label);
+
+    // Simulate tapping the remove button, but call function directly so that
+    // can determine what happens after the promise is resolved.
+    return element._handleDescriptionRemoved()
+        .then(() => {
+          // The API stub should be called with an empty string for the new
+          // description.
+          assert.equal(putDescStub.lastCall.args[2], '');
+          assert.equal(element.change.revisions.rev1.description, '');
+
+          flushAsynchronousOperations();
+          // The editable label should now be visible and the chip hidden.
+          label = dom(element.root).querySelector('#descriptionLabel');
+          assert.isOk(label);
+          assert.equal(getComputedStyle(chip).display, 'none');
+          assert.notEqual(getComputedStyle(label).display, 'none');
+          assert.isFalse(label.readOnly);
+          // Edit the label to have a new value of test2, and save.
+          label.editing = true;
+          label._inputText = 'test2';
+          label._save();
+          flushAsynchronousOperations();
+          // The API stub should be called with an `test2` for the new
+          // description.
+          assert.equal(putDescStub.callCount, 2);
+          assert.equal(putDescStub.lastCall.args[2], 'test2');
+        })
+        .then(() => {
+          flushAsynchronousOperations();
+          // The chip should be visible again, and the label hidden.
+          assert.equal(element.change.revisions.rev1.description, 'test2');
+          assert.equal(getComputedStyle(label).display, 'none');
+          assert.notEqual(getComputedStyle(chip).display, 'none');
+        });
+  });
+
+  test('expandAllDiffs called when expand button clicked', () => {
+    element.shownFileCount = 1;
+    flushAsynchronousOperations();
+    sinon.stub(element, '_expandAllDiffs');
+    MockInteractions.tap(dom(element.root).querySelector(
+        '#expandBtn'));
+    assert.isTrue(element._expandAllDiffs.called);
+  });
+
+  test('collapseAllDiffs called when expand button clicked', () => {
+    element.shownFileCount = 1;
+    flushAsynchronousOperations();
+    sinon.stub(element, '_collapseAllDiffs');
+    MockInteractions.tap(dom(element.root).querySelector(
+        '#collapseBtn'));
+    assert.isTrue(element._collapseAllDiffs.called);
+  });
+
+  test('show/hide diffs disabled for large amounts of files', done => {
+    const computeSpy = sinon.spy(element, '_fileListActionsVisible');
+    element._files = [];
+    element.changeNum = '42';
+    element.basePatchNum = 'PARENT';
+    element.patchNum = '2';
+    element.shownFileCount = 1;
+    flush(() => {
+      assert.isTrue(computeSpy.lastCall.returnValue);
+      _.times(element._maxFilesForBulkActions + 1, () => {
+        element.shownFileCount = element.shownFileCount + 1;
+      });
+      assert.isFalse(computeSpy.lastCall.returnValue);
+      done();
+    });
+  });
+
+  test('fileViewActions are properly hidden', () => {
+    const actions = element.shadowRoot
+        .querySelector('.fileViewActions');
+    assert.equal(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
+    flushAsynchronousOperations();
+    assert.equal(getComputedStyle(actions).display, 'none');
+  });
+
+  test('expand/collapse buttons are toggled correctly', () => {
+    element.shownFileCount = 10;
+    flushAsynchronousOperations();
+    const expandBtn = element.shadowRoot.querySelector('#expandBtn');
+    const collapseBtn = element.shadowRoot.querySelector('#collapseBtn');
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.equal(getComputedStyle(collapseBtn).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.equal(getComputedStyle(collapseBtn).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
+    flushAsynchronousOperations();
+    assert.equal(getComputedStyle(expandBtn).display, 'none');
+    assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
+    element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
+    flushAsynchronousOperations();
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.equal(getComputedStyle(collapseBtn).display, 'none');
+  });
+
+  test('navigateToChange called when range select changes', () => {
+    const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+    element.change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev2: {_number: 2},
+        rev1: {_number: 1},
+        rev13: {_number: 13},
+        rev3: {_number: 3},
+      },
+      status: 'NEW',
+      labels: {},
+    };
+    element.basePatchNum = 1;
+    element.patchNum = 2;
+
+    element._handlePatchChange({detail: {basePatchNum: 1, patchNum: 3}});
+    assert.equal(navigateToChangeStub.callCount, 1);
+    assert.isTrue(navigateToChangeStub.lastCall
+        .calledWithExactly(element.change, 3, 1));
+  });
+
+  test('class is applied to file list on old patch set', () => {
+    const allPatchSets = [{num: 4}, {num: 2}, {num: 1}];
+    assert.equal(element._computePatchInfoClass('1', allPatchSets),
+        'patchInfoOldPatchSet');
+    assert.equal(element._computePatchInfoClass('2', allPatchSets),
+        'patchInfoOldPatchSet');
+    assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
+  });
+
+  suite('editMode behavior', () => {
+    setup(() => {
+      element.diffPrefsDisabled = false;
+      element.diffPrefs = {};
+    });
+
+    const isVisible = el => {
+      assert.ok(el);
+      return getComputedStyle(el).getPropertyValue('display') !== 'none';
+    };
+
+    test('patch specific elements', () => {
+      element.editMode = true;
+      element.allPatchSets = generateChange({revisionsCount: 2}).revisions;
+      flushAsynchronousOperations();
+
+      assert.isFalse(isVisible(element.$.diffPrefsContainer));
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.descriptionContainer')));
+
+      element.editMode = false;
+      flushAsynchronousOperations();
+
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.descriptionContainer')));
+      assert.isTrue(isVisible(element.$.diffPrefsContainer));
+    });
+
+    test('edit-controls visibility', () => {
+      element.editMode = false;
+      flushAsynchronousOperations();
+      // on the first render, when editMode is false, editControls are not
+      // in the DOM to reduce size of DOM and make first render faster.
+      assert.isNull(element.shadowRoot
+          .querySelector('#editControls'));
+
+      element.editMode = true;
+      flushAsynchronousOperations();
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('#editControls').parentElement));
+
+      element.editMode = false;
+      flushAsynchronousOperations();
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('#editControls').parentElement));
+    });
+
+    test('_computeUploadHelpContainerClass', () => {
+      // Only show the upload helper button when an unmerged change is viewed
+      // by its owner.
+      const accountA = {_account_id: 1};
+      const accountB = {_account_id: 2};
+      assert.notInclude(element._computeUploadHelpContainerClass(
+          {owner: accountA}, accountA), 'hide');
+      assert.include(element._computeUploadHelpContainerClass(
+          {owner: accountA}, accountB), 'hide');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index eb85cd7..2ad0b0c 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -14,10 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
-import '../../core/gr-reporting/gr-reporting.js';
 import '../../diff/gr-diff-cursor/gr-diff-cursor.js';
 import '../../diff/gr-diff-host/gr-diff-host.js';
 import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
@@ -31,21 +28,28 @@
 import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
 import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-file-list_html.js';
-import {AsyncForeachBehavior} from '../../../behaviors/async-foreach-behavior/async-foreach-behavior.js';
-import {DomUtilBehavior} from '../../../behaviors/dom-util-behavior/dom-util-behavior.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
-import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {asyncForeach} from '../../../utils/async-util.js';
+import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {GrFileListConstants} from '../gr-file-list-constants.js';
 import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {appContext} from '../../../services/app-context.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
+import {descendedFromClass} from '../../../utils/dom-util.js';
+import {getRevisionByPatchNum} from '../../../utils/patch-set-util.js';
+import {
+  addUnmodifiedFiles,
+  computeDisplayPath,
+  computeTruncatedPath,
+  isMagicPath,
+  specialFilePathCompare,
+} from '../../../utils/path-list-util.js';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -71,6 +75,8 @@
   U: 'Unchanged',
 };
 
+const FILE_ROW_CLASS = 'file-row';
+
 /**
  * Type for FileInfo
  *
@@ -87,27 +93,11 @@
  */
 
 /**
- * Type for FileData
- *
- * This contains minimal info required about the file to get comments for
- *
- * @typedef {Object} FileData
- * @property {string} path
- * @property {?string} oldPath
+ * @extends PolymerElement
  */
-
-/**
- * @extends Polymer.Element
- */
-class GrFileList extends mixinBehaviors( [
-  AsyncForeachBehavior,
-  DomUtilBehavior,
-  KeyboardShortcutBehavior,
-  PatchSetBehavior,
-  PathListBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrFileList extends KeyboardShortcutMixin(
+    GestureEventListeners(
+        LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-file-list'; }
@@ -206,7 +196,7 @@
        */
       _reportinShownFilesIncrement: Number,
 
-      /** @type {!Array<FileData>} */
+      /** @type {!Array<Gerrit.FileRange>} */
       _expandedFiles: {
         type: Array,
         value() { return []; },
@@ -236,6 +226,11 @@
         computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
                 '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
       },
+      _showPrependedDynamicColumns: {
+        type: Boolean,
+        computed: '_computeShowPrependedDynamicColumns(' +
+        '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
+      },
       /** @type {Array<string>} */
       _dynamicHeaderEndpoints: {
         type: Array,
@@ -248,6 +243,14 @@
       _dynamicSummaryEndpoints: {
         type: Array,
       },
+      /** @type {Array<string>} */
+      _dynamicPrependedHeaderEndpoints: {
+        type: Array,
+      },
+      /** @type {Array<string>} */
+      _dynamicPrependedContentEndpoints: {
+        type: Array,
+      },
     };
   }
 
@@ -267,29 +270,36 @@
 
   keyboardShortcuts() {
     return {
-      [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
-      [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
-      [this.Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
-      [this.Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
-      [this.Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
-      [this.Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
-      [this.Shortcut.NEXT_LINE]: '_handleCursorNext',
-      [this.Shortcut.PREV_LINE]: '_handleCursorPrev',
-      [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
-      [this.Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
-      [this.Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
-      [this.Shortcut.OPEN_FILE]: '_handleOpenFile',
-      [this.Shortcut.NEXT_CHUNK]: '_handleNextChunk',
-      [this.Shortcut.PREV_CHUNK]: '_handlePrevChunk',
-      [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
-      [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+      [Shortcut.LEFT_PANE]: '_handleLeftPane',
+      [Shortcut.RIGHT_PANE]: '_handleRightPane',
+      [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
+      [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
+      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+        '_handleToggleHideAllCommentThreads',
+      [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
+      [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
+      [Shortcut.NEXT_LINE]: '_handleCursorNext',
+      [Shortcut.PREV_LINE]: '_handleCursorPrev',
+      [Shortcut.NEW_COMMENT]: '_handleNewComment',
+      [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
+      [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
+      [Shortcut.OPEN_FILE]: '_handleOpenFile',
+      [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
+      [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
+      [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
 
       // Final two are actually handled by gr-comment-thread.
-      [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-      [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
     };
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   /** @override */
   created() {
     super.created();
@@ -301,18 +311,27 @@
   attached() {
     super.attached();
     pluginLoader.awaitPluginsLoaded().then(() => {
-      this._dynamicHeaderEndpoints = pluginEndpoints.getDynamicEndpoints(
-          'change-view-file-list-header');
-      this._dynamicContentEndpoints = pluginEndpoints.getDynamicEndpoints(
-          'change-view-file-list-content');
-      this._dynamicSummaryEndpoints = pluginEndpoints.getDynamicEndpoints(
-          'change-view-file-list-summary');
+      this._dynamicHeaderEndpoints = getPluginEndpoints()
+          .getDynamicEndpoints('change-view-file-list-header');
+      this._dynamicContentEndpoints = getPluginEndpoints()
+          .getDynamicEndpoints('change-view-file-list-content');
+      this._dynamicPrependedHeaderEndpoints = getPluginEndpoints()
+          .getDynamicEndpoints('change-view-file-list-header-prepend');
+      this._dynamicPrependedContentEndpoints = getPluginEndpoints()
+          .getDynamicEndpoints('change-view-file-list-content-prepend');
+      this._dynamicSummaryEndpoints = getPluginEndpoints()
+          .getDynamicEndpoints('change-view-file-list-summary');
 
       if (this._dynamicHeaderEndpoints.length !==
           this._dynamicContentEndpoints.length) {
         console.warn(
             'Different number of dynamic file-list header and content.');
       }
+      if (this._dynamicPrependedHeaderEndpoints.length !==
+        this._dynamicPrependedContentEndpoints.length) {
+        console.warn(
+            'Different number of dynamic file-list header and content.');
+      }
       if (this._dynamicHeaderEndpoints.length !==
           this._dynamicSummaryEndpoints.length) {
         console.warn(
@@ -375,14 +394,14 @@
     return Promise.all(promises).then(() => {
       this._loading = false;
       this._detectChromiteButler();
-      this.$.reporting.fileListDisplayed();
+      this.reporting.fileListDisplayed();
     });
   }
 
   _detectChromiteButler() {
     const hasButler = !!document.getElementById('butler-suggested-owners');
     if (hasButler) {
-      this.$.reporting.reportExtension('butler');
+      this.reporting.reportExtension('butler');
     }
   }
 
@@ -401,7 +420,7 @@
 
   _calculatePatchChange(files) {
     const magicFilesExcluded = files.filter(files =>
-      !this.isMagicPath(files.__path)
+      !isMagicPath(files.__path)
     );
 
     return magicFilesExcluded.reduce((acc, obj) => {
@@ -444,13 +463,13 @@
   }
 
   _toggleFileExpandedByIndex(index) {
-    this._toggleFileExpanded(this._computeFileData(this._files[index]));
+    this._toggleFileExpanded(this._computeFileRange(this._files[index]));
   }
 
   _updateDiffPreferences() {
     if (!this.diffs.length) { return; }
     // Re-render all expanded diffs sequentially.
-    this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);
+    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
     this._renderInOrder(this._expandedFiles, this.diffs,
         this._expandedFiles.length);
   }
@@ -472,7 +491,7 @@
     for (let i = 0; i < this._shownFiles.length; i++) {
       path = this._shownFiles[i].__path;
       if (!this._expandedFiles.some(f => f.path === path)) {
-        newFiles.push(this._computeFileData(this._shownFiles[i]));
+        newFiles.push(this._computeFileRange(this._shownFiles[i]));
       }
     }
 
@@ -496,6 +515,9 @@
    * @return {string}
    */
   _computeCommentsString(changeComments, patchRange, path) {
+    if ([changeComments, patchRange, path].includes(undefined)) {
+      return '';
+    }
     const unresolvedCount =
         changeComments.computeUnresolvedNum({
           patchNum: patchRange.basePatchNum,
@@ -535,6 +557,9 @@
    * @return {string}
    */
   _computeDraftsString(changeComments, patchRange, path) {
+    if ([changeComments, patchRange, path].includes(undefined)) {
+      return '';
+    }
     const draftCount =
         changeComments.computeDraftCount({
           patchNum: patchRange.basePatchNum,
@@ -556,6 +581,9 @@
    * @return {string}
    */
   _computeDraftsStringMobile(changeComments, patchRange, path) {
+    if ([changeComments, patchRange, path].includes(undefined)) {
+      return '';
+    }
     const draftCount =
         changeComments.computeDraftCount({
           patchNum: patchRange.basePatchNum,
@@ -577,6 +605,9 @@
    * @return {string}
    */
   _computeCommentsStringMobile(changeComments, patchRange, path) {
+    if ([changeComments, patchRange, path].includes(undefined)) {
+      return '';
+    }
     const commentCount =
         changeComments.computeCommentCount({
           patchNum: patchRange.basePatchNum,
@@ -627,14 +658,12 @@
   }
 
   /**
-   * The closure compiler doesn't realize this.specialFilePathCompare is
-   * valid.
    *
    * @returns {!Array<FileInfo>}
    */
   _normalizeChangeFilesResponse(response) {
     if (!response) { return []; }
-    const paths = Object.keys(response).sort(this.specialFilePathCompare);
+    const paths = Object.keys(response).sort(specialFilePathCompare);
     const files = [];
     for (let i = 0; i < paths.length; i++) {
       const info = response[paths[i]];
@@ -648,53 +677,98 @@
   }
 
   /**
+   * Returns true if the event e is a click on an element.
+   *
+   * The click is: mouse click or pressing Enter or Space key
+   * P.S> Screen readers sends click event as well
+   */
+  _isClickEvent(e) {
+    if (e.type === 'click') {
+      return true;
+    }
+    const isSpaceOrEnter = (e.key === 'Enter' || e.key === ' ');
+    return e.type === 'keydown' && isSpaceOrEnter;
+  }
+
+  _fileActionClick(e, fileAction) {
+    if (this._isClickEvent(e)) {
+      const fileRow = this._getFileRowFromEvent(e);
+      if (!fileRow) {
+        return;
+      }
+      // Prevent default actions (e.g. scrolling for space key)
+      e.preventDefault();
+      // Prevent _handleFileListClick handler call
+      e.stopPropagation();
+      this.$.fileCursor.setCursor(fileRow.element);
+      fileAction(fileRow.file);
+    }
+  }
+
+  _reviewedClick(e) {
+    this._fileActionClick(e,
+        file => this._reviewFile(file.path));
+  }
+
+  _expandedClick(e) {
+    this._fileActionClick(e,
+        file => this._toggleFileExpanded(file));
+  }
+
+  /**
    * Handle all events from the file list dom-repeat so event handleers don't
    * have to get registered for potentially very long lists.
    */
   _handleFileListClick(e) {
+    const fileRow = this._getFileRowFromEvent(e);
+    if (!fileRow) {
+      return;
+    }
+    const file = fileRow.file;
+    const path = file.path;
+    // If a path cannot be interpreted from the click target (meaning it's not
+    // somewhere in the row, e.g. diff content) or if the user clicked the
+    // link, defer to the native behavior.
+    if (!path || descendedFromClass(e.target, 'pathLink')) { return; }
+
+    // Disregard the event if the click target is in the edit controls.
+    if (descendedFromClass(e.target, 'editFileControls')) { return; }
+
+    e.preventDefault();
+    this.$.fileCursor.setCursor(fileRow.element);
+    this._toggleFileExpanded(file);
+  }
+
+  _getFileRowFromEvent(e) {
     // Traverse upwards to find the row element if the target is not the row.
     let row = e.target;
-    while (!row.classList.contains('row') && row.parentElement) {
+    while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
       row = row.parentElement;
     }
 
     // No action needed for item without a valid file
     if (!row.dataset.file) {
-      return;
+      return null;
     }
 
-    const file = JSON.parse(row.dataset.file);
-    const path = file.path;
-    // Handle checkbox mark as reviewed.
-    if (e.target.classList.contains('markReviewed')) {
-      e.preventDefault();
-      return this._reviewFile(path);
-    }
-
-    // If a path cannot be interpreted from the click target (meaning it's not
-    // somewhere in the row, e.g. diff content) or if the user clicked the
-    // link, defer to the native behavior.
-    if (!path || this.descendedFromClass(e.target, 'pathLink')) { return; }
-
-    // Disregard the event if the click target is in the edit controls.
-    if (this.descendedFromClass(e.target, 'editFileControls')) { return; }
-
-    e.preventDefault();
-    this._toggleFileExpanded(file);
+    return {
+      file: JSON.parse(row.dataset.file),
+      element: row,
+    };
   }
 
   /**
-   * Generates file data from file info object.
+   * Generates file range from file info object.
    *
    * @param {FileInfo} file
-   * @returns {FileData}
+   * @returns {Gerrit.FileRange}
    */
-  _computeFileData(file) {
+  _computeFileRange(file) {
     const fileData = {
       path: file.__path,
     };
     if (file.old_path) {
-      fileData.oldPath = file.old_path;
+      fileData.basePath = file.old_path;
     }
     return fileData;
   }
@@ -733,6 +807,15 @@
     this._toggleInlineDiffs();
   }
 
+  _handleToggleHideAllCommentThreads(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.toggleClass('hideComments');
+  }
+
   _handleCursorNext(e) {
     if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
       return;
@@ -910,7 +993,7 @@
         .some(arg => arg === undefined)) {
       return;
     }
-    if (editMode && path !== this.MERGE_LIST_PATH) {
+    if (editMode && path !== SpecialFilePath.MERGE_LIST) {
       return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum,
           patchRange.basePatchNum);
     }
@@ -953,12 +1036,18 @@
     if (baseClass) {
       classes.push(baseClass);
     }
-    if (path === this.COMMIT_MESSAGE_PATH || path === this.MERGE_LIST_PATH) {
+    if (path === SpecialFilePath.COMMIT_MESSAGE ||
+      path === SpecialFilePath.MERGE_LIST) {
       classes.push('invisible');
     }
     return classes.join(' ');
   }
 
+  _computeStatusClass(file) {
+    const classStr = this._computeClass('status', file.__path);
+    return `${classStr} ${this._computeFileStatus(file.status)}`;
+  }
+
   _computePathClass(path, expandedFilesRecord) {
     return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
   }
@@ -976,7 +1065,7 @@
       patchRange,
       reviewed,
       loading,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return;
     }
 
@@ -984,11 +1073,8 @@
     if (loading || !changeComments) { return; }
 
     const commentedPaths = changeComments.getPaths(patchRange);
-    const files = Object.assign({}, filesByPath);
-    Object.keys(commentedPaths).forEach(commentedPath => {
-      if (files.hasOwnProperty(commentedPath)) { return; }
-      files[commentedPath] = {status: 'U'};
-    });
+    const files = {...filesByPath};
+    addUnmodifiedFiles(files, commentedPaths);
     const reviewedSet = new Set(reviewed || []);
     for (const filePath in files) {
       if (!files.hasOwnProperty(filePath)) { continue; }
@@ -1000,7 +1086,7 @@
 
   _computeFilesShown(numFilesShown, files) {
     // Polymer 2: check for undefined
-    if ([numFilesShown, files].some(arg => arg === undefined)) {
+    if ([numFilesShown, files].includes(undefined)) {
       return undefined;
     }
 
@@ -1016,7 +1102,7 @@
     // Start the timer for the rendering work hwere because this is where the
     // _shownFiles property is being set, and _shownFiles is used in the
     // dom-repeat binding.
-    this.$.reporting.time(RENDER_TIMING_LABEL);
+    this.reporting.time(RENDER_TIMING_LABEL);
 
     // How many more files are being shown (if it's an increase).
     this._reportinShownFilesIncrement =
@@ -1034,9 +1120,8 @@
   _filesChanged() {
     if (this._files && this._files.length > 0) {
       flush();
-      const files = Array.from(
-          dom(this.root).querySelectorAll('.file-row'));
-      this.$.fileCursor.stops = files;
+      this.$.fileCursor.stops = Array.from(
+          dom(this.root).querySelectorAll(`.${FILE_ROW_CLASS}`));
       this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
     }
   }
@@ -1077,11 +1162,11 @@
 
   _computePatchSetDescription(revisions, patchNum) {
     // Polymer 2: check for undefined
-    if ([revisions, patchNum].some(arg => arg === undefined)) {
+    if ([revisions, patchNum].includes(undefined)) {
       return '';
     }
 
-    const rev = this.getRevisionByPatchNum(revisions, patchNum);
+    const rev = getRevisionByPatchNum(revisions, patchNum);
     return (rev && rev.description) ?
       rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
   }
@@ -1099,10 +1184,29 @@
       FileStatus[statusCode] : 'Status Unknown';
   }
 
+  /**
+   * Converts any boolean-like variable to the string 'true' or 'false'
+   *
+   * This method is useful when you bind aria-checked attribute to a boolean
+   * value. The aria-checked attribute is string attribute. Binding directly
+   * to boolean variable causes problem on gerrit-CI.
+   *
+   * @param {object} val
+   * @return {string} 'true' if val is true-like, otherwise false
+   */
+  _booleanToString(val) {
+    return val ? 'true' : 'false';
+  }
+
   _isFileExpanded(path, expandedFilesRecord) {
     return expandedFilesRecord.base.some(f => f.path === path);
   }
 
+  _isFileExpandedStr(path, expandedFilesRecord) {
+    return this._booleanToString(
+        this._isFileExpanded(path, expandedFilesRecord));
+  }
+
   _computeExpandedFiles(expandedCount, totalCount) {
     if (expandedCount === 0) {
       return GrFileListConstants.FilesExpandedState.NONE;
@@ -1141,14 +1245,14 @@
     // Required so that the newly created diff view is included in this.diffs.
     flush();
 
-    this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);
+    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
 
     if (newFiles.length) {
       this._renderInOrder(newFiles, this.diffs, newFiles.length);
     }
 
     this._updateDiffCursor();
-    this.$.diffCursor.handleDiffUpdate();
+    this.$.diffCursor.reInitAndUpdateStops();
   }
 
   _clearCollapsedDiffs(collapsedDiffs) {
@@ -1161,9 +1265,9 @@
   /**
    * Given an array of paths and a NodeList of diff elements, render the diff
    * for each path in order, awaiting the previous render to complete before
-   * continung.
+   * continuing.
    *
-   * @param  {!Array<FileData>} files
+   * @param  {!Array<Gerrit.FileRange>} files
    * @param  {!NodeList<!Object>} diffElements (GrDiffHostElement)
    * @param  {number} initialCount The total number of paths in the pass. This
    *   is used to generate log messages.
@@ -1172,17 +1276,25 @@
   _renderInOrder(files, diffElements, initialCount) {
     let iter = 0;
 
+    for (const file of files) {
+      const path = file.path;
+      const diffElem = this._findDiffByPath(path, diffElements);
+      if (diffElem) {
+        diffElem.prefetchDiff();
+      }
+    }
+
     return (new Promise(resolve => {
       this.dispatchEvent(new CustomEvent('reload-drafts', {
         detail: {resolve},
         composed: true, bubbles: true,
       }));
-    })).then(() => this.asyncForeach(files, (file, cancel) => {
+    })).then(() => asyncForeach(files, (file, cancel) => {
       const path = file.path;
       this._cancelForEachDiff = cancel;
 
       iter++;
-      console.log('Expanding diff', iter, 'of', initialCount, ':',
+      console.info('Expanding diff', iter, 'of', initialCount, ':',
           path);
       const diffElem = this._findDiffByPath(path, diffElements);
       if (!diffElem) {
@@ -1199,10 +1311,22 @@
     }).then(() => {
       this._cancelForEachDiff = null;
       this._nextRenderParams = null;
-      console.log('Finished expanding', initialCount, 'diff(s)');
-      this.$.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
+      console.info('Finished expanding', initialCount, 'diff(s)');
+      this.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
           EXPAND_ALL_AVG_TIMING_LABEL, initialCount);
-      this.$.diffCursor.handleDiffUpdate();
+      /* Block diff cursor from auto scrolling after files are done rendering.
+       * This prevents the bug where the screen jumps to the first diff chunk
+       * after files are done being rendered after the user has already begun
+       * scrolling.
+       * This also however results in the fact that the cursor does not auto
+       * focus on the first diff chunk on a small screen. This is however, a use
+       * case we are willing to not support for now.
+
+       * Using handleDiffUpdate resulted in diffCursor.row being set which
+       * prevented the issue of scrolling to top when we expand the second
+       * file individually.
+       */
+      this.$.diffCursor.reInitAndUpdateStops();
     }));
   }
 
@@ -1262,7 +1386,6 @@
         c, {__commentSide: threadEl.commentSide}
     ));
     flush();
-    return;
   }
 
   _handleEscKey(e) {
@@ -1307,7 +1430,8 @@
    * @return {boolean}
    */
   _showBarsForPath(path) {
-    return path !== this.COMMIT_MESSAGE_PATH && path !== this.MERGE_LIST_PATH;
+    return path !== SpecialFilePath.COMMIT_MESSAGE &&
+      path !== SpecialFilePath.MERGE_LIST;
   }
 
   /**
@@ -1421,18 +1545,30 @@
 
   /**
    * Shows registered dynamic columns iff the 'header', 'content' and
-   * 'summary' endpoints are regiestered the exact same number of times.
+   * 'summary' endpoints are registered the exact same number of times.
    * Ideally, there should be a better way to enforce the expectation of the
    * dependencies between dynamic endpoints.
    */
   _computeShowDynamicColumns(
       headerEndpoints, contentEndpoints, summaryEndpoints) {
     return headerEndpoints && contentEndpoints && summaryEndpoints &&
+           headerEndpoints.length &&
            headerEndpoints.length === contentEndpoints.length &&
            headerEndpoints.length === summaryEndpoints.length;
   }
 
   /**
+   * Shows registered dynamic prepended columns iff the 'header', 'content'
+   * endpoints are registered the exact same number of times.
+   */
+  _computeShowPrependedDynamicColumns(
+      headerEndpoints, contentEndpoints) {
+    return headerEndpoints && contentEndpoints &&
+           headerEndpoints.length &&
+           headerEndpoints.length === contentEndpoints.length;
+  }
+
+  /**
    * Returns true if none of the inline diffs have been expanded.
    *
    * @return {boolean}
@@ -1452,7 +1588,7 @@
   _reportRenderedRow(index) {
     if (index === this._shownFiles.length - 1) {
       this.async(() => {
-        this.$.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
+        this.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
             RENDER_AVG_TIMING_LABEL, this._reportinShownFilesIncrement);
       }, 1);
     }
@@ -1472,6 +1608,20 @@
       this.diffPrefs = prefs;
     });
   }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeDisplayPath(path) {
+    return computeDisplayPath(path);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeTruncatedPath(path) {
+    return computeTruncatedPath(path);
+  }
 }
 
 customElements.define(GrFileList.is, GrFileList);
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
deleted file mode 100644
index a9a785e..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.js
+++ /dev/null
@@ -1,591 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    .row {
-      align-items: center;
-      border-top: 1px solid var(--border-color);
-      display: flex;
-      min-height: calc(var(--line-height-normal) + 2 * var(--spacing-s));
-      padding: var(--spacing-xs) var(--spacing-l) var(--spacing-xs)
-        calc(var(--spacing-l) - 0.35rem);
-    }
-    :host(.loading) .row {
-      opacity: 0.5;
-    }
-    :host(.editMode) .hideOnEdit {
-      display: none;
-    }
-    .showOnEdit {
-      display: none;
-    }
-    :host(.editMode) .showOnEdit {
-      display: initial;
-    }
-    .invisible {
-      visibility: hidden;
-    }
-    .header-row {
-      background-color: var(--background-color-secondary);
-    }
-    .controlRow {
-      align-items: center;
-      display: flex;
-      height: 2.25em;
-      justify-content: center;
-    }
-    .controlRow.invisible,
-    .show-hide.invisible {
-      display: none;
-    }
-    .reviewed,
-    .status {
-      align-items: center;
-      display: inline-flex;
-    }
-    .reviewed,
-    .status {
-      display: inline-block;
-      text-align: left;
-      width: 1.5em;
-    }
-    .file-row {
-      cursor: pointer;
-    }
-    .file-row.expanded {
-      border-bottom: 1px solid var(--border-color);
-      position: -webkit-sticky;
-      position: sticky;
-      top: 0;
-      /* Has to visible above the diff view, and by default has a lower
-         z-index. setting to 1 places it directly above. */
-      z-index: 1;
-    }
-    .file-row:hover {
-      background-color: var(--hover-background-color);
-    }
-    .file-row.selected {
-      background-color: var(--selection-background-color);
-    }
-    .file-row.expanded,
-    .file-row.expanded:hover {
-      background-color: var(--expanded-background-color);
-    }
-    .path {
-      cursor: pointer;
-      flex: 1;
-      /* Wrap it into multiple lines if too long. */
-      white-space: normal;
-      word-break: break-word;
-    }
-    .oldPath {
-      color: var(--deemphasized-text-color);
-    }
-    .header-stats {
-      text-align: center;
-      min-width: 7.5em;
-    }
-    .stats {
-      text-align: right;
-      min-width: 7.5em;
-    }
-    .comments {
-      padding-left: var(--spacing-l);
-      min-width: 7.5em;
-    }
-    .row:not(.header-row) .stats,
-    .total-stats {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      display: flex;
-    }
-    .sizeBars {
-      margin-left: var(--spacing-m);
-      min-width: 7em;
-      text-align: center;
-    }
-    .sizeBars.hide {
-      display: none;
-    }
-    .added,
-    .removed {
-      display: inline-block;
-      min-width: 3.5em;
-    }
-    .added {
-      color: var(--vote-text-color-recommended);
-    }
-    .removed {
-      color: var(--vote-text-color-disliked);
-      text-align: left;
-      min-width: 4em;
-      padding-left: var(--spacing-s);
-    }
-    .drafts {
-      color: #c62828;
-      font-weight: var(--font-weight-bold);
-    }
-    .show-hide {
-      margin-left: var(--spacing-s);
-      width: 1.9em;
-    }
-    .fileListButton {
-      margin: var(--spacing-m);
-    }
-    .totalChanges {
-      justify-content: flex-end;
-      text-align: right;
-    }
-    .warning {
-      color: var(--deemphasized-text-color);
-    }
-    input.show-hide {
-      display: none;
-    }
-    label.show-hide {
-      cursor: pointer;
-      display: block;
-      min-width: 2em;
-    }
-    gr-diff {
-      display: block;
-      overflow-x: auto;
-    }
-    .truncatedFileName {
-      display: none;
-    }
-    .mobile {
-      display: none;
-    }
-    .reviewed {
-      margin-left: var(--spacing-xxl);
-      width: 15em;
-    }
-    .reviewed label {
-      color: var(--link-color);
-      opacity: 0;
-      justify-content: flex-end;
-      width: 100%;
-    }
-    .reviewed label:hover {
-      cursor: pointer;
-      opacity: 100;
-    }
-    .row:focus {
-      outline: none;
-    }
-    .row:hover .reviewed label,
-    .row:focus .reviewed label,
-    .row.expanded .reviewed label {
-      opacity: 100;
-    }
-    .reviewed input {
-      display: none;
-    }
-    .reviewedLabel {
-      color: var(--deemphasized-text-color);
-      margin-right: var(--spacing-l);
-      opacity: 0;
-    }
-    .reviewedLabel.isReviewed {
-      display: initial;
-      opacity: 100;
-    }
-    .editFileControls {
-      width: 7em;
-    }
-    .markReviewed,
-    .pathLink {
-      display: inline-block;
-      margin: -2px 0;
-      padding: var(--spacing-s) 0;
-      text-decoration: none;
-    }
-    .pathLink:hover {
-      text-decoration: underline;
-    }
-
-    /** copy on file path **/
-    .pathLink gr-copy-clipboard,
-    .oldPath gr-copy-clipboard {
-      display: inline-block;
-      visibility: hidden;
-      vertical-align: bottom;
-      text-decoration: none;
-      --gr-button: {
-        padding: 0px;
-      }
-    }
-    .pathLink:hover gr-copy-clipboard,
-    .oldPath:hover gr-copy-clipboard {
-      visibility: visible;
-    }
-
-    /** small screen breakpoint: 768px */
-    @media screen and (max-width: 55em) {
-      .desktop {
-        display: none;
-      }
-      .mobile {
-        display: block;
-      }
-      .row.selected {
-        background-color: var(--view-background-color);
-      }
-      .stats {
-        display: none;
-      }
-      .reviewed,
-      .status {
-        justify-content: flex-start;
-      }
-      .reviewed {
-        display: none;
-      }
-      .comments {
-        min-width: initial;
-      }
-      .expanded .fullFileName,
-      .truncatedFileName {
-        display: inline;
-      }
-      .expanded .truncatedFileName,
-      .fullFileName {
-        display: none;
-      }
-    }
-  </style>
-  <div id="container" on-click="_handleFileListClick">
-    <div class="header-row row">
-      <div class="status"></div>
-      <div class="path">File</div>
-      <div class="comments">Comments</div>
-      <div class="sizeBars">Size</div>
-      <div class="header-stats">Delta</div>
-      <template is="dom-if" if="[[_showDynamicColumns]]">
-        <template
-          is="dom-repeat"
-          items="[[_dynamicHeaderEndpoints]]"
-          as="headerEndpoint"
-        >
-          <gr-endpoint-decorator name$="[[headerEndpoint]]">
-          </gr-endpoint-decorator>
-        </template>
-      </template>
-      <!-- Empty div here exists to keep spacing in sync with file rows. -->
-      <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]"></div>
-      <div class="editFileControls showOnEdit"></div>
-      <div class="show-hide"></div>
-    </div>
-
-    <template
-      is="dom-repeat"
-      items="[[_shownFiles]]"
-      id="files"
-      as="file"
-      initial-count="[[fileListIncrement]]"
-      target-framerate="1"
-    >
-      [[_reportRenderedRow(index)]]
-      <div class="stickyArea">
-        <div
-          class$="file-row row [[_computePathClass(file.__path, _expandedFiles.*)]]"
-          data-file$="[[_computeFileData(file)]]"
-          tabindex="-1"
-        >
-          <div
-            class$="[[_computeClass('status', file.__path)]]"
-            tabindex="0"
-            title$="[[_computeFileStatusLabel(file.status)]]"
-            aria-label$="[[_computeFileStatusLabel(file.status)]]"
-          >
-            [[_computeFileStatus(file.status)]]
-          </div>
-          <!-- TODO: Remove data-url as it appears its not used -->
-          <span
-            data-url="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
-            class="path"
-          >
-            <a
-              class="pathLink"
-              href$="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
-            >
-              <span
-                title$="[[computeDisplayPath(file.__path)]]"
-                class="fullFileName"
-              >
-                [[computeDisplayPath(file.__path)]]
-              </span>
-              <span
-                title$="[[computeDisplayPath(file.__path)]]"
-                class="truncatedFileName"
-              >
-                [[computeTruncatedPath(file.__path)]]
-              </span>
-              <gr-copy-clipboard
-                hide-input=""
-                text="[[file.__path]]"
-              ></gr-copy-clipboard>
-            </a>
-            <template is="dom-if" if="[[file.old_path]]">
-              <div class="oldPath" title$="[[file.old_path]]">
-                [[file.old_path]]
-                <gr-copy-clipboard
-                  hide-input=""
-                  text="[[file.old_path]]"
-                ></gr-copy-clipboard>
-              </div>
-            </template>
-          </span>
-          <div class="comments desktop">
-            <span class="drafts">
-              [[_computeDraftsString(changeComments, patchRange, file.__path)]]
-            </span>
-            [[_computeCommentsString(changeComments, patchRange, file.__path)]]
-          </div>
-          <div class="comments mobile">
-            <span class="drafts">
-              [[_computeDraftsStringMobile(changeComments, patchRange,
-              file.__path)]]
-            </span>
-            [[_computeCommentsStringMobile(changeComments, patchRange,
-            file.__path)]]
-          </div>
-          <div class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]">
-            <svg width="61" height="8">
-              <rect
-                x$="[[_computeBarAdditionX(file, _sizeBarLayout)]]"
-                y="0"
-                height="8"
-                fill="#388E3C"
-                width$="[[_computeBarAdditionWidth(file, _sizeBarLayout)]]"
-              ></rect>
-              <rect
-                x$="[[_computeBarDeletionX(_sizeBarLayout)]]"
-                y="0"
-                height="8"
-                fill="#D32F2F"
-                width$="[[_computeBarDeletionWidth(file, _sizeBarLayout)]]"
-              ></rect>
-            </svg>
-          </div>
-          <div class$="[[_computeClass('stats', file.__path)]]">
-            <span
-              class="added"
-              tabindex="0"
-              aria-label$="[[file.lines_inserted]] lines added"
-              hidden$="[[file.binary]]"
-            >
-              +[[file.lines_inserted]]
-            </span>
-            <span
-              class="removed"
-              tabindex="0"
-              aria-label$="[[file.lines_deleted]] lines removed"
-              hidden$="[[file.binary]]"
-            >
-              -[[file.lines_deleted]]
-            </span>
-            <span
-              class$="[[_computeBinaryClass(file.size_delta)]]"
-              hidden$="[[!file.binary]]"
-            >
-              [[_formatBytes(file.size_delta)]] [[_formatPercentage(file.size,
-              file.size_delta)]]
-            </span>
-          </div>
-          <template is="dom-if" if="[[_showDynamicColumns]]">
-            <template
-              is="dom-repeat"
-              items="[[_dynamicContentEndpoints]]"
-              as="contentEndpoint"
-            >
-              <div class$="[[_computeClass('', file.__path)]]">
-                <gr-endpoint-decorator name="[[contentEndpoint]]">
-                  <gr-endpoint-param name="changeNum" value="[[changeNum]]">
-                  </gr-endpoint-param>
-                  <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-                  </gr-endpoint-param>
-                  <gr-endpoint-param name="path" value="[[file.__path]]">
-                  </gr-endpoint-param>
-                </gr-endpoint-decorator>
-              </div>
-            </template>
-          </template>
-          <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]" hidden="">
-            <span
-              class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]"
-              >Reviewed</span
-            >
-            <label>
-              <input
-                class="reviewed"
-                type="checkbox"
-                checked="[[file.isReviewed]]"
-              />
-              <span
-                class="markReviewed"
-                title$="[[_reviewedTitle(file.isReviewed)]]"
-                >[[_computeReviewedText(file.isReviewed)]]</span
-              >
-            </label>
-          </div>
-          <div class="editFileControls showOnEdit">
-            <template is="dom-if" if="[[editMode]]">
-              <gr-edit-file-controls
-                class$="[[_computeClass('', file.__path)]]"
-                file-path="[[file.__path]]"
-              ></gr-edit-file-controls>
-            </template>
-          </div>
-          <div class="show-hide">
-            <label
-              class="show-hide"
-              data-path$="[[file.__path]]"
-              data-expand="true"
-            >
-              <input
-                type="checkbox"
-                class="show-hide"
-                checked$="[[_isFileExpanded(file.__path, _expandedFiles.*)]]"
-                data-path$="[[file.__path]]"
-                data-expand="true"
-              />
-              <iron-icon
-                id="icon"
-                icon="[[_computeShowHideIcon(file.__path, _expandedFiles.*)]]"
-              >
-              </iron-icon>
-            </label>
-          </div>
-        </div>
-        <template
-          is="dom-if"
-          if="[[_isFileExpanded(file.__path, _expandedFiles.*)]]"
-        >
-          <gr-diff-host
-            no-auto-render=""
-            show-load-failure=""
-            display-line="[[_displayLine]]"
-            hidden="[[!_isFileExpanded(file.__path, _expandedFiles.*)]]"
-            change-num="[[changeNum]]"
-            patch-range="[[patchRange]]"
-            path="[[file.__path]]"
-            prefs="[[diffPrefs]]"
-            project-name="[[change.project]]"
-            no-render-on-prefs-change=""
-            view-mode="[[diffViewMode]]"
-          ></gr-diff-host>
-        </template>
-      </div>
-    </template>
-  </div>
-  <div class="row totalChanges" hidden$="[[_hideChangeTotals]]">
-    <div class="total-stats">
-      <span
-        class="added"
-        tabindex="0"
-        aria-label$="[[_patchChange.inserted]] lines added"
-      >
-        +[[_patchChange.inserted]]
-      </span>
-      <span
-        class="removed"
-        tabindex="0"
-        aria-label$="[[_patchChange.deleted]] lines removed"
-      >
-        -[[_patchChange.deleted]]
-      </span>
-    </div>
-    <template is="dom-if" if="[[_showDynamicColumns]]">
-      <template
-        is="dom-repeat"
-        items="[[_dynamicSummaryEndpoints]]"
-        as="summaryEndpoint"
-      >
-        <gr-endpoint-decorator name="[[summaryEndpoint]]">
-        </gr-endpoint-decorator>
-      </template>
-    </template>
-    <!-- Empty div here exists to keep spacing in sync with file rows. -->
-    <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]"></div>
-    <div class="editFileControls showOnEdit"></div>
-    <div class="show-hide"></div>
-  </div>
-  <div class="row totalChanges" hidden$="[[_hideBinaryChangeTotals]]">
-    <div class="total-stats">
-      <span class="added" aria-label="Total lines added">
-        [[_formatBytes(_patchChange.size_delta_inserted)]]
-        [[_formatPercentage(_patchChange.total_size,
-        _patchChange.size_delta_inserted)]]
-      </span>
-      <span class="removed" aria-label="Total lines removed">
-        [[_formatBytes(_patchChange.size_delta_deleted)]]
-        [[_formatPercentage(_patchChange.total_size,
-        _patchChange.size_delta_deleted)]]
-      </span>
-    </div>
-  </div>
-  <div
-    class$="row controlRow [[_computeFileListControlClass(numFilesShown, _files)]]"
-  >
-    <gr-button
-      class="fileListButton"
-      id="incrementButton"
-      link=""
-      on-click="_incrementNumFilesShown"
-    >
-      [[_computeIncrementText(numFilesShown, _files)]]
-    </gr-button>
-    <gr-tooltip-content
-      has-tooltip="[[_computeWarnShowAll(_files)]]"
-      show-icon="[[_computeWarnShowAll(_files)]]"
-      title$="[[_computeShowAllWarning(_files)]]"
-    >
-      <gr-button
-        class="fileListButton"
-        id="showAllButton"
-        link=""
-        on-click="_showAllFiles"
-      >
-        [[_computeShowAllText(_files)]] </gr-button
-      ><!--
-  --></gr-tooltip-content>
-  </div>
-  <gr-diff-preferences-dialog
-    id="diffPreferencesDialog"
-    diff-prefs="{{diffPrefs}}"
-    on-reload-diff-preference="_handleReloadingDiffPreference"
-  >
-  </gr-diff-preferences-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
-  <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
-  <gr-cursor-manager
-    id="fileCursor"
-    scroll-behavior="keep-visible"
-    focus-on-move=""
-    cursor-target-class="selected"
-  ></gr-cursor-manager>
-  <gr-reporting id="reporting"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
new file mode 100644
index 0000000..e141b70
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -0,0 +1,771 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    .row {
+      align-items: center;
+      border-top: 1px solid var(--border-color);
+      display: flex;
+      min-height: calc(var(--line-height-normal) + 2 * var(--spacing-s));
+      padding: var(--spacing-xs) var(--spacing-l);
+    }
+    /* The class defines a content visible only to screen readers */
+    .noCommentsScreenReaderText {
+      opacity: 0;
+      max-width: 1px;
+      overflow: hidden;
+      display: none;
+    }
+    div[role='gridcell']
+      > div.comments
+      > span:empty
+      + span:empty
+      + span.noCommentsScreenReaderText {
+      display: inline;
+    }
+    :host(.loading) .row {
+      opacity: 0.5;
+    }
+    :host(.editMode) .hideOnEdit {
+      display: none;
+    }
+    .showOnEdit {
+      display: none;
+    }
+    :host(.editMode) .showOnEdit {
+      display: initial;
+    }
+    .invisible {
+      visibility: hidden;
+    }
+    .header-row {
+      background-color: var(--background-color-secondary);
+    }
+    .controlRow {
+      align-items: center;
+      display: flex;
+      height: 2.25em;
+      justify-content: center;
+    }
+    .controlRow.invisible,
+    .show-hide.invisible {
+      display: none;
+    }
+    .reviewed,
+    .status {
+      align-items: center;
+      display: inline-flex;
+    }
+    .reviewed {
+      display: inline-block;
+      text-align: left;
+      width: 1.5em;
+    }
+    .status {
+      display: inline-block;
+      border-radius: var(--border-radius);
+      margin-left: var(--spacing-s);
+      padding: 0 var(--spacing-m);
+      color: var(--primary-text-color);
+      font-size: var(--font-size-small);
+      background-color: var(--dark-add-highlight-color);
+    }
+    .status.invisible,
+    .status.M {
+      display: none;
+    }
+    .status.D,
+    .status.R,
+    .status.W {
+      background-color: var(--dark-remove-highlight-color);
+    }
+    .status.U {
+      background-color: var(--comment-background-color);
+    }
+    .file-row {
+      cursor: pointer;
+    }
+    .file-row.expanded {
+      border-bottom: 1px solid var(--border-color);
+      position: -webkit-sticky;
+      position: sticky;
+      top: 0;
+      /* Has to visible above the diff view, and by default has a lower
+         z-index. setting to 1 places it directly above. */
+      z-index: 1;
+    }
+    .file-row:hover {
+      background-color: var(--hover-background-color);
+    }
+    .file-row.selected {
+      background-color: var(--selection-background-color);
+    }
+    .file-row.expanded,
+    .file-row.expanded:hover {
+      background-color: var(--expanded-background-color);
+    }
+    .path {
+      cursor: pointer;
+      flex: 1;
+      /* Wrap it into multiple lines if too long. */
+      white-space: normal;
+      word-break: break-word;
+    }
+    .oldPath {
+      color: var(--deemphasized-text-color);
+    }
+    .header-stats {
+      text-align: center;
+      min-width: 7.5em;
+    }
+    .stats {
+      text-align: right;
+      min-width: 7.5em;
+    }
+    .comments {
+      padding-left: var(--spacing-l);
+      min-width: 7.5em;
+    }
+    .row:not(.header-row) .stats,
+    .total-stats {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      display: flex;
+    }
+    .sizeBars {
+      margin-left: var(--spacing-m);
+      min-width: 7em;
+      text-align: center;
+    }
+    .sizeBars.hide {
+      display: none;
+    }
+    .added,
+    .removed {
+      display: inline-block;
+      min-width: 3.5em;
+    }
+    .added {
+      color: var(--positive-green-text-color);
+    }
+    .removed {
+      color: var(--negative-red-text-color);
+      text-align: left;
+      min-width: 4em;
+      padding-left: var(--spacing-s);
+    }
+    .drafts {
+      color: #c62828;
+      font-weight: var(--font-weight-bold);
+    }
+    .show-hide-icon:focus {
+      outline: none;
+    }
+    .show-hide {
+      margin-left: var(--spacing-s);
+      width: 1.9em;
+    }
+    .fileListButton {
+      margin: var(--spacing-m);
+    }
+    .totalChanges {
+      justify-content: flex-end;
+      text-align: right;
+    }
+    .warning {
+      color: var(--deemphasized-text-color);
+    }
+    input.show-hide {
+      display: none;
+    }
+    label.show-hide {
+      cursor: pointer;
+      display: block;
+      min-width: 2em;
+    }
+    gr-diff {
+      display: block;
+      overflow-x: auto;
+    }
+    .truncatedFileName {
+      display: none;
+    }
+    .mobile {
+      display: none;
+    }
+    .reviewed {
+      margin-left: var(--spacing-xxl);
+      width: 15em;
+    }
+    .reviewedSwitch {
+      color: var(--link-color);
+      opacity: 0;
+      justify-content: flex-end;
+      width: 100%;
+    }
+    .reviewedSwitch:hover {
+      cursor: pointer;
+      opacity: 100;
+    }
+    .row:focus {
+      outline: none;
+    }
+    .row:hover .reviewedSwitch,
+    .row:focus-within .reviewedSwitch,
+    .row.expanded .reviewedSwitch {
+      opacity: 100;
+    }
+    .reviewedLabel {
+      color: var(--deemphasized-text-color);
+      margin-right: var(--spacing-l);
+      opacity: 0;
+    }
+    .reviewedLabel.isReviewed {
+      display: initial;
+      opacity: 100;
+    }
+    .editFileControls {
+      width: 7em;
+    }
+    .markReviewed:focus {
+      outline: none;
+    }
+    .markReviewed,
+    .pathLink {
+      display: inline-block;
+      margin: -2px 0;
+      padding: var(--spacing-s) 0;
+      text-decoration: none;
+    }
+    .pathLink:hover span.fullFileName,
+    .pathLink:hover span.truncatedFileName {
+      text-decoration: underline;
+    }
+
+    /** copy on file path **/
+    .pathLink gr-copy-clipboard,
+    .oldPath gr-copy-clipboard {
+      display: inline-block;
+      visibility: hidden;
+      vertical-align: bottom;
+      --gr-button: {
+        padding: 0px;
+      }
+    }
+    .row:focus-within gr-copy-clipboard,
+    .row:hover gr-copy-clipboard {
+      visibility: visible;
+    }
+
+    /** small screen breakpoint: 768px */
+    @media screen and (max-width: 55em) {
+      .desktop {
+        display: none;
+      }
+      .mobile {
+        display: block;
+      }
+      .row.selected {
+        background-color: var(--view-background-color);
+      }
+      .stats {
+        display: none;
+      }
+      .reviewed,
+      .status {
+        justify-content: flex-start;
+      }
+      .reviewed {
+        display: none;
+      }
+      .comments {
+        min-width: initial;
+      }
+      .expanded .fullFileName,
+      .truncatedFileName {
+        display: inline;
+      }
+      .expanded .truncatedFileName,
+      .fullFileName {
+        display: none;
+      }
+    }
+    :host(.hideComments) {
+      --gr-comment-thread-display: none;
+    }
+  </style>
+  <div
+    id="container"
+    on-click="_handleFileListClick"
+    role="grid"
+    aria-label="Files list"
+  >
+    <div class="header-row row" role="row">
+      <!-- endpoint: change-view-file-list-header-prepend -->
+      <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
+        <template
+          is="dom-repeat"
+          items="[[_dynamicPrependedHeaderEndpoints]]"
+          as="headerEndpoint"
+        >
+          <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
+            <gr-endpoint-param
+              name="change"
+              value="[[change]]"
+            ></gr-endpoint-param>
+            <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+            </gr-endpoint-param>
+          </gr-endpoint-decorator>
+        </template>
+      </template>
+      <div class="path" role="columnheader">File</div>
+      <div class="comments" role="columnheader">Comments</div>
+      <div class="sizeBars" role="columnheader">Size</div>
+      <div class="header-stats" role="columnheader">Delta</div>
+      <!-- endpoint: change-view-file-list-header -->
+      <template is="dom-if" if="[[_showDynamicColumns]]">
+        <template
+          is="dom-repeat"
+          items="[[_dynamicHeaderEndpoints]]"
+          as="headerEndpoint"
+        >
+          <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
+          </gr-endpoint-decorator>
+        </template>
+      </template>
+      <!-- Empty div here exists to keep spacing in sync with file rows. -->
+      <div
+        class="reviewed hideOnEdit"
+        hidden$="[[!_loggedIn]]"
+        aria-hidden="true"
+      ></div>
+      <div class="editFileControls showOnEdit" aria-hidden="true"></div>
+      <div class="show-hide" aria-hidden="true"></div>
+    </div>
+
+    <template
+      is="dom-repeat"
+      items="[[_shownFiles]]"
+      id="files"
+      as="file"
+      initial-count="[[fileListIncrement]]"
+      target-framerate="1"
+    >
+      [[_reportRenderedRow(index)]]
+      <div class="stickyArea">
+        <div
+          class$="file-row row [[_computePathClass(file.__path, _expandedFiles.*)]]"
+          data-file$="[[_computeFileRange(file)]]"
+          tabindex="-1"
+          role="row"
+        >
+          <!-- endpoint: change-view-file-list-content-prepend -->
+          <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
+            <template
+              is="dom-repeat"
+              items="[[_dynamicPrependedContentEndpoints]]"
+              as="contentEndpoint"
+            >
+              <gr-endpoint-decorator name="[[contentEndpoint]]" role="gridcell">
+                <gr-endpoint-param name="change" value="[[change]]">
+                </gr-endpoint-param>
+                <gr-endpoint-param name="changeNum" value="[[changeNum]]">
+                </gr-endpoint-param>
+                <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+                </gr-endpoint-param>
+                <gr-endpoint-param name="path" value="[[file.__path]]">
+                </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </template>
+          </template>
+          <!-- TODO: Remove data-url as it appears its not used -->
+          <span
+            data-url="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
+            class="path"
+            role="gridcell"
+          >
+            <a
+              class="pathLink"
+              href$="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
+            >
+              <span
+                title$="[[_computeDisplayPath(file.__path)]]"
+                class="fullFileName"
+              >
+                [[_computeDisplayPath(file.__path)]]
+              </span>
+              <span
+                title$="[[_computeDisplayPath(file.__path)]]"
+                class="truncatedFileName"
+              >
+                [[_computeTruncatedPath(file.__path)]]
+              </span>
+              <span
+                class$="[[_computeStatusClass(file)]]"
+                tabindex="0"
+                title$="[[_computeFileStatusLabel(file.status)]]"
+                aria-label$="[[_computeFileStatusLabel(file.status)]]"
+              >
+                [[_computeFileStatusLabel(file.status)]]
+              </span>
+              <gr-copy-clipboard
+                hide-input=""
+                text="[[file.__path]]"
+              ></gr-copy-clipboard>
+            </a>
+            <template is="dom-if" if="[[file.old_path]]">
+              <div class="oldPath" title$="[[file.old_path]]">
+                [[file.old_path]]
+                <gr-copy-clipboard
+                  hide-input=""
+                  text="[[file.old_path]]"
+                ></gr-copy-clipboard>
+              </div>
+            </template>
+          </span>
+          <div role="gridcell">
+            <div class="comments desktop">
+              <span class="drafts"
+                ><!-- This comments ensure that span is empty when the function
+                returns empty string.
+              -->[[_computeDraftsString(changeComments, patchRange,
+                file.__path)]]<!-- This comments ensure that span is empty when
+                the function returns empty string.
+           --></span
+              >
+              <span
+                ><!--
+              -->[[_computeCommentsString(changeComments, patchRange,
+                file.__path)]]<!--
+           --></span
+              >
+              <span class="noCommentsScreenReaderText">
+                <!-- Screen readers read the following content only if 2 other
+              spans in the parent div is empty. The content is not visible on
+              the page.
+              Without this span, screen readers don't navigate correctly inside
+              table, because empty div doesn't rendered. For example, VoiceOver
+              jumps back to the whole table.
+              We can use &nbsp instead, but it sounds worse.
+              -->
+                No comments
+              </span>
+            </div>
+            <div class="comments mobile">
+              <span class="drafts"
+                ><!-- This comments ensure that span is empty when the function
+                returns empty string.
+              -->[[_computeDraftsStringMobile(changeComments, patchRange,
+                file.__path)]]<!-- This comments ensure that span is empty when
+                the function returns empty string.
+           --></span
+              >
+              <span
+                ><!--
+             -->[[_computeCommentsStringMobile(changeComments, patchRange,
+                file.__path)]]<!--
+           --></span
+              >
+              <span class="noCommentsScreenReaderText">
+                <!-- The same as for desktop comments -->
+                No comments
+              </span>
+            </div>
+          </div>
+          <div role="gridcell">
+            <!-- The content must be in a separate div. It guarantees, that
+              gridcell always visible for screen readers.
+              For example, without a nested div screen readers pronounce the
+              "Commit message" row content with incorrect column headers.
+            -->
+            <div
+              class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]"
+              aria-label="A bar that represents the addition and deletion ratio for the current file"
+            >
+              <svg width="61" height="8">
+                <rect
+                  x$="[[_computeBarAdditionX(file, _sizeBarLayout)]]"
+                  y="0"
+                  height="8"
+                  fill="var(--positive-green-text-color)"
+                  width$="[[_computeBarAdditionWidth(file, _sizeBarLayout)]]"
+                ></rect>
+                <rect
+                  x$="[[_computeBarDeletionX(_sizeBarLayout)]]"
+                  y="0"
+                  height="8"
+                  fill="var(--negative-red-text-color)"
+                  width$="[[_computeBarDeletionWidth(file, _sizeBarLayout)]]"
+                ></rect>
+              </svg>
+            </div>
+          </div>
+          <div class="stats" role="gridcell">
+            <!-- The content must be in a separate div. It guarantees, that
+            gridcell always visible for screen readers.
+            For example, without a nested div screen readers pronounce the
+            "Commit message" row content with incorrect column headers.
+            -->
+            <div class$="[[_computeClass('', file.__path)]]">
+              <span
+                class="added"
+                tabindex="0"
+                aria-label$="[[file.lines_inserted]] lines added"
+                hidden$="[[file.binary]]"
+              >
+                +[[file.lines_inserted]]
+              </span>
+              <span
+                class="removed"
+                tabindex="0"
+                aria-label$="[[file.lines_deleted]] lines removed"
+                hidden$="[[file.binary]]"
+              >
+                -[[file.lines_deleted]]
+              </span>
+              <span
+                class$="[[_computeBinaryClass(file.size_delta)]]"
+                hidden$="[[!file.binary]]"
+              >
+                [[_formatBytes(file.size_delta)]] [[_formatPercentage(file.size,
+                file.size_delta)]]
+              </span>
+            </div>
+          </div>
+          <!-- endpoint: change-view-file-list-content -->
+          <template is="dom-if" if="[[_showDynamicColumns]]">
+            <template
+              is="dom-repeat"
+              items="[[_dynamicContentEndpoints]]"
+              as="contentEndpoint"
+            >
+              <div class$="[[_computeClass('', file.__path)]]" role="gridcell">
+                <gr-endpoint-decorator name="[[contentEndpoint]]">
+                  <gr-endpoint-param name="change" value="[[change]]">
+                  </gr-endpoint-param>
+                  <gr-endpoint-param name="changeNum" value="[[changeNum]]">
+                  </gr-endpoint-param>
+                  <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+                  </gr-endpoint-param>
+                  <gr-endpoint-param name="path" value="[[file.__path]]">
+                  </gr-endpoint-param>
+                </gr-endpoint-decorator>
+              </div>
+            </template>
+          </template>
+          <div
+            class="reviewed hideOnEdit"
+            role="gridcell"
+            hidden$="[[!_loggedIn]]"
+          >
+            <span
+              class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]"
+              aria-hidden$="[[!file.isReviewed]]"
+              >Reviewed</span
+            >
+            <!-- Do not use input type="checkbox" with hidden input and
+                  visible label here. Screen readers don't read/interract
+                  correctly with such input.
+              -->
+            <span
+              class="reviewedSwitch"
+              role="switch"
+              tabindex="0"
+              on-click="_reviewedClick"
+              on-keydown="_reviewedClick"
+              aria-label="Reviewed"
+              aria-checked$="[[_booleanToString(file.isReviewed)]]"
+            >
+              <!-- Trick with tabindex to avoid outline on mouse focus, but
+                preserve focus outline for keyboard navigation -->
+              <span
+                tabindex="-1"
+                class="markReviewed"
+                title$="[[_reviewedTitle(file.isReviewed)]]"
+                >[[_computeReviewedText(file.isReviewed)]]</span
+              >
+            </span>
+          </div>
+          <div
+            class="editFileControls showOnEdit"
+            role="gridcell"
+            aria-hidden$="[[!editMode]]"
+          >
+            <template is="dom-if" if="[[editMode]]">
+              <gr-edit-file-controls
+                class$="[[_computeClass('', file.__path)]]"
+                file-path="[[file.__path]]"
+              ></gr-edit-file-controls>
+            </template>
+          </div>
+          <div class="show-hide" role="gridcell">
+            <!-- Do not use input type="checkbox" with hidden input and
+                visible label here. Screen readers don't read/interract
+                correctly with such input.
+            -->
+            <span
+              class="show-hide"
+              data-path$="[[file.__path]]"
+              data-expand="true"
+              role="switch"
+              tabindex="0"
+              aria-checked$="[[_isFileExpandedStr(file.__path, _expandedFiles.*)]]"
+              aria-label="Expand file"
+              on-click="_expandedClick"
+              on-keydown="_expandedClick"
+            >
+              <!-- Trick with tabindex to avoid outline on mouse focus, but
+              preserve focus outline for keyboard navigation -->
+              <iron-icon
+                class="show-hide-icon"
+                tabindex="-1"
+                id="icon"
+                icon="[[_computeShowHideIcon(file.__path, _expandedFiles.*)]]"
+              >
+              </iron-icon>
+            </span>
+          </div>
+        </div>
+        <template
+          is="dom-if"
+          if="[[_isFileExpanded(file.__path, _expandedFiles.*)]]"
+        >
+          <gr-diff-host
+            no-auto-render=""
+            show-load-failure=""
+            display-line="[[_displayLine]]"
+            hidden="[[!_isFileExpanded(file.__path, _expandedFiles.*)]]"
+            change-num="[[changeNum]]"
+            patch-range="[[patchRange]]"
+            file="[[_computeFileRange(file)]]"
+            path="[[file.__path]]"
+            prefs="[[diffPrefs]]"
+            project-name="[[change.project]]"
+            no-render-on-prefs-change=""
+            view-mode="[[diffViewMode]]"
+          ></gr-diff-host>
+        </template>
+      </div>
+    </template>
+  </div>
+  <div class="row totalChanges" hidden$="[[_hideChangeTotals]]">
+    <div class="total-stats">
+      <span
+        class="added"
+        tabindex="0"
+        aria-label$="Total [[_patchChange.inserted]] lines added"
+      >
+        +[[_patchChange.inserted]]
+      </span>
+      <span
+        class="removed"
+        tabindex="0"
+        aria-label$="Total [[_patchChange.deleted]] lines removed"
+      >
+        -[[_patchChange.deleted]]
+      </span>
+    </div>
+    <!-- endpoint: change-view-file-list-summary -->
+    <template is="dom-if" if="[[_showDynamicColumns]]">
+      <template
+        is="dom-repeat"
+        items="[[_dynamicSummaryEndpoints]]"
+        as="summaryEndpoint"
+      >
+        <gr-endpoint-decorator name="[[summaryEndpoint]]">
+          <gr-endpoint-param
+            name="change"
+            value="[[change]]"
+          ></gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </template>
+    </template>
+    <!-- Empty div here exists to keep spacing in sync with file rows. -->
+    <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]"></div>
+    <div class="editFileControls showOnEdit"></div>
+    <div class="show-hide"></div>
+  </div>
+  <div class="row totalChanges" hidden$="[[_hideBinaryChangeTotals]]">
+    <div class="total-stats">
+      <span
+        class="added"
+        aria-label$="Total bytes inserted: [[_formatBytes(_patchChange.size_delta_inserted)]] "
+      >
+        [[_formatBytes(_patchChange.size_delta_inserted)]]
+        [[_formatPercentage(_patchChange.total_size,
+        _patchChange.size_delta_inserted)]]
+      </span>
+      <span
+        class="removed"
+        aria-label$="Total bytes removed: [[_formatBytes(_patchChange.size_delta_deleted)]]"
+      >
+        [[_formatBytes(_patchChange.size_delta_deleted)]]
+        [[_formatPercentage(_patchChange.total_size,
+        _patchChange.size_delta_deleted)]]
+      </span>
+    </div>
+  </div>
+  <div
+    class$="row controlRow [[_computeFileListControlClass(numFilesShown, _files)]]"
+  >
+    <gr-button
+      class="fileListButton"
+      id="incrementButton"
+      link=""
+      on-click="_incrementNumFilesShown"
+    >
+      [[_computeIncrementText(numFilesShown, _files)]]
+    </gr-button>
+    <gr-tooltip-content
+      has-tooltip="[[_computeWarnShowAll(_files)]]"
+      show-icon="[[_computeWarnShowAll(_files)]]"
+      title$="[[_computeShowAllWarning(_files)]]"
+    >
+      <gr-button
+        class="fileListButton"
+        id="showAllButton"
+        link=""
+        on-click="_showAllFiles"
+      >
+        [[_computeShowAllText(_files)]] </gr-button
+      ><!--
+  --></gr-tooltip-content>
+  </div>
+  <gr-diff-preferences-dialog
+    id="diffPreferencesDialog"
+    diff-prefs="{{diffPrefs}}"
+    on-reload-diff-preference="_handleReloadingDiffPreference"
+  >
+  </gr-diff-preferences-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-storage id="storage"></gr-storage>
+  <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
+  <gr-cursor-manager
+    id="fileCursor"
+    scroll-mode="keep-visible"
+    focus-on-move=""
+    cursor-target-class="selected"
+  ></gr-cursor-manager>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
deleted file mode 100644
index 32945e4..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ /dev/null
@@ -1,1942 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-file-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script src="/components/web-component-tester/data/a11ySuite.js"></script>
-<script src="/node_modules/page/page.js"></script>
-
-<dom-module id="comment-api-mock">
-  <template>
-    <gr-file-list id="fileList"
-        change-comments="[[_changeComments]]"
-        on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
-  </template>
-  </dom-module>
-
-<test-fixture id="basic">
-  <template>
-    <comment-api-mock></comment-api-mock>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../diff/gr-comment-api/gr-comment-api.js';
-import {getMockDiffResponse} from '../../../test/mock-diff-response.js';
-import './gr-file-list.js';
-import '../../diff/gr-comment-api/gr-comment-api-mock_test.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {GrFileListConstants} from '../gr-file-list-constants.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-file-list tests', () => {
-  const kb = KeyboardShortcutBinder;
-  kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
-  kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
-  kb.bindShortcut(kb.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
-  kb.bindShortcut(kb.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
-  kb.bindShortcut(kb.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-  kb.bindShortcut(kb.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
-  kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
-  kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
-  kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
-  kb.bindShortcut(kb.Shortcut.OPEN_LAST_FILE, '[');
-  kb.bindShortcut(kb.Shortcut.OPEN_FIRST_FILE, ']');
-  kb.bindShortcut(kb.Shortcut.OPEN_FILE, 'o');
-  kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
-  kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
-  kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-  kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-
-  let element;
-  let commentApiWrapper;
-  let sandbox;
-  let saveStub;
-  let loadCommentSpy;
-
-  suite('basic tests', () => {
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getPreferences() { return Promise.resolve({}); },
-        getDiffPreferences() { return Promise.resolve({}); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-        getAccountCapabilities() { return Promise.resolve({}); },
-      });
-      stub('gr-date-formatter', {
-        _loadTimeFormat() { return Promise.resolve(''); },
-      });
-      stub('gr-diff-host', {
-        reload() { return Promise.resolve(); },
-      });
-
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = fixture('basic');
-      element = commentApiWrapper.$.fileList;
-      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      commentApiWrapper.loadComments().then(() => {
-        sandbox.stub(element.changeComments, 'getPaths').returns({});
-        sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
-            .returns({meta: {}, left: [], right: []});
-        done();
-      });
-      element._loading = false;
-      element.diffPrefs = {};
-      element.numFilesShown = 200;
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      saveStub = sandbox.stub(element, '_saveReviewedState',
-          () => Promise.resolve());
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('correct number of files are shown', () => {
-      element.fileListIncrement = 300;
-      element._filesByPath = _.range(500)
-          .reduce((_filesByPath, i) => {
-            _filesByPath['/file' + i] = {lines_inserted: 9};
-            return _filesByPath;
-          }, {});
-
-      flushAsynchronousOperations();
-      assert.equal(
-          dom(element.root).querySelectorAll('.file-row').length,
-          element.numFilesShown);
-      const controlRow = element.shadowRoot
-          .querySelector('.controlRow');
-      assert.isFalse(controlRow.classList.contains('invisible'));
-      assert.equal(element.$.incrementButton.textContent.trim(),
-          'Show 300 more');
-      assert.equal(element.$.showAllButton.textContent.trim(),
-          'Show all 500 files');
-
-      MockInteractions.tap(element.$.showAllButton);
-      flushAsynchronousOperations();
-
-      assert.equal(element.numFilesShown, 500);
-      assert.equal(element._shownFiles.length, 500);
-      assert.isTrue(controlRow.classList.contains('invisible'));
-    });
-
-    test('rendering each row calls the _reportRenderedRow method', () => {
-      const renderedStub = sandbox.stub(element, '_reportRenderedRow');
-      element._filesByPath = _.range(10)
-          .reduce((_filesByPath, i) => {
-            _filesByPath['/file' + i] = {lines_inserted: 9};
-            return _filesByPath;
-          }, {});
-      flushAsynchronousOperations();
-      assert.equal(
-          dom(element.root).querySelectorAll('.file-row').length, 10);
-      assert.equal(renderedStub.callCount, 10);
-    });
-
-    test('calculate totals for patch number', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {
-          lines_inserted: 9,
-        },
-        '/MERGE_LIST': {
-          lines_inserted: 9,
-        },
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-      };
-
-      assert.deepEqual(element._patchChange, {
-        inserted: 2,
-        deleted: 2,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-
-      // Test with a commit message that isn't the first file.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-        '/COMMIT_MSG': {
-          lines_inserted: 9,
-        },
-        '/MERGE_LIST': {
-          lines_inserted: 9,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-      };
-
-      assert.deepEqual(element._patchChange, {
-        inserted: 2,
-        deleted: 2,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-
-      // Test with no commit message.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-      };
-
-      assert.deepEqual(element._patchChange, {
-        inserted: 2,
-        deleted: 2,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-
-      // Test with files missing either lines_inserted or lines_deleted.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {lines_inserted: 1},
-        'myfile.txt': {lines_deleted: 1},
-      };
-      assert.deepEqual(element._patchChange, {
-        inserted: 1,
-        deleted: 1,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-    });
-
-    test('binary only files', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {lines_inserted: 9},
-        'file_binary_1': {binary: true, size_delta: 10, size: 100},
-        'file_binary_2': {binary: true, size_delta: -5, size: 120},
-      };
-      assert.deepEqual(element._patchChange, {
-        inserted: 0,
-        deleted: 0,
-        size_delta_inserted: 10,
-        size_delta_deleted: -5,
-        total_size: 220,
-      });
-      assert.isFalse(element._hideBinaryChangeTotals);
-      assert.isTrue(element._hideChangeTotals);
-    });
-
-    test('binary and regular files', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {lines_inserted: 9},
-        'file_binary_1': {binary: true, size_delta: 10, size: 100},
-        'file_binary_2': {binary: true, size_delta: -5, size: 120},
-        'myfile.txt': {lines_deleted: 5, size_delta: -10, size: 100},
-        'myfile2.txt': {lines_inserted: 10},
-      };
-      assert.deepEqual(element._patchChange, {
-        inserted: 10,
-        deleted: 5,
-        size_delta_inserted: 10,
-        size_delta_deleted: -5,
-        total_size: 220,
-      });
-      assert.isFalse(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-    });
-
-    test('_formatBytes function', () => {
-      const table = {
-        '64': '+64 B',
-        '1023': '+1023 B',
-        '1024': '+1 KiB',
-        '4096': '+4 KiB',
-        '1073741824': '+1 GiB',
-        '-64': '-64 B',
-        '-1023': '-1023 B',
-        '-1024': '-1 KiB',
-        '-4096': '-4 KiB',
-        '-1073741824': '-1 GiB',
-        '0': '+/-0 B',
-      };
-
-      for (const bytes in table) {
-        if (table.hasOwnProperty(bytes)) {
-          assert.equal(element._formatBytes(bytes), table[bytes]);
-        }
-      }
-    });
-
-    test('_formatPercentage function', () => {
-      const table = [
-        {size: 100,
-          delta: 100,
-          display: '',
-        },
-        {size: 195060,
-          delta: 64,
-          display: '(+0%)',
-        },
-        {size: 195060,
-          delta: -64,
-          display: '(-0%)',
-        },
-        {size: 394892,
-          delta: -7128,
-          display: '(-2%)',
-        },
-        {size: 90,
-          delta: -10,
-          display: '(-10%)',
-        },
-        {size: 110,
-          delta: 10,
-          display: '(+10%)',
-        },
-      ];
-
-      for (const item of table) {
-        assert.equal(element._formatPercentage(
-            item.size, item.delta), item.display);
-      }
-    });
-
-    test('comment filtering', () => {
-      element.changeComments._comments = {
-        '/COMMIT_MSG': [
-          {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
-          {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
-          {patch_set: 2, message: 'hello', updated: '2017-02-10 16:40:49'},
-        ],
-        'myfile.txt': [
-          {patch_set: 1, message: 'good news!', updated: '2017-02-08 16:40:49'},
-          {patch_set: 2, message: 'wat!?', updated: '2017-02-09 16:40:49'},
-          {patch_set: 2, message: 'hi', updated: '2017-02-10 16:40:49'},
-        ],
-        'unresolved.file': [
-          {
-            patch_set: 2,
-            message: 'wat!?',
-            updated: '2017-02-09 16:40:49',
-            id: '1',
-            unresolved: true,
-          },
-          {
-            patch_set: 2,
-            message: 'hi',
-            updated: '2017-02-10 16:40:49',
-            id: '2',
-            in_reply_to: '1',
-            unresolved: false,
-          },
-          {
-            patch_set: 2,
-            message: 'good news!',
-            updated: '2017-02-08 16:40:49',
-            id: '3',
-            unresolved: true,
-          },
-        ],
-      };
-      element.changeComments._drafts = {
-        '/COMMIT_MSG': [
-          {
-            patch_set: 1,
-            message: 'hi',
-            updated: '2017-02-15 16:40:49',
-            id: '5',
-            unresolved: true,
-          },
-          {
-            patch_set: 1,
-            message: 'fyi',
-            updated: '2017-02-15 16:40:49',
-            id: '6',
-            unresolved: false,
-          },
-        ],
-        'unresolved.file': [
-          {
-            patch_set: 1,
-            message: 'hi',
-            updated: '2017-02-11 16:40:49',
-            id: '4',
-            unresolved: false,
-          },
-        ],
-      };
-
-      const parentTo1 = {
-        basePatchNum: 'PARENT',
-        patchNum: '1',
-      };
-
-      const parentTo2 = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-
-      const _1To2 = {
-        basePatchNum: '1',
-        patchNum: '2',
-      };
-
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo1,
-              '/COMMIT_MSG', 'comment'), '2 comments (1 unresolved)');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, parentTo1
-              , '/COMMIT_MSG'), '2c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2
-              , '/COMMIT_MSG'), '3c');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              'unresolved.file'), '1 draft');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              'unresolved.file'), '1 draft');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              'unresolved.file'), '1d');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'unresolved.file'), '1d');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo1,
-              'myfile.txt', 'comment'), '1 comment');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'myfile.txt', 'comment'), '3 comments');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo1,
-              'myfile.txt'
-          ), '1c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '3c');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              'myfile.txt'), '');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              'myfile.txt'), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              'myfile.txt'), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo1,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo1,
-              'file_added_in_rev2.txt'
-          ), '');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              '/COMMIT_MSG', 'comment'), '1 comment');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo2,
-              '/COMMIT_MSG'
-          ), '1c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              '/COMMIT_MSG'), '3c');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              '/COMMIT_MSG'), '2 drafts');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              '/COMMIT_MSG'), '2 drafts');
-      assert.equal(
-          element._computeDraftsStringMobile(
-              element.changeComments,
-              parentTo1,
-              '/COMMIT_MSG'
-          ), '2d');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              '/COMMIT_MSG'), '2d');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              'myfile.txt', 'comment'), '2 comments');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'myfile.txt', 'comment'), '3 comments');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo2,
-              'myfile.txt'
-          ), '2c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '3c');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo2,
-              'myfile.txt'), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, parentTo2,
-              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
-      assert.equal(
-          element._computeCommentsString(element.changeComments, _1To2,
-              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
-    });
-
-    test('_reviewedTitle', () => {
-      assert.equal(
-          element._reviewedTitle(true), 'Mark as not reviewed (shortcut: r)');
-
-      assert.equal(
-          element._reviewedTitle(false), 'Mark as reviewed (shortcut: r)');
-    });
-
-    suite('keyboard shortcuts', () => {
-      setup(() => {
-        element._filesByPath = {
-          '/COMMIT_MSG': {},
-          'file_added_in_rev2.txt': {},
-          'myfile.txt': {},
-        };
-        element.changeNum = '42';
-        element.patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: '2',
-        };
-        element.change = {_number: 42};
-        element.$.fileCursor.setCursorAtIndex(0);
-      });
-
-      test('toggle left diff via shortcut', () => {
-        const toggleLeftDiffStub = sandbox.stub();
-        // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
-        // https://github.com/sinonjs/sinon/issues/781
-        const diffsStub = sinon.stub(element, 'diffs', {
-          get() {
-            return [{toggleLeftDiff: toggleLeftDiffStub}];
-          },
-        });
-        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-        assert.isTrue(toggleLeftDiffStub.calledOnce);
-        diffsStub.restore();
-      });
-
-      test('keyboard shortcuts', () => {
-        flushAsynchronousOperations();
-
-        const items = dom(element.root).querySelectorAll('.file-row');
-        element.$.fileCursor.stops = items;
-        element.$.fileCursor.setCursorAtIndex(0);
-        assert.equal(items.length, 3);
-        assert.isTrue(items[0].classList.contains('selected'));
-        assert.isFalse(items[1].classList.contains('selected'));
-        assert.isFalse(items[2].classList.contains('selected'));
-        // j with a modifier should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
-        assert.equal(element.$.fileCursor.index, 0);
-        // down should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
-        assert.equal(element.$.fileCursor.index, 0);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-        assert.equal(element.$.fileCursor.index, 1);
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-
-        const navStub = sandbox.stub(GerritNav, 'navigateToDiff');
-        assert.equal(element.$.fileCursor.index, 2);
-        assert.equal(element.selectedIndex, 2);
-
-        // k with a modifier should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
-        assert.equal(element.$.fileCursor.index, 2);
-
-        // up should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
-        assert.equal(element.$.fileCursor.index, 2);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.$.fileCursor.index, 1);
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
-
-        assert(navStub.lastCall.calledWith(element.change,
-            'file_added_in_rev2.txt', '2'),
-        'Should navigate to /c/42/2/file_added_in_rev2.txt');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.$.fileCursor.index, 0);
-        assert.equal(element.selectedIndex, 0);
-
-        const createCommentInPlaceStub = sandbox.stub(element.$.diffCursor,
-            'createCommentInPlace');
-        MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
-        assert.isTrue(createCommentInPlaceStub.called);
-      });
-
-      test('i key shows/hides selected inline diff', () => {
-        const paths = Object.keys(element._filesByPath);
-        sandbox.stub(element, '_expandedFilesChanged');
-        flushAsynchronousOperations();
-        const files = dom(element.root).querySelectorAll('.file-row');
-        element.$.fileCursor.stops = files;
-        element.$.fileCursor.setCursorAtIndex(0);
-        assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
-
-        MockInteractions.keyUpOn(element, 73, null, 'i');
-        flushAsynchronousOperations();
-        assert.equal(element.diffs.length, 1);
-        assert.equal(element.diffs[0].path, paths[0]);
-        assert.equal(element._expandedFiles.length, 1);
-        assert.equal(element._expandedFiles[0].path, paths[0]);
-
-        MockInteractions.keyUpOn(element, 73, null, 'i');
-        flushAsynchronousOperations();
-        assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
-
-        element.$.fileCursor.setCursorAtIndex(1);
-        MockInteractions.keyUpOn(element, 73, null, 'i');
-        flushAsynchronousOperations();
-        assert.equal(element.diffs.length, 1);
-        assert.equal(element.diffs[0].path, paths[1]);
-        assert.equal(element._expandedFiles.length, 1);
-        assert.equal(element._expandedFiles[0].path, paths[1]);
-
-        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-        flushAsynchronousOperations();
-        assert.equal(element.diffs.length, paths.length);
-        assert.equal(element._expandedFiles.length, paths.length);
-        for (const index in element.diffs) {
-          if (!element.diffs.hasOwnProperty(index)) { continue; }
-          assert.isTrue(
-              element._expandedFiles
-                  .some(f => f.path === element.diffs[index].path)
-          );
-        }
-
-        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-        flushAsynchronousOperations();
-        assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
-      });
-
-      test('r key toggles reviewed flag', () => {
-        const reducer = (accum, file) => (file.isReviewed ? ++accum : accum);
-        const getNumReviewed = () => element._files.reduce(reducer, 0);
-        flushAsynchronousOperations();
-
-        // Default state should be unreviewed.
-        assert.equal(getNumReviewed(), 0);
-
-        // Press the review key to toggle it (set the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        flushAsynchronousOperations();
-        assert.equal(getNumReviewed(), 1);
-
-        // Press the review key to toggle it (clear the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        assert.equal(getNumReviewed(), 0);
-      });
-
-      suite('_handleOpenFile', () => {
-        let interact;
-
-        setup(() => {
-          sandbox.stub(element, 'shouldSuppressKeyboardShortcut')
-              .returns(false);
-          sandbox.stub(element, 'modifierPressed').returns(false);
-          const openCursorStub = sandbox.stub(element, '_openCursorFile');
-          const openSelectedStub = sandbox.stub(element, '_openSelectedFile');
-          const expandStub = sandbox.stub(element, '_toggleFileExpanded');
-
-          interact = function(opt_payload) {
-            openCursorStub.reset();
-            openSelectedStub.reset();
-            expandStub.reset();
-
-            const e = new CustomEvent('fake-keyboard-event', opt_payload);
-            sinon.stub(e, 'preventDefault');
-            element._handleOpenFile(e);
-            assert.isTrue(e.preventDefault.called);
-            const result = {};
-            if (openCursorStub.called) {
-              result.opened_cursor = true;
-            }
-            if (openSelectedStub.called) {
-              result.opened_selected = true;
-            }
-            if (expandStub.called) {
-              result.expanded = true;
-            }
-            return result;
-          };
-        });
-
-        test('open from selected file', () => {
-          element._showInlineDiffs = false;
-          assert.deepEqual(interact(), {opened_selected: true});
-        });
-
-        test('open from diff cursor', () => {
-          element._showInlineDiffs = true;
-          assert.deepEqual(interact(), {opened_cursor: true});
-        });
-
-        test('expand when user prefers', () => {
-          element._showInlineDiffs = false;
-          assert.deepEqual(interact(), {opened_selected: true});
-          element._userPrefs = {};
-          assert.deepEqual(interact(), {opened_selected: true});
-        });
-      });
-
-      test('shift+left/shift+right', () => {
-        const moveLeftStub = sandbox.stub(element.$.diffCursor, 'moveLeft');
-        const moveRightStub = sandbox.stub(element.$.diffCursor, 'moveRight');
-
-        let noDiffsExpanded = true;
-        sandbox.stub(element, '_noDiffsExpanded', () => noDiffsExpanded);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
-        assert.isFalse(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
-        assert.isFalse(moveRightStub.called);
-
-        noDiffsExpanded = false;
-
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
-        assert.isTrue(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
-        assert.isTrue(moveRightStub.called);
-      });
-    });
-
-    test('computed properties', () => {
-      assert.equal(element._computeFileStatus('A'), 'A');
-      assert.equal(element._computeFileStatus(undefined), 'M');
-      assert.equal(element._computeFileStatus(null), 'M');
-
-      assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
-      assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
-          'clazz invisible');
-    });
-
-    test('file review status', () => {
-      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-        'file_added_in_rev2.txt': {},
-        'myfile.txt': {},
-      };
-      element._loggedIn = true;
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      element.$.fileCursor.setCursorAtIndex(0);
-
-      flushAsynchronousOperations();
-      const fileRows =
-          dom(element.root).querySelectorAll('.row:not(.header-row)');
-      const checkSelector = 'input.reviewed[type="checkbox"]';
-      const commitMsg = fileRows[0].querySelector(checkSelector);
-      const fileAdded = fileRows[1].querySelector(checkSelector);
-      const myFile = fileRows[2].querySelector(checkSelector);
-
-      assert.isTrue(commitMsg.checked);
-      assert.isFalse(fileAdded.checked);
-      assert.isTrue(myFile.checked);
-
-      const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
-      const markReviewLabel = commitMsg.nextElementSibling;
-      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
-      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
-
-      const clickSpy = sandbox.spy(element, '_handleFileListClick');
-      MockInteractions.tap(markReviewLabel);
-      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
-      assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
-      assert.equal(markReviewLabel.textContent, 'MARK REVIEWED');
-      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
-
-      MockInteractions.tap(markReviewLabel);
-      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
-      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
-      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
-      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
-    });
-
-    test('_computeFileStatusLabel', () => {
-      assert.equal(element._computeFileStatusLabel('A'), 'Added');
-      assert.equal(element._computeFileStatusLabel('M'), 'Modified');
-    });
-
-    test('_handleFileListClick', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-        'f1.txt': {},
-        'f2.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-
-      const clickSpy = sandbox.spy(element, '_handleFileListClick');
-      const reviewStub = sandbox.stub(element, '_reviewFile');
-      const toggleExpandSpy = sandbox.spy(element, '_toggleFileExpanded');
-
-      const row = dom(element.root)
-          .querySelector(`.row[data-file='{"path":"f1.txt"}']`);
-
-      // Click on the expand button, resulting in _toggleFileExpanded being
-      // called and not resulting in a call to _reviewFile.
-      row.querySelector('div.show-hide').click();
-      assert.isTrue(clickSpy.calledOnce);
-      assert.isTrue(toggleExpandSpy.calledOnce);
-      assert.isFalse(reviewStub.called);
-
-      // Click inside the diff. This should result in no additional calls to
-      // _toggleFileExpanded or _reviewFile.
-      dom(element.root).querySelector('gr-diff-host')
-          .click();
-      assert.isTrue(clickSpy.calledTwice);
-      assert.isTrue(toggleExpandSpy.calledOnce);
-      assert.isFalse(reviewStub.called);
-
-      // Click the reviewed checkbox, resulting in a call to _reviewFile, but
-      // no additional call to _toggleFileExpanded.
-      row.querySelector('.markReviewed').click();
-      assert.isTrue(clickSpy.calledThrice);
-      assert.isTrue(toggleExpandSpy.calledOnce);
-      assert.isTrue(reviewStub.calledOnce);
-    });
-
-    test('_handleFileListClick editMode', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-        'f1.txt': {},
-        'f2.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      element.editMode = true;
-      flushAsynchronousOperations();
-      const clickSpy = sandbox.spy(element, '_handleFileListClick');
-      const toggleExpandSpy = sandbox.spy(element, '_toggleFileExpanded');
-
-      // Tap the edit controls. Should be ignored by _handleFileListClick.
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.editFileControls'));
-      assert.isTrue(clickSpy.calledOnce);
-      assert.isFalse(toggleExpandSpy.called);
-    });
-
-    test('patch set from revisions', () => {
-      const expected = [
-        {num: 4, desc: 'test', sha: 'rev4'},
-        {num: 3, desc: 'test', sha: 'rev3'},
-        {num: 2, desc: 'test', sha: 'rev2'},
-        {num: 1, desc: 'test', sha: 'rev1'},
-      ];
-      const patchNums = element.computeAllPatchSets({
-        revisions: {
-          rev3: {_number: 3, description: 'test', date: 3},
-          rev1: {_number: 1, description: 'test', date: 1},
-          rev4: {_number: 4, description: 'test', date: 4},
-          rev2: {_number: 2, description: 'test', date: 2},
-        },
-      });
-      assert.equal(patchNums.length, expected.length);
-      for (let i = 0; i < expected.length; i++) {
-        assert.deepEqual(patchNums[i], expected[i]);
-      }
-    });
-
-    test('checkbox shows/hides diff inline', () => {
-      element._filesByPath = {
-        'myfile.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      element.$.fileCursor.setCursorAtIndex(0);
-      sandbox.stub(element, '_expandedFilesChanged');
-      flushAsynchronousOperations();
-      const fileRows =
-          dom(element.root).querySelectorAll('.row:not(.header-row)');
-      // Because the label surrounds the input, the tap event is triggered
-      // there first.
-      const showHideLabel = fileRows[0].querySelector('label.show-hide');
-      const showHideCheck = fileRows[0].querySelector(
-          'input.show-hide[type="checkbox"]');
-      assert.isNotOk(showHideCheck.checked);
-      MockInteractions.tap(showHideLabel);
-      assert.isOk(showHideCheck.checked);
-      assert.notEqual(
-          element._expandedFiles.findIndex(f => f.path === 'myfile.txt'),
-          -1);
-    });
-
-    test('diff mode correctly toggles the diffs', () => {
-      element._filesByPath = {
-        'myfile.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      sandbox.spy(element, '_updateDiffPreferences');
-      element.$.fileCursor.setCursorAtIndex(0);
-      flushAsynchronousOperations();
-
-      // Tap on a file to generate the diff.
-      const row = dom(element.root)
-          .querySelectorAll('.row:not(.header-row) label.show-hide')[0];
-
-      MockInteractions.tap(row);
-      flushAsynchronousOperations();
-      const diffDisplay = element.diffs[0];
-      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
-      element.set('diffViewMode', 'UNIFIED_DIFF');
-      assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
-      assert.isTrue(element._updateDiffPreferences.called);
-    });
-
-    test('expanded attribute not set on path when not expanded', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-      };
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.expanded'));
-    });
-
-    test('tapping row ignores links', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      sandbox.stub(element, '_expandedFilesChanged');
-      flushAsynchronousOperations();
-      const commitMsgFile = dom(element.root)
-          .querySelectorAll('.row:not(.header-row) a.pathLink')[0];
-
-      // Remove href attribute so the app doesn't route to a diff view
-      commitMsgFile.removeAttribute('href');
-      const togglePathSpy = sandbox.spy(element, '_toggleFileExpanded');
-
-      MockInteractions.tap(commitMsgFile);
-      flushAsynchronousOperations();
-      assert(togglePathSpy.notCalled, 'file is opened as diff view');
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.expanded'));
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.show-hide')).display,
-      'none');
-    });
-
-    test('_toggleFileExpanded', () => {
-      const path = 'path/to/my/file.txt';
-      element._filesByPath = {[path]: {}};
-      const renderSpy = sandbox.spy(element, '_renderInOrder');
-      const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');
-
-      assert.equal(element.shadowRoot
-          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
-      assert.equal(element._expandedFiles.length, 0);
-      element._toggleFileExpanded({path});
-      flushAsynchronousOperations();
-      assert.equal(collapseStub.lastCall.args[0].length, 0);
-      assert.equal(element.shadowRoot
-          .querySelector('iron-icon').icon, 'gr-icons:expand-less');
-
-      assert.equal(renderSpy.callCount, 1);
-      assert.isTrue(element._expandedFiles.some(f => f.path === path));
-      element._toggleFileExpanded({path});
-      flushAsynchronousOperations();
-
-      assert.equal(element.shadowRoot
-          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
-      assert.equal(renderSpy.callCount, 1);
-      assert.isFalse(element._expandedFiles.some(f => f.path === path));
-      assert.equal(collapseStub.lastCall.args[0].length, 1);
-    });
-
-    test('expandAllDiffs and collapseAllDiffs', () => {
-      const collapseStub = sandbox.stub(element, '_clearCollapsedDiffs');
-      const cursorUpdateStub = sandbox.stub(element.$.diffCursor,
-          'handleDiffUpdate');
-
-      const path = 'path/to/my/file.txt';
-      element._filesByPath = {[path]: {}};
-      element.expandAllDiffs();
-      flushAsynchronousOperations();
-      assert.isTrue(element._showInlineDiffs);
-      assert.isTrue(cursorUpdateStub.calledOnce);
-      assert.equal(collapseStub.lastCall.args[0].length, 0);
-
-      element.collapseAllDiffs();
-      flushAsynchronousOperations();
-      assert.equal(element._expandedFiles.length, 0);
-      assert.isFalse(element._showInlineDiffs);
-      assert.isTrue(cursorUpdateStub.calledTwice);
-      assert.equal(collapseStub.lastCall.args[0].length, 1);
-    });
-
-    test('_expandedFilesChanged', done => {
-      sandbox.stub(element, '_reviewFile');
-      const path = 'path/to/my/file.txt';
-      const diffs = [{
-        path,
-        style: {},
-        reload() {
-          done();
-        },
-        cancel() {},
-        getCursorStops() { return []; },
-        addEventListener(eventName, callback) {
-          if (['render-start', 'render-content', 'scroll']
-              .indexOf(eventName) >= 0) {
-            callback(new Event(eventName));
-          }
-        },
-      }];
-      sinon.stub(element, 'diffs', {
-        get() { return diffs; },
-      });
-      element.push('_expandedFiles', {path});
-    });
-
-    test('_clearCollapsedDiffs', () => {
-      const diff = {
-        cancel: sinon.stub(),
-        clearDiffContent: sinon.stub(),
-      };
-      element._clearCollapsedDiffs([diff]);
-      assert.isTrue(diff.cancel.calledOnce);
-      assert.isTrue(diff.clearDiffContent.calledOnce);
-    });
-
-    test('filesExpanded value updates to correct enum', () => {
-      element._filesByPath = {
-        'foo.bar': {},
-        'baz.bar': {},
-      };
-      flushAsynchronousOperations();
-      assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.NONE);
-      element.push('_expandedFiles', {path: 'baz.bar'});
-      flushAsynchronousOperations();
-      assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.SOME);
-      element.push('_expandedFiles', {path: 'foo.bar'});
-      flushAsynchronousOperations();
-      assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.ALL);
-      element.collapseAllDiffs();
-      flushAsynchronousOperations();
-      assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.NONE);
-      element.expandAllDiffs();
-      flushAsynchronousOperations();
-      assert.equal(element.filesExpanded,
-          GrFileListConstants.FilesExpandedState.ALL);
-    });
-
-    test('_renderInOrder', done => {
-      const reviewStub = sandbox.stub(element, '_reviewFile');
-      let callCount = 0;
-      const diffs = [{
-        path: 'p0',
-        style: {},
-        reload() {
-          assert.equal(callCount++, 2);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p1',
-        style: {},
-        reload() {
-          assert.equal(callCount++, 1);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p2',
-        style: {},
-        reload() {
-          assert.equal(callCount++, 0);
-          return Promise.resolve();
-        },
-      }];
-      element._renderInOrder([
-        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3)
-          .then(() => {
-            assert.isFalse(reviewStub.called);
-            assert.isTrue(loadCommentSpy.called);
-            done();
-          });
-    });
-
-    test('_renderInOrder logged in', done => {
-      element._loggedIn = true;
-      const reviewStub = sandbox.stub(element, '_reviewFile');
-      let callCount = 0;
-      const diffs = [{
-        path: 'p0',
-        style: {},
-        reload() {
-          assert.equal(reviewStub.callCount, 2);
-          assert.equal(callCount++, 2);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p1',
-        style: {},
-        reload() {
-          assert.equal(reviewStub.callCount, 1);
-          assert.equal(callCount++, 1);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p2',
-        style: {},
-        reload() {
-          assert.equal(reviewStub.callCount, 0);
-          assert.equal(callCount++, 0);
-          return Promise.resolve();
-        },
-      }];
-      element._renderInOrder([
-        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3)
-          .then(() => {
-            assert.equal(reviewStub.callCount, 3);
-            done();
-          });
-    });
-
-    test('_renderInOrder respects diffPrefs.manual_review', () => {
-      element._loggedIn = true;
-      element.diffPrefs = {manual_review: true};
-      const reviewStub = sandbox.stub(element, '_reviewFile');
-      const diffs = [{
-        path: 'p',
-        style: {},
-        reload() { return Promise.resolve(); },
-      }];
-
-      return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
-        assert.isFalse(reviewStub.called);
-        delete element.diffPrefs.manual_review;
-        return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
-          assert.isTrue(reviewStub.called);
-          assert.isTrue(reviewStub.calledWithExactly('p', true));
-        });
-      });
-    });
-
-    test('_loadingChanged fired from reload in debouncer', done => {
-      sandbox.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
-      element.changeNum = 123;
-      element.patchRange = {patchNum: 12};
-      element._filesByPath = {'foo.bar': {}};
-
-      element.reload().then(() => {
-        assert.isFalse(element._loading);
-        element.flushDebouncer('loading-change');
-        assert.isFalse(element.classList.contains('loading'));
-        done();
-      });
-      assert.isTrue(element._loading);
-      assert.isFalse(element.classList.contains('loading'));
-      element.flushDebouncer('loading-change');
-      assert.isTrue(element.classList.contains('loading'));
-    });
-
-    test('_loadingChanged does not set class when there are no files', () => {
-      sandbox.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
-      element.changeNum = 123;
-      element.patchRange = {patchNum: 12};
-      element.reload();
-      assert.isTrue(element._loading);
-      element.flushDebouncer('loading-change');
-      assert.isFalse(element.classList.contains('loading'));
-    });
-  });
-
-  suite('diff url file list', () => {
-    test('diff url', () => {
-      const diffStub = sandbox.stub(GerritNav, 'getUrlForDiff')
-          .returns('/c/gerrit/+/1/1/index.php');
-      const change = {
-        _number: 1,
-        project: 'gerrit',
-      };
-      const path = 'index.php';
-      const patchRange = {
-        patchNum: 1,
-      };
-      assert.equal(
-          element._computeDiffURL(change, patchRange, path, false),
-          '/c/gerrit/+/1/1/index.php');
-      diffStub.restore();
-    });
-
-    test('diff url commit msg', () => {
-      const diffStub = sandbox.stub(GerritNav, 'getUrlForDiff')
-          .returns('/c/gerrit/+/1/1//COMMIT_MSG');
-      const change = {
-        _number: 1,
-        project: 'gerrit',
-      };
-      const path = '/COMMIT_MSG';
-      const patchRange = {
-        patchNum: 1,
-      };
-      assert.equal(
-          element._computeDiffURL(change, patchRange, path, false),
-          '/c/gerrit/+/1/1//COMMIT_MSG');
-      diffStub.restore();
-    });
-
-    test('edit url', () => {
-      const editStub = sandbox.stub(GerritNav, 'getEditUrlForDiff')
-          .returns('/c/gerrit/+/1/edit/index.php,edit');
-      const change = {
-        _number: 1,
-        project: 'gerrit',
-      };
-      const path = 'index.php';
-      const patchRange = {
-        patchNum: 1,
-      };
-      assert.equal(
-          element._computeDiffURL(change, patchRange, path, true),
-          '/c/gerrit/+/1/edit/index.php,edit');
-      editStub.restore();
-    });
-
-    test('edit url commit msg', () => {
-      const editStub = sandbox.stub(GerritNav, 'getEditUrlForDiff')
-          .returns('/c/gerrit/+/1/edit//COMMIT_MSG,edit');
-      const change = {
-        _number: 1,
-        project: 'gerrit',
-      };
-      const path = '/COMMIT_MSG';
-      const patchRange = {
-        patchNum: 1,
-      };
-      assert.equal(
-          element._computeDiffURL(change, patchRange, path, true),
-          '/c/gerrit/+/1/edit//COMMIT_MSG,edit');
-      editStub.restore();
-    });
-  });
-
-  suite('size bars', () => {
-    test('_computeSizeBarLayout', () => {
-      assert.isUndefined(element._computeSizeBarLayout(null));
-      assert.isUndefined(element._computeSizeBarLayout({}));
-      assert.deepEqual(element._computeSizeBarLayout({base: []}), {
-        maxInserted: 0,
-        maxDeleted: 0,
-        maxAdditionWidth: 0,
-        maxDeletionWidth: 0,
-        deletionOffset: 0,
-      });
-
-      const files = [
-        {__path: '/COMMIT_MSG', lines_inserted: 10000},
-        {__path: 'foo', lines_inserted: 4, lines_deleted: 10},
-        {__path: 'bar', lines_inserted: 5, lines_deleted: 8},
-      ];
-      const layout = element._computeSizeBarLayout({base: files});
-      assert.equal(layout.maxInserted, 5);
-      assert.equal(layout.maxDeleted, 10);
-    });
-
-    test('_computeBarAdditionWidth', () => {
-      const file = {
-        __path: 'foo/bar.baz',
-        lines_inserted: 5,
-        lines_deleted: 0,
-      };
-      const stats = {
-        maxInserted: 10,
-        maxDeleted: 0,
-        maxAdditionWidth: 60,
-        maxDeletionWidth: 0,
-        deletionOffset: 60,
-      };
-
-      // Uses half the space when file is half the largest addition and there
-      // are no deletions.
-      assert.equal(element._computeBarAdditionWidth(file, stats), 30);
-
-      // If there are no insetions, there is no width.
-      stats.maxInserted = 0;
-      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
-
-      // If the insertions is not present on the file, there is no width.
-      stats.maxInserted = 10;
-      file.lines_inserted = undefined;
-      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
-
-      // If the file is a commit message, returns zero.
-      file.lines_inserted = 5;
-      file.__path = '/COMMIT_MSG';
-      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
-
-      // Width bottoms-out at the minimum width.
-      file.__path = 'stuff.txt';
-      file.lines_inserted = 1;
-      stats.maxInserted = 1000000;
-      assert.equal(element._computeBarAdditionWidth(file, stats), 1.5);
-    });
-
-    test('_computeBarAdditionX', () => {
-      const file = {
-        __path: 'foo/bar.baz',
-        lines_inserted: 5,
-        lines_deleted: 0,
-      };
-      const stats = {
-        maxInserted: 10,
-        maxDeleted: 0,
-        maxAdditionWidth: 60,
-        maxDeletionWidth: 0,
-        deletionOffset: 60,
-      };
-      assert.equal(element._computeBarAdditionX(file, stats), 30);
-    });
-
-    test('_computeBarDeletionWidth', () => {
-      const file = {
-        __path: 'foo/bar.baz',
-        lines_inserted: 0,
-        lines_deleted: 5,
-      };
-      const stats = {
-        maxInserted: 10,
-        maxDeleted: 10,
-        maxAdditionWidth: 30,
-        maxDeletionWidth: 30,
-        deletionOffset: 31,
-      };
-
-      // Uses a quarter the space when file is half the largest deletions and
-      // there are equal additions.
-      assert.equal(element._computeBarDeletionWidth(file, stats), 15);
-
-      // If there are no deletions, there is no width.
-      stats.maxDeleted = 0;
-      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
-
-      // If the deletions is not present on the file, there is no width.
-      stats.maxDeleted = 10;
-      file.lines_deleted = undefined;
-      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
-
-      // If the file is a commit message, returns zero.
-      file.lines_deleted = 5;
-      file.__path = '/COMMIT_MSG';
-      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
-
-      // Width bottoms-out at the minimum width.
-      file.__path = 'stuff.txt';
-      file.lines_deleted = 1;
-      stats.maxDeleted = 1000000;
-      assert.equal(element._computeBarDeletionWidth(file, stats), 1.5);
-    });
-
-    test('_computeSizeBarsClass', () => {
-      assert.equal(element._computeSizeBarsClass(false, 'foo/bar.baz'),
-          'sizeBars desktop hide');
-      assert.equal(element._computeSizeBarsClass(true, '/COMMIT_MSG'),
-          'sizeBars desktop invisible');
-      assert.equal(element._computeSizeBarsClass(true, 'foo/bar.baz'),
-          'sizeBars desktop ');
-    });
-  });
-
-  suite('gr-file-list inline diff tests', () => {
-    let element;
-    let sandbox;
-
-    const commitMsgComments = [
-      {
-        patch_set: 2,
-        id: 'ecf0b9fa_fe1a5f62',
-        line: 20,
-        updated: '2018-02-08 18:49:18.000000000',
-        message: 'another comment',
-        unresolved: true,
-      },
-      {
-        patch_set: 2,
-        id: '503008e2_0ab203ee',
-        line: 10,
-        updated: '2018-02-14 22:07:43.000000000',
-        message: 'a comment',
-        unresolved: true,
-      },
-      {
-        patch_set: 2,
-        id: 'cc788d2c_cb1d728c',
-        line: 20,
-        in_reply_to: 'ecf0b9fa_fe1a5f62',
-        updated: '2018-02-13 22:07:43.000000000',
-        message: 'response',
-        unresolved: true,
-      },
-    ];
-
-    const setupDiff = function(diff) {
-      diff.comments = {
-        left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
-        right: [],
-        meta: {
-          changeNum: 1,
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 2,
-          },
-        },
-      };
-      diff.prefs = {
-        context: 10,
-        tab_size: 8,
-        font_size: 12,
-        line_length: 100,
-        cursor_blink_rate: 0,
-        line_wrapping: false,
-        intraline_difference: true,
-        show_line_endings: true,
-        show_tabs: true,
-        show_whitespace_errors: true,
-        syntax_highlighting: true,
-        auto_hide_diff_table_header: true,
-        theme: 'DEFAULT',
-        ignore_whitespace: 'IGNORE_NONE',
-      };
-      diff.diff = getMockDiffResponse();
-      diff.$.diff.flushDebouncer('renderDiffTable');
-    };
-
-    const renderAndGetNewDiffs = function(index) {
-      const diffs =
-          dom(element.root).querySelectorAll('gr-diff-host');
-
-      for (let i = index; i < diffs.length; i++) {
-        setupDiff(diffs[i]);
-      }
-
-      element._updateDiffCursor();
-      element.$.diffCursor.handleDiffUpdate();
-      return diffs;
-    };
-
-    setup(done => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getPreferences() { return Promise.resolve({}); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
-      stub('gr-date-formatter', {
-        _loadTimeFormat() { return Promise.resolve(''); },
-      });
-      stub('gr-diff-host', {
-        reload() { return Promise.resolve(); },
-      });
-
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = fixture('basic');
-      element = commentApiWrapper.$.fileList;
-      loadCommentSpy = sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
-      element.diffPrefs = {};
-      sandbox.stub(element, '_reviewFile');
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      commentApiWrapper.loadComments().then(() => {
-        sandbox.stub(element.changeComments, 'getPaths').returns({});
-        sandbox.stub(element.changeComments, 'getCommentsBySideForPath')
-            .returns({meta: {}, left: [], right: []});
-        done();
-      });
-      element._loading = false;
-      element.numFilesShown = 75;
-      element.selectedIndex = 0;
-      element._filesByPath = {
-        '/COMMIT_MSG': {lines_inserted: 9},
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-      };
-      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
-      element._loggedIn = true;
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      sandbox.stub(window, 'fetch', () => Promise.resolve());
-      flushAsynchronousOperations();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('cursor with individually opened files', () => {
-      MockInteractions.keyUpOn(element, 73, null, 'i');
-      flushAsynchronousOperations();
-      let diffs = renderAndGetNewDiffs(0);
-      const diffStops = diffs[0].getCursorStops();
-
-      // 1 diff should be rendered.
-      assert.equal(diffs.length, 1);
-
-      // No line number is selected.
-      assert.isFalse(diffStops[10].classList.contains('target-row'));
-
-      // Tapping content on a line selects the line number.
-      MockInteractions.tap(dom(
-          diffStops[10]).querySelectorAll('.contentText')[0]);
-      flushAsynchronousOperations();
-      assert.isTrue(diffStops[10].classList.contains('target-row'));
-
-      // Keyboard shortcuts are still moving the file cursor, not the diff
-      // cursor.
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      flushAsynchronousOperations();
-      assert.isTrue(diffStops[10].classList.contains('target-row'));
-      assert.isFalse(diffStops[11].classList.contains('target-row'));
-
-      // The file cusor is now at 1.
-      assert.equal(element.$.fileCursor.index, 1);
-      MockInteractions.keyUpOn(element, 73, null, 'i');
-      flushAsynchronousOperations();
-
-      diffs = renderAndGetNewDiffs(1);
-      // Two diffs should be rendered.
-      assert.equal(diffs.length, 2);
-      const diffStopsFirst = diffs[0].getCursorStops();
-      const diffStopsSecond = diffs[1].getCursorStops();
-
-      // The line on the first diff is stil selected
-      assert.isTrue(diffStopsFirst[10].classList.contains('target-row'));
-      assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
-    });
-
-    test('cursor with toggle all files', () => {
-      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-      flushAsynchronousOperations();
-
-      const diffs = renderAndGetNewDiffs(0);
-      const diffStops = diffs[0].getCursorStops();
-
-      // 1 diff should be rendered.
-      assert.equal(diffs.length, 3);
-
-      // No line number is selected.
-      assert.isFalse(diffStops[10].classList.contains('target-row'));
-
-      // Tapping content on a line selects the line number.
-      MockInteractions.tap(dom(
-          diffStops[10]).querySelectorAll('.contentText')[0]);
-      flushAsynchronousOperations();
-      assert.isTrue(diffStops[10].classList.contains('target-row'));
-
-      // Keyboard shortcuts are still moving the file cursor, not the diff
-      // cursor.
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      flushAsynchronousOperations();
-      assert.isFalse(diffStops[10].classList.contains('target-row'));
-      assert.isTrue(diffStops[11].classList.contains('target-row'));
-
-      // The file cusor is still at 0.
-      assert.equal(element.$.fileCursor.index, 0);
-    });
-
-    suite('n key presses', () => {
-      let nKeySpy;
-      let nextCommentStub;
-      let nextChunkStub;
-      let fileRows;
-
-      setup(() => {
-        sandbox.stub(element, '_renderInOrder').returns(Promise.resolve());
-        nKeySpy = sandbox.spy(element, '_handleNextChunk');
-        nextCommentStub = sandbox.stub(element.$.diffCursor,
-            'moveToNextCommentThread');
-        nextChunkStub = sandbox.stub(element.$.diffCursor,
-            'moveToNextChunk');
-        fileRows =
-            dom(element.root).querySelectorAll('.row:not(.header-row)');
-      });
-
-      test('n key with some files expanded and no shift key', () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
-        flushAsynchronousOperations();
-        assert.equal(nextChunkStub.callCount, 1);
-
-        // Handle N key should return before calling diff cursor functions.
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isFalse(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 2);
-        assert.equal(element.filesExpanded, 'some');
-      });
-
-      test('n key with some files expanded and shift key', () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
-        flushAsynchronousOperations();
-        assert.equal(nextChunkStub.callCount, 1);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isTrue(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 1);
-        assert.equal(element.filesExpanded, 'some');
-      });
-
-      test('n key without all files expanded and shift key', () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
-        flushAsynchronousOperations();
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isFalse(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 2);
-        assert.isTrue(element._showInlineDiffs);
-      });
-
-      test('n key without all files expanded and no shift key', () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
-        flushAsynchronousOperations();
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isTrue(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 1);
-        assert.isTrue(element._showInlineDiffs);
-      });
-    });
-
-    test('_openSelectedFile behavior', () => {
-      const _filesByPath = element._filesByPath;
-      element.set('_filesByPath', {});
-      const navStub = sandbox.stub(GerritNav, 'navigateToDiff');
-      // Noop when there are no files.
-      element._openSelectedFile();
-      assert.isFalse(navStub.called);
-
-      element.set('_filesByPath', _filesByPath);
-      flushAsynchronousOperations();
-      // Navigates when a file is selected.
-      element._openSelectedFile();
-      assert.isTrue(navStub.called);
-    });
-
-    test('_displayLine', () => {
-      sandbox.stub(element, 'shouldSuppressKeyboardShortcut', () => false);
-      sandbox.stub(element, 'modifierPressed', () => false);
-      element._showInlineDiffs = true;
-      const mockEvent = {preventDefault() {}};
-
-      element._displayLine = false;
-      element._handleCursorNext(mockEvent);
-      assert.isTrue(element._displayLine);
-
-      element._displayLine = false;
-      element._handleCursorPrev(mockEvent);
-      assert.isTrue(element._displayLine);
-
-      element._displayLine = true;
-      element._handleEscKey(mockEvent);
-      assert.isFalse(element._displayLine);
-    });
-
-    suite('editMode behavior', () => {
-      test('reviewed checkbox', () => {
-        element._reviewFile.restore();
-        const saveReviewStub = sandbox.stub(element, '_saveReviewedState');
-
-        element.editMode = false;
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        assert.isTrue(saveReviewStub.calledOnce);
-
-        element.editMode = true;
-        flushAsynchronousOperations();
-
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        assert.isTrue(saveReviewStub.calledOnce);
-      });
-
-      test('_getReviewedFiles does not call API', () => {
-        const apiSpy = sandbox.spy(element.$.restAPI, 'getReviewedFiles');
-        element.editMode = true;
-        return element._getReviewedFiles().then(files => {
-          assert.equal(files.length, 0);
-          assert.isFalse(apiSpy.called);
-        });
-      });
-    });
-
-    test('editing actions', () => {
-      // Edit controls are guarded behind a dom-if initially and not rendered.
-      assert.isNotOk(dom(element.root)
-          .querySelector('gr-edit-file-controls'));
-
-      element.editMode = true;
-      flushAsynchronousOperations();
-
-      // Commit message should not have edit controls.
-      const editControls =
-          Array.from(
-              dom(element.root)
-                  .querySelectorAll('.row:not(.header-row)'))
-              .map(row => row.querySelector('gr-edit-file-controls'));
-      assert.isTrue(editControls[0].classList.contains('invisible'));
-    });
-
-    test('reloadCommentsForThreadWithRootId', () => {
-      // Expand the commit message diff
-      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-      const diffs = renderAndGetNewDiffs(0);
-      flushAsynchronousOperations();
-
-      // Two comment threads should be generated by renderAndGetNewDiffs
-      const threadEls = diffs[0].getThreadEls();
-      assert.equal(threadEls.length, 2);
-      const threadElsByRootId = new Map(
-          threadEls.map(threadEl => [threadEl.rootId, threadEl]));
-
-      const thread1 = threadElsByRootId.get('503008e2_0ab203ee');
-      assert.equal(thread1.comments.length, 1);
-      assert.equal(thread1.comments[0].message, 'a comment');
-      assert.equal(thread1.comments[0].line, 10);
-
-      const thread2 = threadElsByRootId.get('ecf0b9fa_fe1a5f62');
-      assert.equal(thread2.comments.length, 2);
-      assert.isTrue(thread2.comments[0].unresolved);
-      assert.equal(thread2.comments[0].message, 'another comment');
-      assert.equal(thread2.comments[0].line, 20);
-
-      const commentStub =
-          sandbox.stub(element.changeComments, 'getCommentsForThread');
-      const commentStubRes1 = [
-        {
-          patch_set: 2,
-          id: '503008e2_0ab203ee',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'edited text',
-          unresolved: false,
-        },
-      ];
-      const commentStubRes2 = [
-        {
-          patch_set: 2,
-          id: 'ecf0b9fa_fe1a5f62',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'another comment',
-          unresolved: true,
-        },
-        {
-          patch_set: 2,
-          id: '503008e2_0ab203ee',
-          line: 10,
-          in_reply_to: 'ecf0b9fa_fe1a5f62',
-          updated: '2018-02-14 22:07:43.000000000',
-          message: 'response',
-          unresolved: true,
-        },
-        {
-          patch_set: 2,
-          id: '503008e2_0ab203ef',
-          line: 20,
-          in_reply_to: '503008e2_0ab203ee',
-          updated: '2018-02-15 22:07:43.000000000',
-          message: 'a third comment in the thread',
-          unresolved: true,
-        },
-      ];
-      commentStub.withArgs('503008e2_0ab203ee').returns(
-          commentStubRes1);
-      commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
-          commentStubRes2);
-
-      // Reload comments from the first comment thread, which should have a
-      // an updated message and a toggled resolve state.
-      element.reloadCommentsForThreadWithRootId('503008e2_0ab203ee',
-          '/COMMIT_MSG');
-      assert.equal(thread1.comments.length, 1);
-      assert.isFalse(thread1.comments[0].unresolved);
-      assert.equal(thread1.comments[0].message, 'edited text');
-
-      // Reload comments from the second comment thread, which should have a new
-      // reply.
-      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-          '/COMMIT_MSG');
-      assert.equal(thread2.comments.length, 3);
-
-      const commentStubCount = commentStub.callCount;
-      const getThreadsSpy = sandbox.spy(diffs[0], 'getThreadEls');
-
-      // Should not be getting threads when the file is not expanded.
-      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-          'other/file');
-      assert.isFalse(getThreadsSpy.called);
-      assert.equal(commentStubCount, commentStub.callCount);
-
-      // Should be query selecting diffs when the file is expanded.
-      // Should not be fetching change comments when the rootId is not found
-      // to match.
-      element.reloadCommentsForThreadWithRootId('acf0b9fa_fe1a5f62',
-          '/COMMIT_MSG');
-      assert.isTrue(getThreadsSpy.called);
-      assert.equal(commentStubCount, commentStub.callCount);
-    });
-  });
-  a11ySuite('basic');
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
new file mode 100644
index 0000000..932000c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -0,0 +1,1914 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../diff/gr-comment-api/gr-comment-api.js';
+import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import './gr-file-list.js';
+import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrFileListConstants} from '../gr-file-list-constants.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {runA11yAudit} from '../../../test/a11y-test-utils.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+
+const commentApiMock = createCommentApiMockWithTemplateElement(
+    'gr-file-list-comment-api-mock', html`
+    <gr-file-list id="fileList"
+        change-comments="[[_changeComments]]"
+        on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
+`);
+
+const basicFixture = fixtureFromElement(commentApiMock.is);
+
+suite('gr-diff a11y test', () => {
+  test('audit', async () => {
+    await runA11yAudit(basicFixture);
+  });
+});
+
+suite('gr-file-list tests', () => {
+  let element;
+  let commentApiWrapper;
+
+  let saveStub;
+  let loadCommentSpy;
+
+  suiteSetup(() => {
+    const kb = TestKeyboardShortcutBinder.push();
+    kb.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
+    kb.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
+    kb.bindShortcut(Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+    kb.bindShortcut(Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+    kb.bindShortcut(Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+    kb.bindShortcut(Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+    kb.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
+    kb.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
+    kb.bindShortcut(Shortcut.NEW_COMMENT, 'c');
+    kb.bindShortcut(Shortcut.OPEN_LAST_FILE, '[');
+    kb.bindShortcut(Shortcut.OPEN_FIRST_FILE, ']');
+    kb.bindShortcut(Shortcut.OPEN_FILE, 'o');
+    kb.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
+    kb.bindShortcut(Shortcut.PREV_CHUNK, 'p');
+    kb.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r');
+    kb.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+  });
+
+  suiteTeardown(() => {
+    TestKeyboardShortcutBinder.pop();
+  });
+
+  suite('basic tests', () => {
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
+        getDiffPreferences() { return Promise.resolve({}); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+        getAccountCapabilities() { return Promise.resolve({}); },
+      });
+      stub('gr-date-formatter', {
+        _loadTimeFormat() { return Promise.resolve(''); },
+      });
+      stub('gr-diff-host', {
+        reload() { return Promise.resolve(); },
+        prefetchDiff() {},
+      });
+
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = basicFixture.instantiate();
+      element = commentApiWrapper.$.fileList;
+      loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      commentApiWrapper.loadComments().then(() => {
+        sinon.stub(element.changeComments, 'getPaths').returns({});
+        sinon.stub(element.changeComments, 'getCommentsBySideForPath')
+            .returns({meta: {}, left: [], right: []});
+        done();
+      });
+      element._loading = false;
+      element.diffPrefs = {};
+      element.numFilesShown = 200;
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      saveStub = sinon.stub(element, '_saveReviewedState').callsFake(
+          () => Promise.resolve());
+    });
+
+    test('correct number of files are shown', () => {
+      element.fileListIncrement = 300;
+      element._filesByPath = Array(500).fill(0)
+          .reduce((_filesByPath, _, idx) => {
+            _filesByPath['/file' + idx] = {lines_inserted: 9};
+            return _filesByPath;
+          }, {});
+
+      flushAsynchronousOperations();
+      assert.equal(
+          dom(element.root).querySelectorAll('.file-row').length,
+          element.numFilesShown);
+      const controlRow = element.shadowRoot
+          .querySelector('.controlRow');
+      assert.isFalse(controlRow.classList.contains('invisible'));
+      assert.equal(element.$.incrementButton.textContent.trim(),
+          'Show 300 more');
+      assert.equal(element.$.showAllButton.textContent.trim(),
+          'Show all 500 files');
+
+      MockInteractions.tap(element.$.showAllButton);
+      flushAsynchronousOperations();
+
+      assert.equal(element.numFilesShown, 500);
+      assert.equal(element._shownFiles.length, 500);
+      assert.isTrue(controlRow.classList.contains('invisible'));
+    });
+
+    test('rendering each row calls the _reportRenderedRow method', () => {
+      const renderedStub = sinon.stub(element, '_reportRenderedRow');
+      element._filesByPath = Array(10).fill(0)
+          .reduce((_filesByPath, _, idx) => {
+            _filesByPath['/file' + idx] = {lines_inserted: 9};
+            return _filesByPath;
+          }, {});
+      flushAsynchronousOperations();
+      assert.equal(
+          dom(element.root).querySelectorAll('.file-row').length, 10);
+      assert.equal(renderedStub.callCount, 10);
+    });
+
+    test('calculate totals for patch number', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {
+          lines_inserted: 9,
+        },
+        '/MERGE_LIST': {
+          lines_inserted: 9,
+        },
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      };
+
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+
+      // Test with a commit message that isn't the first file.
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+        '/COMMIT_MSG': {
+          lines_inserted: 9,
+        },
+        '/MERGE_LIST': {
+          lines_inserted: 9,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+      };
+
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+
+      // Test with no commit message.
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+        },
+      };
+
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+
+      // Test with files missing either lines_inserted or lines_deleted.
+      element._filesByPath = {
+        'file_added_in_rev2.txt': {lines_inserted: 1},
+        'myfile.txt': {lines_deleted: 1},
+      };
+      assert.deepEqual(element._patchChange, {
+        inserted: 1,
+        deleted: 1,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+    });
+
+    test('binary only files', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {lines_inserted: 9},
+        'file_binary_1': {binary: true, size_delta: 10, size: 100},
+        'file_binary_2': {binary: true, size_delta: -5, size: 120},
+      };
+      assert.deepEqual(element._patchChange, {
+        inserted: 0,
+        deleted: 0,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isTrue(element._hideChangeTotals);
+    });
+
+    test('binary and regular files', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {lines_inserted: 9},
+        'file_binary_1': {binary: true, size_delta: 10, size: 100},
+        'file_binary_2': {binary: true, size_delta: -5, size: 120},
+        'myfile.txt': {lines_deleted: 5, size_delta: -10, size: 100},
+        'myfile2.txt': {lines_inserted: 10},
+      };
+      assert.deepEqual(element._patchChange, {
+        inserted: 10,
+        deleted: 5,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+    });
+
+    test('_formatBytes function', () => {
+      const table = {
+        '64': '+64 B',
+        '1023': '+1023 B',
+        '1024': '+1 KiB',
+        '4096': '+4 KiB',
+        '1073741824': '+1 GiB',
+        '-64': '-64 B',
+        '-1023': '-1023 B',
+        '-1024': '-1 KiB',
+        '-4096': '-4 KiB',
+        '-1073741824': '-1 GiB',
+        '0': '+/-0 B',
+      };
+
+      for (const bytes in table) {
+        if (table.hasOwnProperty(bytes)) {
+          assert.equal(element._formatBytes(bytes), table[bytes]);
+        }
+      }
+    });
+
+    test('_formatPercentage function', () => {
+      const table = [
+        {size: 100,
+          delta: 100,
+          display: '',
+        },
+        {size: 195060,
+          delta: 64,
+          display: '(+0%)',
+        },
+        {size: 195060,
+          delta: -64,
+          display: '(-0%)',
+        },
+        {size: 394892,
+          delta: -7128,
+          display: '(-2%)',
+        },
+        {size: 90,
+          delta: -10,
+          display: '(-10%)',
+        },
+        {size: 110,
+          delta: 10,
+          display: '(+10%)',
+        },
+      ];
+
+      for (const item of table) {
+        assert.equal(element._formatPercentage(
+            item.size, item.delta), item.display);
+      }
+    });
+
+    test('comment filtering', () => {
+      element.changeComments._comments = {
+        '/COMMIT_MSG': [
+          {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
+          {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
+          {patch_set: 2, message: 'hello', updated: '2017-02-10 16:40:49'},
+        ],
+        'myfile.txt': [
+          {patch_set: 1, message: 'good news!', updated: '2017-02-08 16:40:49'},
+          {patch_set: 2, message: 'wat!?', updated: '2017-02-09 16:40:49'},
+          {patch_set: 2, message: 'hi', updated: '2017-02-10 16:40:49'},
+        ],
+        'unresolved.file': [
+          {
+            patch_set: 2,
+            message: 'wat!?',
+            updated: '2017-02-09 16:40:49',
+            id: '1',
+            unresolved: true,
+          },
+          {
+            patch_set: 2,
+            message: 'hi',
+            updated: '2017-02-10 16:40:49',
+            id: '2',
+            in_reply_to: '1',
+            unresolved: false,
+          },
+          {
+            patch_set: 2,
+            message: 'good news!',
+            updated: '2017-02-08 16:40:49',
+            id: '3',
+            unresolved: true,
+          },
+        ],
+      };
+      element.changeComments._drafts = {
+        '/COMMIT_MSG': [
+          {
+            patch_set: 1,
+            message: 'hi',
+            updated: '2017-02-15 16:40:49',
+            id: '5',
+            unresolved: true,
+          },
+          {
+            patch_set: 1,
+            message: 'fyi',
+            updated: '2017-02-15 16:40:49',
+            id: '6',
+            unresolved: false,
+          },
+        ],
+        'unresolved.file': [
+          {
+            patch_set: 1,
+            message: 'hi',
+            updated: '2017-02-11 16:40:49',
+            id: '4',
+            unresolved: false,
+          },
+        ],
+      };
+
+      const parentTo1 = {
+        basePatchNum: 'PARENT',
+        patchNum: '1',
+      };
+
+      const parentTo2 = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+
+      const _1To2 = {
+        basePatchNum: '1',
+        patchNum: '2',
+      };
+
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo1,
+              '/COMMIT_MSG', 'comment'), '2 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, parentTo1
+              , '/COMMIT_MSG'), '2c');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2
+              , '/COMMIT_MSG'), '3c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
+              'unresolved.file'), '1 draft');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, _1To2,
+              'unresolved.file'), '1 draft');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo1,
+              'unresolved.file'), '1d');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'unresolved.file'), '1d');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo1,
+              'myfile.txt', 'comment'), '1 comment');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'myfile.txt', 'comment'), '3 comments');
+      assert.equal(
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo1,
+              'myfile.txt'
+          ), '1c');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '3c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, _1To2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo1,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo1,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo1,
+              'file_added_in_rev2.txt'
+          ), '');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, _1To2,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo1,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
+              '/COMMIT_MSG', 'comment'), '1 comment');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              '/COMMIT_MSG', 'comment'), '3 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo2,
+              '/COMMIT_MSG'
+          ), '1c');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              '/COMMIT_MSG'), '3c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, parentTo1,
+              '/COMMIT_MSG'), '2 drafts');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, _1To2,
+              '/COMMIT_MSG'), '2 drafts');
+      assert.equal(
+          element._computeDraftsStringMobile(
+              element.changeComments,
+              parentTo1,
+              '/COMMIT_MSG'
+          ), '2d');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              '/COMMIT_MSG'), '2d');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
+              'myfile.txt', 'comment'), '2 comments');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'myfile.txt', 'comment'), '3 comments');
+      assert.equal(
+          element._computeCommentsStringMobile(
+              element.changeComments,
+              parentTo2,
+              'myfile.txt'
+          ), '2c');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '3c');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, parentTo2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, _1To2,
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, parentTo2,
+              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, _1To2,
+              'unresolved.file', 'comment'), '3 comments (1 unresolved)');
+    });
+
+    test('_reviewedTitle', () => {
+      assert.equal(
+          element._reviewedTitle(true), 'Mark as not reviewed (shortcut: r)');
+
+      assert.equal(
+          element._reviewedTitle(false), 'Mark as reviewed (shortcut: r)');
+    });
+
+    suite('keyboard shortcuts', () => {
+      setup(() => {
+        element._filesByPath = {
+          '/COMMIT_MSG': {},
+          'file_added_in_rev2.txt': {},
+          'myfile.txt': {},
+        };
+        element.changeNum = '42';
+        element.patchRange = {
+          basePatchNum: 'PARENT',
+          patchNum: '2',
+        };
+        element.change = {_number: 42};
+        element.$.fileCursor.setCursorAtIndex(0);
+      });
+
+      test('toggle left diff via shortcut', () => {
+        const toggleLeftDiffStub = sinon.stub();
+        // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
+        // https://github.com/sinonjs/sinon/issues/781
+        const diffsStub = sinon.stub(element, 'diffs')
+            .get(() => [{toggleLeftDiff: toggleLeftDiffStub}]);
+        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+        assert.isTrue(toggleLeftDiffStub.calledOnce);
+        diffsStub.restore();
+      });
+
+      test('keyboard shortcuts', () => {
+        flushAsynchronousOperations();
+
+        const items = dom(element.root).querySelectorAll('.file-row');
+        element.$.fileCursor.stops = items;
+        element.$.fileCursor.setCursorAtIndex(0);
+        assert.equal(items.length, 3);
+        assert.isTrue(items[0].classList.contains('selected'));
+        assert.isFalse(items[1].classList.contains('selected'));
+        assert.isFalse(items[2].classList.contains('selected'));
+        // j with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
+        assert.equal(element.$.fileCursor.index, 0);
+        // down should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
+        assert.equal(element.$.fileCursor.index, 0);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+        assert.equal(element.$.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+
+        const navStub = sinon.stub(GerritNav, 'navigateToDiff');
+        assert.equal(element.$.fileCursor.index, 2);
+        assert.equal(element.selectedIndex, 2);
+
+        // k with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
+        assert.equal(element.$.fileCursor.index, 2);
+
+        // up should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
+        assert.equal(element.$.fileCursor.index, 2);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        assert.equal(element.$.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+        MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
+
+        assert(navStub.lastCall.calledWith(element.change,
+            'file_added_in_rev2.txt', '2'),
+        'Should navigate to /c/42/2/file_added_in_rev2.txt');
+
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+        assert.equal(element.$.fileCursor.index, 0);
+        assert.equal(element.selectedIndex, 0);
+
+        const createCommentInPlaceStub = sinon.stub(element.$.diffCursor,
+            'createCommentInPlace');
+        MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
+        assert.isTrue(createCommentInPlaceStub.called);
+      });
+
+      test('i key shows/hides selected inline diff', () => {
+        const paths = Object.keys(element._filesByPath);
+        sinon.stub(element, '_expandedFilesChanged');
+        flushAsynchronousOperations();
+        const files = dom(element.root).querySelectorAll('.file-row');
+        element.$.fileCursor.stops = files;
+        element.$.fileCursor.setCursorAtIndex(0);
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element._expandedFiles.length, 0);
+
+        MockInteractions.keyUpOn(element, 73, null, 'i');
+        flushAsynchronousOperations();
+        assert.equal(element.diffs.length, 1);
+        assert.equal(element.diffs[0].path, paths[0]);
+        assert.equal(element._expandedFiles.length, 1);
+        assert.equal(element._expandedFiles[0].path, paths[0]);
+
+        MockInteractions.keyUpOn(element, 73, null, 'i');
+        flushAsynchronousOperations();
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element._expandedFiles.length, 0);
+
+        element.$.fileCursor.setCursorAtIndex(1);
+        MockInteractions.keyUpOn(element, 73, null, 'i');
+        flushAsynchronousOperations();
+        assert.equal(element.diffs.length, 1);
+        assert.equal(element.diffs[0].path, paths[1]);
+        assert.equal(element._expandedFiles.length, 1);
+        assert.equal(element._expandedFiles[0].path, paths[1]);
+
+        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+        flushAsynchronousOperations();
+        assert.equal(element.diffs.length, paths.length);
+        assert.equal(element._expandedFiles.length, paths.length);
+        for (const index in element.diffs) {
+          if (!element.diffs.hasOwnProperty(index)) { continue; }
+          assert.isTrue(
+              element._expandedFiles
+                  .some(f => f.path === element.diffs[index].path)
+          );
+        }
+
+        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+        flushAsynchronousOperations();
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element._expandedFiles.length, 0);
+      });
+
+      test('r key toggles reviewed flag', () => {
+        const reducer = (accum, file) => (file.isReviewed ? ++accum : accum);
+        const getNumReviewed = () => element._files.reduce(reducer, 0);
+        flushAsynchronousOperations();
+
+        // Default state should be unreviewed.
+        assert.equal(getNumReviewed(), 0);
+
+        // Press the review key to toggle it (set the flag).
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        flushAsynchronousOperations();
+        assert.equal(getNumReviewed(), 1);
+
+        // Press the review key to toggle it (clear the flag).
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.equal(getNumReviewed(), 0);
+      });
+
+      suite('_handleOpenFile', () => {
+        let interact;
+
+        setup(() => {
+          sinon.stub(element, 'shouldSuppressKeyboardShortcut')
+              .returns(false);
+          sinon.stub(element, 'modifierPressed').returns(false);
+          const openCursorStub = sinon.stub(element, '_openCursorFile');
+          const openSelectedStub = sinon.stub(element, '_openSelectedFile');
+          const expandStub = sinon.stub(element, '_toggleFileExpanded');
+
+          interact = function(opt_payload) {
+            openCursorStub.reset();
+            openSelectedStub.reset();
+            expandStub.reset();
+
+            const e = new CustomEvent('fake-keyboard-event', opt_payload);
+            sinon.stub(e, 'preventDefault');
+            element._handleOpenFile(e);
+            assert.isTrue(e.preventDefault.called);
+            const result = {};
+            if (openCursorStub.called) {
+              result.opened_cursor = true;
+            }
+            if (openSelectedStub.called) {
+              result.opened_selected = true;
+            }
+            if (expandStub.called) {
+              result.expanded = true;
+            }
+            return result;
+          };
+        });
+
+        test('open from selected file', () => {
+          element._showInlineDiffs = false;
+          assert.deepEqual(interact(), {opened_selected: true});
+        });
+
+        test('open from diff cursor', () => {
+          element._showInlineDiffs = true;
+          assert.deepEqual(interact(), {opened_cursor: true});
+        });
+
+        test('expand when user prefers', () => {
+          element._showInlineDiffs = false;
+          assert.deepEqual(interact(), {opened_selected: true});
+          element._userPrefs = {};
+          assert.deepEqual(interact(), {opened_selected: true});
+        });
+      });
+
+      test('shift+left/shift+right', () => {
+        const moveLeftStub = sinon.stub(element.$.diffCursor, 'moveLeft');
+        const moveRightStub = sinon.stub(element.$.diffCursor, 'moveRight');
+
+        let noDiffsExpanded = true;
+        sinon.stub(element, '_noDiffsExpanded')
+            .callsFake(() => noDiffsExpanded);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
+        assert.isFalse(moveLeftStub.called);
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
+        assert.isFalse(moveRightStub.called);
+
+        noDiffsExpanded = false;
+
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
+        assert.isTrue(moveLeftStub.called);
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
+        assert.isTrue(moveRightStub.called);
+      });
+    });
+
+    test('computed properties', () => {
+      assert.equal(element._computeFileStatus('A'), 'A');
+      assert.equal(element._computeFileStatus(undefined), 'M');
+      assert.equal(element._computeFileStatus(null), 'M');
+
+      assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
+      assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
+          'clazz invisible');
+    });
+
+    test('file review status', () => {
+      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+        'file_added_in_rev2.txt': {},
+        'myfile.txt': {},
+      };
+      element._loggedIn = true;
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.$.fileCursor.setCursorAtIndex(0);
+      const reviewSpy = sinon.spy(element, '_reviewFile');
+      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
+
+      flushAsynchronousOperations();
+      const fileRows =
+          dom(element.root).querySelectorAll('.row:not(.header-row)');
+      const checkSelector = 'span.reviewedSwitch[role="switch"]';
+      const commitMsg = fileRows[0].querySelector(checkSelector);
+      const fileAdded = fileRows[1].querySelector(checkSelector);
+      const myFile = fileRows[2].querySelector(checkSelector);
+
+      assert.equal(commitMsg.getAttribute('aria-checked'), 'true');
+      assert.equal(fileAdded.getAttribute('aria-checked'), 'false');
+      assert.equal(myFile.getAttribute('aria-checked'), 'true');
+
+      const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
+      const markReviewLabel = fileRows[0].querySelector('.markReviewed');
+      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
+
+      const clickSpy = sinon.spy(element, '_reviewedClick');
+      MockInteractions.tap(markReviewLabel);
+      // assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
+      // assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel.textContent, 'MARK REVIEWED');
+      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+      assert.isTrue(reviewSpy.calledOnce);
+
+      MockInteractions.tap(markReviewLabel);
+      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
+      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
+      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+      assert.isTrue(reviewSpy.calledTwice);
+
+      assert.isFalse(toggleExpandSpy.called);
+    });
+
+    test('_computeFileStatusLabel', () => {
+      assert.equal(element._computeFileStatusLabel('A'), 'Added');
+      assert.equal(element._computeFileStatusLabel('M'), 'Modified');
+    });
+
+    test('_handleFileListClick', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+        'f1.txt': {},
+        'f2.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+
+      const clickSpy = sinon.spy(element, '_handleFileListClick');
+      const reviewStub = sinon.stub(element, '_reviewFile');
+      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
+
+      const row = dom(element.root)
+          .querySelector(`.row[data-file='{"path":"f1.txt"}']`);
+
+      // Click on the expand button, resulting in _toggleFileExpanded being
+      // called and not resulting in a call to _reviewFile.
+      row.querySelector('div.show-hide').click();
+      assert.isTrue(clickSpy.calledOnce);
+      assert.isTrue(toggleExpandSpy.calledOnce);
+      assert.isFalse(reviewStub.called);
+
+      // Click inside the diff. This should result in no additional calls to
+      // _toggleFileExpanded or _reviewFile.
+      dom(element.root).querySelector('gr-diff-host')
+          .click();
+      assert.isTrue(clickSpy.calledTwice);
+      assert.isTrue(toggleExpandSpy.calledOnce);
+      assert.isFalse(reviewStub.called);
+    });
+
+    test('_handleFileListClick editMode', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+        'f1.txt': {},
+        'f2.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.editMode = true;
+      flushAsynchronousOperations();
+      const clickSpy = sinon.spy(element, '_handleFileListClick');
+      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
+
+      // Tap the edit controls. Should be ignored by _handleFileListClick.
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.editFileControls'));
+      assert.isTrue(clickSpy.calledOnce);
+      assert.isFalse(toggleExpandSpy.called);
+    });
+
+    test('checkbox shows/hides diff inline', () => {
+      element._filesByPath = {
+        'myfile.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.$.fileCursor.setCursorAtIndex(0);
+      sinon.stub(element, '_expandedFilesChanged');
+      flushAsynchronousOperations();
+      const fileRows =
+          dom(element.root).querySelectorAll('.row:not(.header-row)');
+      // Because the label surrounds the input, the tap event is triggered
+      // there first.
+      const showHideCheck = fileRows[0].querySelector(
+          'span.show-hide[role="switch"]');
+      const showHideLabel = showHideCheck.querySelector('.show-hide-icon');
+      assert.equal(showHideCheck.getAttribute('aria-checked'), 'false');
+      MockInteractions.tap(showHideLabel);
+      assert.equal(showHideCheck.getAttribute('aria-checked'), 'true');
+      assert.notEqual(
+          element._expandedFiles.findIndex(f => f.path === 'myfile.txt'),
+          -1);
+    });
+
+    test('diff mode correctly toggles the diffs', () => {
+      element._filesByPath = {
+        'myfile.txt': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      sinon.spy(element, '_updateDiffPreferences');
+      element.$.fileCursor.setCursorAtIndex(0);
+      flushAsynchronousOperations();
+
+      // Tap on a file to generate the diff.
+      const row = dom(element.root)
+          .querySelectorAll('.row:not(.header-row) span.show-hide')[0];
+
+      MockInteractions.tap(row);
+      flushAsynchronousOperations();
+      const diffDisplay = element.diffs[0];
+      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
+      element.set('diffViewMode', 'UNIFIED_DIFF');
+      assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
+      assert.isTrue(element._updateDiffPreferences.called);
+    });
+
+    test('expanded attribute not set on path when not expanded', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+      };
+      assert.isNotOk(element.shadowRoot
+          .querySelector('.expanded'));
+    });
+
+    test('tapping row ignores links', () => {
+      element._filesByPath = {
+        '/COMMIT_MSG': {},
+      };
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      sinon.stub(element, '_expandedFilesChanged');
+      flushAsynchronousOperations();
+      const commitMsgFile = dom(element.root)
+          .querySelectorAll('.row:not(.header-row) a.pathLink')[0];
+
+      // Remove href attribute so the app doesn't route to a diff view
+      commitMsgFile.removeAttribute('href');
+      const togglePathSpy = sinon.spy(element, '_toggleFileExpanded');
+
+      MockInteractions.tap(commitMsgFile);
+      flushAsynchronousOperations();
+      assert(togglePathSpy.notCalled, 'file is opened as diff view');
+      assert.isNotOk(element.shadowRoot
+          .querySelector('.expanded'));
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('.show-hide')).display,
+      'none');
+    });
+
+    test('_toggleFileExpanded', () => {
+      const path = 'path/to/my/file.txt';
+      element._filesByPath = {[path]: {}};
+      const renderSpy = sinon.spy(element, '_renderInOrder');
+      const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
+
+      assert.equal(element.shadowRoot
+          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
+      assert.equal(element._expandedFiles.length, 0);
+      element._toggleFileExpanded({path});
+      flushAsynchronousOperations();
+      assert.equal(collapseStub.lastCall.args[0].length, 0);
+      assert.equal(element.shadowRoot
+          .querySelector('iron-icon').icon, 'gr-icons:expand-less');
+
+      assert.equal(renderSpy.callCount, 1);
+      assert.isTrue(element._expandedFiles.some(f => f.path === path));
+      element._toggleFileExpanded({path});
+      flushAsynchronousOperations();
+
+      assert.equal(element.shadowRoot
+          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
+      assert.equal(renderSpy.callCount, 1);
+      assert.isFalse(element._expandedFiles.some(f => f.path === path));
+      assert.equal(collapseStub.lastCall.args[0].length, 1);
+    });
+
+    test('expandAllDiffs and collapseAllDiffs', () => {
+      const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
+      const cursorUpdateStub = sinon.stub(element.$.diffCursor,
+          'handleDiffUpdate');
+      const reInitStub = sinon.stub(element.$.diffCursor,
+          'reInitAndUpdateStops');
+
+      const path = 'path/to/my/file.txt';
+      element._filesByPath = {[path]: {}};
+      element.expandAllDiffs();
+      flushAsynchronousOperations();
+      assert.isTrue(element._showInlineDiffs);
+      assert.isTrue(reInitStub.calledOnce);
+      assert.equal(collapseStub.lastCall.args[0].length, 0);
+
+      element.collapseAllDiffs();
+      flushAsynchronousOperations();
+      assert.equal(element._expandedFiles.length, 0);
+      assert.isFalse(element._showInlineDiffs);
+      assert.isTrue(cursorUpdateStub.calledOnce);
+      assert.equal(collapseStub.lastCall.args[0].length, 1);
+    });
+
+    test('_expandedFilesChanged', done => {
+      sinon.stub(element, '_reviewFile');
+      const path = 'path/to/my/file.txt';
+      const diffs = [{
+        path,
+        style: {},
+        reload() {
+          done();
+        },
+        prefetchDiff() {},
+        cancel() {},
+        getCursorStops() { return []; },
+        addEventListener(eventName, callback) {
+          if (['render-start', 'render-content', 'scroll']
+              .indexOf(eventName) >= 0) {
+            callback(new Event(eventName));
+          }
+        },
+      }];
+      sinon.stub(element, 'diffs').get(() => diffs);
+      element.push('_expandedFiles', {path});
+    });
+
+    test('_clearCollapsedDiffs', () => {
+      const diff = {
+        cancel: sinon.stub(),
+        clearDiffContent: sinon.stub(),
+      };
+      element._clearCollapsedDiffs([diff]);
+      assert.isTrue(diff.cancel.calledOnce);
+      assert.isTrue(diff.clearDiffContent.calledOnce);
+    });
+
+    test('filesExpanded value updates to correct enum', () => {
+      element._filesByPath = {
+        'foo.bar': {},
+        'baz.bar': {},
+      };
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.NONE);
+      element.push('_expandedFiles', {path: 'baz.bar'});
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.SOME);
+      element.push('_expandedFiles', {path: 'foo.bar'});
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.ALL);
+      element.collapseAllDiffs();
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.NONE);
+      element.expandAllDiffs();
+      flushAsynchronousOperations();
+      assert.equal(element.filesExpanded,
+          GrFileListConstants.FilesExpandedState.ALL);
+    });
+
+    test('_renderInOrder', done => {
+      const reviewStub = sinon.stub(element, '_reviewFile');
+      let callCount = 0;
+      const diffs = [{
+        path: 'p0',
+        style: {},
+        prefetchDiff() {},
+        reload() {
+          assert.equal(callCount++, 2);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p1',
+        style: {},
+        prefetchDiff() {},
+        reload() {
+          assert.equal(callCount++, 1);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p2',
+        style: {},
+        prefetchDiff() {},
+        reload() {
+          assert.equal(callCount++, 0);
+          return Promise.resolve();
+        },
+      }];
+      element._renderInOrder([
+        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
+      ], diffs, 3)
+          .then(() => {
+            assert.isFalse(reviewStub.called);
+            assert.isTrue(loadCommentSpy.called);
+            done();
+          });
+    });
+
+    test('_renderInOrder logged in', done => {
+      element._loggedIn = true;
+      const reviewStub = sinon.stub(element, '_reviewFile');
+      let callCount = 0;
+      const diffs = [{
+        path: 'p0',
+        style: {},
+        prefetchDiff() {},
+        reload() {
+          assert.equal(reviewStub.callCount, 2);
+          assert.equal(callCount++, 2);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p1',
+        style: {},
+        prefetchDiff() {},
+        reload() {
+          assert.equal(reviewStub.callCount, 1);
+          assert.equal(callCount++, 1);
+          return Promise.resolve();
+        },
+      }, {
+        path: 'p2',
+        style: {},
+        prefetchDiff() {},
+        reload() {
+          assert.equal(reviewStub.callCount, 0);
+          assert.equal(callCount++, 0);
+          return Promise.resolve();
+        },
+      }];
+      element._renderInOrder([
+        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
+      ], diffs, 3)
+          .then(() => {
+            assert.equal(reviewStub.callCount, 3);
+            done();
+          });
+    });
+
+    test('_renderInOrder respects diffPrefs.manual_review', () => {
+      element._loggedIn = true;
+      element.diffPrefs = {manual_review: true};
+      const reviewStub = sinon.stub(element, '_reviewFile');
+      const diffs = [{
+        path: 'p',
+        style: {},
+        prefetchDiff() {},
+        reload() { return Promise.resolve(); },
+      }];
+
+      return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
+        assert.isFalse(reviewStub.called);
+        delete element.diffPrefs.manual_review;
+        return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
+          assert.isTrue(reviewStub.called);
+          assert.isTrue(reviewStub.calledWithExactly('p', true));
+        });
+      });
+    });
+
+    test('_loadingChanged fired from reload in debouncer', done => {
+      sinon.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
+      element.changeNum = 123;
+      element.patchRange = {patchNum: 12};
+      element._filesByPath = {'foo.bar': {}};
+
+      element.reload().then(() => {
+        assert.isFalse(element._loading);
+        element.flushDebouncer('loading-change');
+        assert.isFalse(element.classList.contains('loading'));
+        done();
+      });
+      assert.isTrue(element._loading);
+      assert.isFalse(element.classList.contains('loading'));
+      element.flushDebouncer('loading-change');
+      assert.isTrue(element.classList.contains('loading'));
+    });
+
+    test('_loadingChanged does not set class when there are no files', () => {
+      sinon.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
+      element.changeNum = 123;
+      element.patchRange = {patchNum: 12};
+      element.reload();
+      assert.isTrue(element._loading);
+      element.flushDebouncer('loading-change');
+      assert.isFalse(element.classList.contains('loading'));
+    });
+  });
+
+  suite('diff url file list', () => {
+    test('diff url', () => {
+      const diffStub = sinon.stub(GerritNav, 'getUrlForDiff')
+          .returns('/c/gerrit/+/1/1/index.php');
+      const change = {
+        _number: 1,
+        project: 'gerrit',
+      };
+      const path = 'index.php';
+      const patchRange = {
+        patchNum: 1,
+      };
+      assert.equal(
+          element._computeDiffURL(change, patchRange, path, false),
+          '/c/gerrit/+/1/1/index.php');
+      diffStub.restore();
+    });
+
+    test('diff url commit msg', () => {
+      const diffStub = sinon.stub(GerritNav, 'getUrlForDiff')
+          .returns('/c/gerrit/+/1/1//COMMIT_MSG');
+      const change = {
+        _number: 1,
+        project: 'gerrit',
+      };
+      const path = '/COMMIT_MSG';
+      const patchRange = {
+        patchNum: 1,
+      };
+      assert.equal(
+          element._computeDiffURL(change, patchRange, path, false),
+          '/c/gerrit/+/1/1//COMMIT_MSG');
+      diffStub.restore();
+    });
+
+    test('edit url', () => {
+      const editStub = sinon.stub(GerritNav, 'getEditUrlForDiff')
+          .returns('/c/gerrit/+/1/edit/index.php,edit');
+      const change = {
+        _number: 1,
+        project: 'gerrit',
+      };
+      const path = 'index.php';
+      const patchRange = {
+        patchNum: 1,
+      };
+      assert.equal(
+          element._computeDiffURL(change, patchRange, path, true),
+          '/c/gerrit/+/1/edit/index.php,edit');
+      editStub.restore();
+    });
+
+    test('edit url commit msg', () => {
+      const editStub = sinon.stub(GerritNav, 'getEditUrlForDiff')
+          .returns('/c/gerrit/+/1/edit//COMMIT_MSG,edit');
+      const change = {
+        _number: 1,
+        project: 'gerrit',
+      };
+      const path = '/COMMIT_MSG';
+      const patchRange = {
+        patchNum: 1,
+      };
+      assert.equal(
+          element._computeDiffURL(change, patchRange, path, true),
+          '/c/gerrit/+/1/edit//COMMIT_MSG,edit');
+      editStub.restore();
+    });
+  });
+
+  suite('size bars', () => {
+    test('_computeSizeBarLayout', () => {
+      assert.isUndefined(element._computeSizeBarLayout(null));
+      assert.isUndefined(element._computeSizeBarLayout({}));
+      assert.deepEqual(element._computeSizeBarLayout({base: []}), {
+        maxInserted: 0,
+        maxDeleted: 0,
+        maxAdditionWidth: 0,
+        maxDeletionWidth: 0,
+        deletionOffset: 0,
+      });
+
+      const files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 10000},
+        {__path: 'foo', lines_inserted: 4, lines_deleted: 10},
+        {__path: 'bar', lines_inserted: 5, lines_deleted: 8},
+      ];
+      const layout = element._computeSizeBarLayout({base: files});
+      assert.equal(layout.maxInserted, 5);
+      assert.equal(layout.maxDeleted, 10);
+    });
+
+    test('_computeBarAdditionWidth', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 5,
+        lines_deleted: 0,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 0,
+        maxAdditionWidth: 60,
+        maxDeletionWidth: 0,
+        deletionOffset: 60,
+      };
+
+      // Uses half the space when file is half the largest addition and there
+      // are no deletions.
+      assert.equal(element._computeBarAdditionWidth(file, stats), 30);
+
+      // If there are no insetions, there is no width.
+      stats.maxInserted = 0;
+      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+
+      // If the insertions is not present on the file, there is no width.
+      stats.maxInserted = 10;
+      file.lines_inserted = undefined;
+      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+
+      // If the file is a commit message, returns zero.
+      file.lines_inserted = 5;
+      file.__path = '/COMMIT_MSG';
+      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
+
+      // Width bottoms-out at the minimum width.
+      file.__path = 'stuff.txt';
+      file.lines_inserted = 1;
+      stats.maxInserted = 1000000;
+      assert.equal(element._computeBarAdditionWidth(file, stats), 1.5);
+    });
+
+    test('_computeBarAdditionX', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 5,
+        lines_deleted: 0,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 0,
+        maxAdditionWidth: 60,
+        maxDeletionWidth: 0,
+        deletionOffset: 60,
+      };
+      assert.equal(element._computeBarAdditionX(file, stats), 30);
+    });
+
+    test('_computeBarDeletionWidth', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 0,
+        lines_deleted: 5,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 10,
+        maxAdditionWidth: 30,
+        maxDeletionWidth: 30,
+        deletionOffset: 31,
+      };
+
+      // Uses a quarter the space when file is half the largest deletions and
+      // there are equal additions.
+      assert.equal(element._computeBarDeletionWidth(file, stats), 15);
+
+      // If there are no deletions, there is no width.
+      stats.maxDeleted = 0;
+      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+
+      // If the deletions is not present on the file, there is no width.
+      stats.maxDeleted = 10;
+      file.lines_deleted = undefined;
+      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+
+      // If the file is a commit message, returns zero.
+      file.lines_deleted = 5;
+      file.__path = '/COMMIT_MSG';
+      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
+
+      // Width bottoms-out at the minimum width.
+      file.__path = 'stuff.txt';
+      file.lines_deleted = 1;
+      stats.maxDeleted = 1000000;
+      assert.equal(element._computeBarDeletionWidth(file, stats), 1.5);
+    });
+
+    test('_computeSizeBarsClass', () => {
+      assert.equal(element._computeSizeBarsClass(false, 'foo/bar.baz'),
+          'sizeBars desktop hide');
+      assert.equal(element._computeSizeBarsClass(true, '/COMMIT_MSG'),
+          'sizeBars desktop invisible');
+      assert.equal(element._computeSizeBarsClass(true, 'foo/bar.baz'),
+          'sizeBars desktop ');
+    });
+  });
+
+  suite('gr-file-list inline diff tests', () => {
+    let element;
+
+    const commitMsgComments = [
+      {
+        patch_set: 2,
+        id: 'ecf0b9fa_fe1a5f62',
+        line: 20,
+        updated: '2018-02-08 18:49:18.000000000',
+        message: 'another comment',
+        unresolved: true,
+      },
+      {
+        patch_set: 2,
+        id: '503008e2_0ab203ee',
+        line: 10,
+        updated: '2018-02-14 22:07:43.000000000',
+        message: 'a comment',
+        unresolved: true,
+      },
+      {
+        patch_set: 2,
+        id: 'cc788d2c_cb1d728c',
+        line: 20,
+        in_reply_to: 'ecf0b9fa_fe1a5f62',
+        updated: '2018-02-13 22:07:43.000000000',
+        message: 'response',
+        unresolved: true,
+      },
+    ];
+
+    const setupDiff = function(diff) {
+      diff.comments = {
+        left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
+        right: [],
+        meta: {
+          changeNum: 1,
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 2,
+          },
+        },
+      };
+      diff.prefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        intraline_difference: true,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        auto_hide_diff_table_header: true,
+        theme: 'DEFAULT',
+        ignore_whitespace: 'IGNORE_NONE',
+      };
+      diff.diff = getMockDiffResponse();
+      diff.$.diff.flushDebouncer('renderDiffTable');
+    };
+
+    const renderAndGetNewDiffs = function(index) {
+      const diffs =
+          dom(element.root).querySelectorAll('gr-diff-host');
+
+      for (let i = index; i < diffs.length; i++) {
+        setupDiff(diffs[i]);
+      }
+
+      element._updateDiffCursor();
+      element.$.diffCursor.handleDiffUpdate();
+      return diffs;
+    };
+
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+      });
+      stub('gr-date-formatter', {
+        _loadTimeFormat() { return Promise.resolve(''); },
+      });
+      stub('gr-diff-host', {
+        reload() { return Promise.resolve(); },
+        prefetchDiff() {},
+      });
+
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = basicFixture.instantiate();
+      element = commentApiWrapper.$.fileList;
+      loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+      element.diffPrefs = {};
+      sinon.stub(element, '_reviewFile');
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      commentApiWrapper.loadComments().then(() => {
+        sinon.stub(element.changeComments, 'getPaths').returns({});
+        sinon.stub(element.changeComments, 'getCommentsBySideForPath')
+            .returns({meta: {}, left: [], right: []});
+        done();
+      });
+      element._loading = false;
+      element.numFilesShown = 75;
+      element.selectedIndex = 0;
+      element._filesByPath = {
+        '/COMMIT_MSG': {lines_inserted: 9},
+        'file_added_in_rev2.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        'myfile.txt': {
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      };
+      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element._loggedIn = true;
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      sinon.stub(window, 'fetch').callsFake(() => Promise.resolve());
+      flushAsynchronousOperations();
+    });
+
+    test('cursor with individually opened files', () => {
+      MockInteractions.keyUpOn(element, 73, null, 'i');
+      flushAsynchronousOperations();
+      let diffs = renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 1);
+
+      // No line number is selected.
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(dom(
+          diffStops[10]).querySelectorAll('.contentText')[0]);
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+      assert.isFalse(diffStops[11].classList.contains('target-row'));
+
+      // The file cursor is now at 1.
+      assert.equal(element.$.fileCursor.index, 1);
+      MockInteractions.keyUpOn(element, 73, null, 'i');
+      flushAsynchronousOperations();
+
+      diffs = renderAndGetNewDiffs(1);
+      // Two diffs should be rendered.
+      assert.equal(diffs.length, 2);
+      const diffStopsFirst = diffs[0].getCursorStops();
+      const diffStopsSecond = diffs[1].getCursorStops();
+
+      // The line on the first diff is still selected
+      assert.isTrue(diffStopsFirst[10].classList.contains('target-row'));
+      assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
+    });
+
+    test('cursor with toggle all files', () => {
+      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+      flushAsynchronousOperations();
+
+      const diffs = renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 3);
+
+      // No line number is selected.
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(dom(
+          diffStops[10]).querySelectorAll('.contentText')[0]);
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      flushAsynchronousOperations();
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+      assert.isTrue(diffStops[11].classList.contains('target-row'));
+
+      // The file cursor is still at 0.
+      assert.equal(element.$.fileCursor.index, 0);
+    });
+
+    suite('n key presses', () => {
+      let nKeySpy;
+      let nextCommentStub;
+      let nextChunkStub;
+      let fileRows;
+
+      setup(() => {
+        sinon.stub(element, '_renderInOrder').returns(Promise.resolve());
+        nKeySpy = sinon.spy(element, '_handleNextChunk');
+        nextCommentStub = sinon.stub(element.$.diffCursor,
+            'moveToNextCommentThread');
+        nextChunkStub = sinon.stub(element.$.diffCursor,
+            'moveToNextChunk');
+        fileRows =
+            dom(element.root).querySelectorAll('.row:not(.header-row)');
+      });
+
+      test('n key with some files expanded and no shift key', () => {
+        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
+        flushAsynchronousOperations();
+
+        // Handle N key should return before calling diff cursor functions.
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.equal(element.filesExpanded, 'some');
+      });
+
+      test('n key with some files expanded and shift key', () => {
+        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
+        flushAsynchronousOperations();
+        assert.equal(nextChunkStub.callCount, 0);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isTrue(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 0);
+        assert.equal(element.filesExpanded, 'some');
+      });
+
+      test('n key without all files expanded and shift key', () => {
+        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.isTrue(element._showInlineDiffs);
+      });
+
+      test('n key without all files expanded and no shift key', () => {
+        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isTrue(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 0);
+        assert.isTrue(element._showInlineDiffs);
+      });
+    });
+
+    test('_openSelectedFile behavior', () => {
+      const _filesByPath = element._filesByPath;
+      element.set('_filesByPath', {});
+      const navStub = sinon.stub(GerritNav, 'navigateToDiff');
+      // Noop when there are no files.
+      element._openSelectedFile();
+      assert.isFalse(navStub.called);
+
+      element.set('_filesByPath', _filesByPath);
+      flushAsynchronousOperations();
+      // Navigates when a file is selected.
+      element._openSelectedFile();
+      assert.isTrue(navStub.called);
+    });
+
+    test('_displayLine', () => {
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut')
+          .callsFake(() => false);
+      sinon.stub(element, 'modifierPressed')
+          .callsFake(() => false);
+      element._showInlineDiffs = true;
+      const mockEvent = {preventDefault() {}};
+
+      element._displayLine = false;
+      element._handleCursorNext(mockEvent);
+      assert.isTrue(element._displayLine);
+
+      element._displayLine = false;
+      element._handleCursorPrev(mockEvent);
+      assert.isTrue(element._displayLine);
+
+      element._displayLine = true;
+      element._handleEscKey(mockEvent);
+      assert.isFalse(element._displayLine);
+    });
+
+    suite('editMode behavior', () => {
+      test('reviewed checkbox', () => {
+        element._reviewFile.restore();
+        const saveReviewStub = sinon.stub(element, '_saveReviewedState');
+
+        element.editMode = false;
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.isTrue(saveReviewStub.calledOnce);
+
+        element.editMode = true;
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.isTrue(saveReviewStub.calledOnce);
+      });
+
+      test('_getReviewedFiles does not call API', () => {
+        const apiSpy = sinon.spy(element.$.restAPI, 'getReviewedFiles');
+        element.editMode = true;
+        return element._getReviewedFiles().then(files => {
+          assert.equal(files.length, 0);
+          assert.isFalse(apiSpy.called);
+        });
+      });
+    });
+
+    test('editing actions', () => {
+      // Edit controls are guarded behind a dom-if initially and not rendered.
+      assert.isNotOk(dom(element.root)
+          .querySelector('gr-edit-file-controls'));
+
+      element.editMode = true;
+      flushAsynchronousOperations();
+
+      // Commit message should not have edit controls.
+      const editControls =
+          Array.from(
+              dom(element.root)
+                  .querySelectorAll('.row:not(.header-row)'))
+              .map(row => row.querySelector('gr-edit-file-controls'));
+      assert.isTrue(editControls[0].classList.contains('invisible'));
+    });
+
+    test('reloadCommentsForThreadWithRootId', () => {
+      // Expand the commit message diff
+      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+      const diffs = renderAndGetNewDiffs(0);
+      flushAsynchronousOperations();
+
+      // Two comment threads should be generated by renderAndGetNewDiffs
+      const threadEls = diffs[0].getThreadEls();
+      assert.equal(threadEls.length, 2);
+      const threadElsByRootId = new Map(
+          threadEls.map(threadEl => [threadEl.rootId, threadEl]));
+
+      const thread1 = threadElsByRootId.get('503008e2_0ab203ee');
+      assert.equal(thread1.comments.length, 1);
+      assert.equal(thread1.comments[0].message, 'a comment');
+      assert.equal(thread1.comments[0].line, 10);
+
+      const thread2 = threadElsByRootId.get('ecf0b9fa_fe1a5f62');
+      assert.equal(thread2.comments.length, 2);
+      assert.isTrue(thread2.comments[0].unresolved);
+      assert.equal(thread2.comments[0].message, 'another comment');
+      assert.equal(thread2.comments[0].line, 20);
+
+      const commentStub =
+          sinon.stub(element.changeComments, 'getCommentsForThread');
+      const commentStubRes1 = [
+        {
+          patch_set: 2,
+          id: '503008e2_0ab203ee',
+          line: 20,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'edited text',
+          unresolved: false,
+        },
+      ];
+      const commentStubRes2 = [
+        {
+          patch_set: 2,
+          id: 'ecf0b9fa_fe1a5f62',
+          line: 20,
+          updated: '2018-02-08 18:49:18.000000000',
+          message: 'another comment',
+          unresolved: true,
+        },
+        {
+          patch_set: 2,
+          id: '503008e2_0ab203ee',
+          line: 10,
+          in_reply_to: 'ecf0b9fa_fe1a5f62',
+          updated: '2018-02-14 22:07:43.000000000',
+          message: 'response',
+          unresolved: true,
+        },
+        {
+          patch_set: 2,
+          id: '503008e2_0ab203ef',
+          line: 20,
+          in_reply_to: '503008e2_0ab203ee',
+          updated: '2018-02-15 22:07:43.000000000',
+          message: 'a third comment in the thread',
+          unresolved: true,
+        },
+      ];
+      commentStub.withArgs('503008e2_0ab203ee').returns(
+          commentStubRes1);
+      commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
+          commentStubRes2);
+
+      // Reload comments from the first comment thread, which should have a
+      // an updated message and a toggled resolve state.
+      element.reloadCommentsForThreadWithRootId('503008e2_0ab203ee',
+          '/COMMIT_MSG');
+      assert.equal(thread1.comments.length, 1);
+      assert.isFalse(thread1.comments[0].unresolved);
+      assert.equal(thread1.comments[0].message, 'edited text');
+
+      // Reload comments from the second comment thread, which should have a new
+      // reply.
+      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
+          '/COMMIT_MSG');
+      assert.equal(thread2.comments.length, 3);
+
+      const commentStubCount = commentStub.callCount;
+      const getThreadsSpy = sinon.spy(diffs[0], 'getThreadEls');
+
+      // Should not be getting threads when the file is not expanded.
+      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
+          'other/file');
+      assert.isFalse(getThreadsSpy.called);
+      assert.equal(commentStubCount, commentStub.callCount);
+
+      // Should be query selecting diffs when the file is expanded.
+      // Should not be fetching change comments when the rootId is not found
+      // to match.
+      element.reloadCommentsForThreadWithRootId('acf0b9fa_fe1a5f62',
+          '/COMMIT_MSG');
+      assert.isTrue(getThreadsSpy.called);
+      assert.equal(commentStubCount, commentStub.callCount);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
index bd262ec..c42c734 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-button/gr-button.js';
@@ -26,7 +24,7 @@
 import {htmlTemplate} from './gr-included-in-dialog_html.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrIncludedInDialog extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
deleted file mode 100644
index 2faac52..0000000
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.js
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      background-color: var(--dialog-background-color);
-      display: block;
-      max-height: 80vh;
-      overflow-y: auto;
-      padding: 4.5em var(--spacing-l) var(--spacing-l) var(--spacing-l);
-    }
-    header {
-      background-color: var(--dialog-background-color);
-      border-bottom: 1px solid var(--border-color);
-      left: 0;
-      padding: var(--spacing-l);
-      position: absolute;
-      right: 0;
-      top: 0;
-    }
-    #title {
-      display: inline-block;
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-      margin-top: var(--spacing-xs);
-    }
-    #filterInput {
-      display: inline-block;
-      float: right;
-      margin: 0 var(--spacing-l);
-      padding: var(--spacing-xs);
-    }
-    .closeButtonContainer {
-      float: right;
-    }
-    ul {
-      margin-bottom: var(--spacing-l);
-    }
-    ul li {
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      background: var(--chip-background-color);
-      display: inline-block;
-      margin: 0 var(--spacing-xs) var(--spacing-s) var(--spacing-xs);
-      padding: var(--spacing-xs) var(--spacing-s);
-    }
-    .loading.loaded {
-      display: none;
-    }
-  </style>
-  <header>
-    <h1 id="title">Included In:</h1>
-    <span class="closeButtonContainer">
-      <gr-button id="closeButton" link="" on-click="_handleCloseTap"
-        >Close</gr-button
-      >
-    </span>
-    <iron-input
-      id="filterInput"
-      placeholder="Filter"
-      bind-value="{{_filterText}}"
-    >
-      <input
-        is="iron-input"
-        placeholder="Filter"
-        bind-value="{{_filterText}}"
-      />
-    </iron-input>
-  </header>
-  <div class$="[[_computeLoadingClass(_loaded)]]">Loading...</div>
-  <template
-    is="dom-repeat"
-    items="[[_computeGroups(_includedIn, _filterText)]]"
-    as="group"
-  >
-    <div>
-      <span>[[group.title]]:</span>
-      <ul>
-        <template is="dom-repeat" items="[[group.items]]">
-          <li>[[item]]</li>
-        </template>
-      </ul>
-    </div>
-  </template>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts
new file mode 100644
index 0000000..8e90f3b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts
@@ -0,0 +1,104 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--dialog-background-color);
+      display: block;
+      max-height: 80vh;
+      overflow-y: auto;
+      padding: 4.5em var(--spacing-l) var(--spacing-l) var(--spacing-l);
+    }
+    header {
+      background-color: var(--dialog-background-color);
+      border-bottom: 1px solid var(--border-color);
+      left: 0;
+      padding: var(--spacing-l);
+      position: absolute;
+      right: 0;
+      top: 0;
+    }
+    #title {
+      display: inline-block;
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h3);
+      font-weight: var(--font-weight-h3);
+      line-height: var(--line-height-h3);
+      margin-top: var(--spacing-xs);
+    }
+    #filterInput {
+      display: inline-block;
+      float: right;
+      margin: 0 var(--spacing-l);
+      padding: var(--spacing-xs);
+    }
+    .closeButtonContainer {
+      float: right;
+    }
+    ul {
+      margin-bottom: var(--spacing-l);
+    }
+    ul li {
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      background: var(--chip-background-color);
+      display: inline-block;
+      margin: 0 var(--spacing-xs) var(--spacing-s) var(--spacing-xs);
+      padding: var(--spacing-xs) var(--spacing-s);
+    }
+    .loading.loaded {
+      display: none;
+    }
+  </style>
+  <header>
+    <h1 id="title" class="heading-1">Included In:</h1>
+    <span class="closeButtonContainer">
+      <gr-button id="closeButton" link="" on-click="_handleCloseTap"
+        >Close</gr-button
+      >
+    </span>
+    <iron-input
+      id="filterInput"
+      placeholder="Filter"
+      bind-value="{{_filterText}}"
+    >
+      <input
+        is="iron-input"
+        placeholder="Filter"
+        bind-value="{{_filterText}}"
+      />
+    </iron-input>
+  </header>
+  <div class$="[[_computeLoadingClass(_loaded)]]">Loading...</div>
+  <template
+    is="dom-repeat"
+    items="[[_computeGroups(_includedIn, _filterText)]]"
+    as="group"
+  >
+    <div>
+      <span>[[group.title]]:</span>
+      <ul>
+        <template is="dom-repeat" items="[[group.items]]">
+          <li>[[item]]</li>
+        </template>
+      </ul>
+    </div>
+  </template>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
deleted file mode 100644
index 5d5b1fc..0000000
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
+++ /dev/null
@@ -1,100 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-included-in-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-included-in-dialog></gr-included-in-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-included-in-dialog.js';
-suite('gr-included-in-dialog', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('_computeGroups', () => {
-    const includedIn = {branches: [], tags: []};
-    let filterText = '';
-    assert.deepEqual(element._computeGroups(includedIn, filterText), []);
-
-    includedIn.branches.push('master', 'development', 'stable-2.0');
-    includedIn.tags.push('v1.9', 'v2.0', 'v2.1');
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
-      {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
-      {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
-    ]);
-
-    includedIn.external = {};
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
-      {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
-      {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
-    ]);
-
-    includedIn.external.foo = ['abc', 'def', 'ghi'];
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
-      {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
-      {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
-      {title: 'foo', items: ['abc', 'def', 'ghi']},
-    ]);
-
-    filterText = 'v2';
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
-      {title: 'Tags', items: ['v2.0', 'v2.1']},
-    ]);
-
-    // Filtering is case-insensitive.
-    filterText = 'V2';
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
-      {title: 'Tags', items: ['v2.0', 'v2.1']},
-    ]);
-  });
-
-  test('_computeGroups with .bindValue', done => {
-    element.$.filterInput.bindValue = 'stable-3.2';
-    const includedIn = {branches: [], tags: []};
-    includedIn.branches.push('master', 'stable-3.2');
-
-    setTimeout(() => {
-      const filterText = element._filterText;
-      assert.deepEqual(element._computeGroups(includedIn, filterText), [
-        {title: 'Branches', items: ['stable-3.2']},
-      ]);
-
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.js
new file mode 100644
index 0000000..c109538
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.js
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-included-in-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-included-in-dialog');
+
+suite('gr-included-in-dialog', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_computeGroups', () => {
+    const includedIn = {branches: [], tags: []};
+    let filterText = '';
+    assert.deepEqual(element._computeGroups(includedIn, filterText), []);
+
+    includedIn.branches.push('master', 'development', 'stable-2.0');
+    includedIn.tags.push('v1.9', 'v2.0', 'v2.1');
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
+      {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
+    ]);
+
+    includedIn.external = {};
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
+      {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
+    ]);
+
+    includedIn.external.foo = ['abc', 'def', 'ghi'];
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
+      {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
+      {title: 'foo', items: ['abc', 'def', 'ghi']},
+    ]);
+
+    filterText = 'v2';
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Tags', items: ['v2.0', 'v2.1']},
+    ]);
+
+    // Filtering is case-insensitive.
+    filterText = 'V2';
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Tags', items: ['v2.0', 'v2.1']},
+    ]);
+  });
+
+  test('_computeGroups with .bindValue', done => {
+    element.$.filterInput.bindValue = 'stable-3.2';
+    const includedIn = {branches: [], tags: []};
+    includedIn.branches.push('master', 'stable-3.2');
+
+    setTimeout(() => {
+      const filterText = element._filterText;
+      assert.deepEqual(element._computeGroups(includedIn, filterText), [
+        {title: 'Branches', items: ['stable-3.2']},
+      ]);
+
+      done();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
index 8541840..fe88e4e 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-selector/iron-selector.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../../styles/gr-voting-styles.js';
@@ -25,7 +23,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-label-score-row_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrLabelScoreRow extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -130,7 +128,7 @@
   }
 
   _computeLabelValue(labels, permittedLabels, label) {
-    if ([labels, permittedLabels, label].some(arg => arg === undefined)) {
+    if ([labels, permittedLabels, label].includes(undefined)) {
       return null;
     }
     if (!labels[label.name]) { return null; }
@@ -150,6 +148,13 @@
     // Needed because when the selected item changes, it first changes to
     // nothing and then to the new item.
     if (!e.target.selectedItem) { return; }
+    for (const item of this.$.labelSelector.items) {
+      if (e.target.selectedItem === item) {
+        item.setAttribute('aria-checked', 'true');
+      } else {
+        item.removeAttribute('aria-checked');
+      }
+    }
     this._selectedValueText = e.target.selectedItem.getAttribute('title');
     // Needed to update the style of the selected button.
     this.updateStyles();
@@ -172,7 +177,7 @@
 
   _computePermittedLabelValues(permittedLabels, label) {
     // Polymer 2: check for undefined
-    if ([permittedLabels, label].some(arg => arg === undefined)) {
+    if ([permittedLabels, label].includes(undefined)) {
       return undefined;
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js
deleted file mode 100644
index 77148ad..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.js
+++ /dev/null
@@ -1,144 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="gr-voting-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    .labelNameCell,
-    .buttonsCell,
-    .selectedValueCell {
-      padding: var(--spacing-s) var(--spacing-m);
-      display: table-cell;
-    }
-    /* We want the :hover highlight to extend to the border of the dialog. */
-    .labelNameCell {
-      padding-left: var(--spacing-xl);
-    }
-    .selectedValueCell {
-      padding-right: var(--spacing-xl);
-    }
-    /* This is a trick to let the selectedValueCell take the remaining width. */
-    .labelNameCell,
-    .buttonsCell {
-      white-space: nowrap;
-    }
-    .selectedValueCell {
-      width: 75%;
-    }
-    .labelMessage {
-      color: var(--deemphasized-text-color);
-    }
-    gr-button {
-      min-width: 42px;
-      box-sizing: border-box;
-      --gr-button: {
-        background-color: var(
-          --button-background-color,
-          var(--table-header-background-color)
-        );
-        color: var(--primary-text-color);
-        padding: 0 var(--spacing-m);
-        @apply --vote-chip-styles;
-      }
-    }
-    gr-button.iron-selected[vote='max'] {
-      --button-background-color: var(--vote-color-approved);
-    }
-    gr-button.iron-selected[vote='positive'] {
-      --button-background-color: var(--vote-color-recommended);
-    }
-    gr-button.iron-selected[vote='min'] {
-      --button-background-color: var(--vote-color-rejected);
-    }
-    gr-button.iron-selected[vote='negative'] {
-      --button-background-color: var(--vote-color-disliked);
-    }
-    gr-button.iron-selected[vote='neutral'] {
-      --button-background-color: var(--vote-color-neutral);
-    }
-    .placeholder {
-      display: inline-block;
-      width: 42px;
-      height: 1px;
-    }
-    .placeholder::before {
-      content: ' ';
-    }
-    .selectedValueCell {
-      color: var(--deemphasized-text-color);
-      font-style: italic;
-    }
-    .selectedValueCell.hidden {
-      display: none;
-    }
-    @media only screen and (max-width: 50em) {
-      .selectedValueCell {
-        display: none;
-      }
-    }
-  </style>
-  <span class="labelNameCell">[[label.name]]</span>
-  <div class="buttonsCell">
-    <template
-      is="dom-repeat"
-      items="[[_computeBlankItems(permittedLabels, label.name, 'start')]]"
-      as="value"
-    >
-      <span class="placeholder" data-label$="[[label.name]]"></span>
-    </template>
-    <iron-selector
-      id="labelSelector"
-      attr-for-selected="data-value"
-      selected="[[_computeLabelValue(labels, permittedLabels, label)]]"
-      hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
-      on-selected-item-changed="_setSelectedValueText"
-    >
-      <template is="dom-repeat" items="[[_items]]" as="value">
-        <gr-button
-          vote$="[[_computeVoteAttribute(value, index, _items.length)]]"
-          has-tooltip=""
-          data-name$="[[label.name]]"
-          data-value$="[[value]]"
-          title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
-        >
-          [[value]]</gr-button
-        >
-      </template>
-    </iron-selector>
-    <template
-      is="dom-repeat"
-      items="[[_computeBlankItems(permittedLabels, label.name, 'end')]]"
-      as="value"
-    >
-      <span class="placeholder" data-label$="[[label.name]]"></span>
-    </template>
-    <span
-      class="labelMessage"
-      hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
-    >
-      You don't have permission to edit this label.
-    </span>
-  </div>
-  <div
-    class$="selectedValueCell [[_computeHiddenClass(permittedLabels, label.name)]]"
-  >
-    <span id="selectedValueLabel">[[_selectedValueText]]</span>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
new file mode 100644
index 0000000..312532e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
@@ -0,0 +1,148 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="gr-voting-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="shared-styles">
+    .labelNameCell,
+    .buttonsCell,
+    .selectedValueCell {
+      padding: var(--spacing-s) var(--spacing-m);
+      display: table-cell;
+    }
+    /* We want the :hover highlight to extend to the border of the dialog. */
+    .labelNameCell {
+      padding-left: var(--spacing-xl);
+    }
+    .selectedValueCell {
+      padding-right: var(--spacing-xl);
+    }
+    /* This is a trick to let the selectedValueCell take the remaining width. */
+    .labelNameCell,
+    .buttonsCell {
+      white-space: nowrap;
+    }
+    .selectedValueCell {
+      width: 75%;
+    }
+    .labelMessage {
+      color: var(--deemphasized-text-color);
+    }
+    gr-button {
+      min-width: 42px;
+      box-sizing: border-box;
+      --gr-button: {
+        background-color: var(
+          --button-background-color,
+          var(--table-header-background-color)
+        );
+        padding: 0 var(--spacing-m);
+        @apply --vote-chip-styles;
+      }
+    }
+    gr-button.iron-selected[vote='max'] {
+      --button-background-color: var(--vote-color-approved);
+    }
+    gr-button.iron-selected[vote='positive'] {
+      --button-background-color: var(--vote-color-recommended);
+    }
+    gr-button.iron-selected[vote='min'] {
+      --button-background-color: var(--vote-color-rejected);
+    }
+    gr-button.iron-selected[vote='negative'] {
+      --button-background-color: var(--vote-color-disliked);
+    }
+    gr-button.iron-selected[vote='neutral'] {
+      --button-background-color: var(--vote-color-neutral);
+    }
+    .placeholder {
+      display: inline-block;
+      width: 42px;
+      height: 1px;
+    }
+    .placeholder::before {
+      content: ' ';
+    }
+    .selectedValueCell {
+      color: var(--deemphasized-text-color);
+      font-style: italic;
+    }
+    .selectedValueCell.hidden {
+      display: none;
+    }
+    @media only screen and (max-width: 50em) {
+      .selectedValueCell {
+        display: none;
+      }
+    }
+  </style>
+  <span class="labelNameCell" id="labelName" aria-hidden="true"
+    >[[label.name]]</span
+  >
+  <div class="buttonsCell">
+    <template
+      is="dom-repeat"
+      items="[[_computeBlankItems(permittedLabels, label.name, 'start')]]"
+      as="value"
+    >
+      <span class="placeholder" data-label$="[[label.name]]"></span>
+    </template>
+    <iron-selector
+      id="labelSelector"
+      attr-for-selected="data-value"
+      selected="[[_computeLabelValue(labels, permittedLabels, label)]]"
+      hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
+      on-selected-item-changed="_setSelectedValueText"
+      role="radiogroup"
+      aria-labelledby="labelName"
+    >
+      <template is="dom-repeat" items="[[_items]]" as="value">
+        <gr-button
+          role="radio"
+          vote$="[[_computeVoteAttribute(value, index, _items.length)]]"
+          has-tooltip=""
+          data-name$="[[label.name]]"
+          data-value$="[[value]]"
+          title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
+        >
+          [[value]]</gr-button
+        >
+      </template>
+    </iron-selector>
+    <template
+      is="dom-repeat"
+      items="[[_computeBlankItems(permittedLabels, label.name, 'end')]]"
+      as="value"
+    >
+      <span class="placeholder" data-label$="[[label.name]]"></span>
+    </template>
+    <span
+      class="labelMessage"
+      hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
+    >
+      You don't have permission to edit this label.
+    </span>
+  </div>
+  <div
+    class$="selectedValueCell [[_computeHiddenClass(permittedLabels, label.name)]]"
+  >
+    <span id="selectedValueLabel">[[_selectedValueText]]</span>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
deleted file mode 100644
index 6e0a90d..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
+++ /dev/null
@@ -1,372 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-label-score-row</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-label-score-row></gr-label-score-row>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-label-score-row.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-label-row-score tests', () => {
-  let element;
-  let sandbox;
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    element.labels = {
-      'Code-Review': {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: 0,
-        value: 1,
-        all: [{
-          _account_id: 123,
-          value: 1,
-        }],
-      },
-      'Verified': {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: 0,
-        value: 1,
-        all: [{
-          _account_id: 123,
-          value: 1,
-        }],
-      },
-    };
-
-    element.permittedLabels = {
-      'Code-Review': [
-        '-2',
-        '-1',
-        ' 0',
-        '+1',
-        '+2',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-
-    element.labelValues = {'0': 2, '1': 3, '2': 4, '-2': 0, '-1': 1};
-
-    element.label = {
-      name: 'Verified',
-      value: '+1',
-    };
-
-    flush(done);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('label picker', () => {
-    const labelsChangedHandler = sandbox.stub();
-    element.addEventListener('labels-changed', labelsChangedHandler);
-    assert.ok(element.$.labelSelector);
-    MockInteractions.tap(element.shadowRoot
-        .querySelector(
-            'gr-button[data-value="-1"]'));
-    flushAsynchronousOperations();
-    assert.strictEqual(element.selectedValue, '-1');
-    assert.strictEqual(element.selectedItem
-        .textContent.trim(), '-1');
-    assert.strictEqual(
-        element.$.selectedValueLabel.textContent.trim(), 'bad');
-    const detail = labelsChangedHandler.args[0][0].detail;
-    assert.equal(detail.name, 'Verified');
-    assert.equal(detail.value, '-1');
-  });
-
-  test('_computeVoteAttribute', () => {
-    let value = 1;
-    let index = 0;
-    const totalItems = 5;
-    // positive and first position
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'positive');
-    // negative and first position
-    value = -1;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'min');
-    // negative but not first position
-    index = 1;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'negative');
-    // neutral
-    value = 0;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'neutral');
-    // positive but not last position
-    value = 1;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'positive');
-    // positive and last position
-    index = 4;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'max');
-    // negative and last position
-    value = -1;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'negative');
-  });
-
-  test('correct item is selected', () => {
-    // 1 should be the value of the selected item
-    assert.strictEqual(element.$.labelSelector.selected, '+1');
-    assert.strictEqual(
-        element.$.labelSelector.selectedItem
-            .textContent.trim(), '+1');
-    assert.strictEqual(
-        element.$.selectedValueLabel.textContent.trim(), 'good');
-  });
-
-  test('do not display tooltips on touch devices', () => {
-    const verifiedBtn = element.shadowRoot
-        .querySelector(
-            'iron-selector > gr-button[data-value="-1"]');
-
-    // On touch devices, tooltips should not be shown.
-    verifiedBtn._isTouchDevice = true;
-    verifiedBtn._handleShowTooltip();
-    assert.isNotOk(verifiedBtn._tooltip);
-    verifiedBtn._handleHideTooltip();
-    assert.isNotOk(verifiedBtn._tooltip);
-
-    // On other devices, tooltips should be shown.
-    verifiedBtn._isTouchDevice = false;
-    verifiedBtn._handleShowTooltip();
-    assert.isOk(verifiedBtn._tooltip);
-    verifiedBtn._handleHideTooltip();
-    assert.isNotOk(verifiedBtn._tooltip);
-  });
-
-  test('_computeLabelValue', () => {
-    assert.strictEqual(element._computeLabelValue(element.labels,
-        element.permittedLabels,
-        element.label), '+1');
-  });
-
-  test('_computeBlankItems', () => {
-    element.labelValues = {
-      '-2': 0,
-      '-1': 1,
-      '0': 2,
-      '1': 3,
-      '2': 4,
-    };
-
-    assert.strictEqual(element._computeBlankItems(element.permittedLabels,
-        'Code-Review').length, 0);
-
-    assert.strictEqual(element._computeBlankItems(element.permittedLabels,
-        'Verified').length, 1);
-  });
-
-  test('labelValues returns no keys', () => {
-    element.labelValues = {};
-
-    assert.deepEqual(element._computeBlankItems(element.permittedLabels,
-        'Code-Review'), []);
-  });
-
-  test('changes in label score are reflected in the DOM', () => {
-    element.labels = {
-      'Code-Review': {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: 0,
-      },
-      'Verified': {
-        values: {
-          ' 0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: 0,
-      },
-    };
-    const selector = element.$.labelSelector;
-    element.set('label', {name: 'Verified', value: ' 0'});
-    flushAsynchronousOperations();
-    assert.strictEqual(selector.selected, ' 0');
-    assert.strictEqual(
-        element.$.selectedValueLabel.textContent.trim(), 'No score');
-  });
-
-  test('without permitted labels', () => {
-    element.permittedLabels = {
-      Verified: [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    flushAsynchronousOperations();
-    assert.isOk(element.$.labelSelector);
-    assert.isFalse(element.$.labelSelector.hidden);
-
-    element.permittedLabels = {};
-    flushAsynchronousOperations();
-    assert.isOk(element.$.labelSelector);
-    assert.isTrue(element.$.labelSelector.hidden);
-
-    element.permittedLabels = {Verified: []};
-    flushAsynchronousOperations();
-    assert.isOk(element.$.labelSelector);
-    assert.isTrue(element.$.labelSelector.hidden);
-  });
-
-  test('asymetrical labels', done => {
-    element.permittedLabels = {
-      'Code-Review': [
-        '-2',
-        '-1',
-        ' 0',
-        '+1',
-        '+2',
-      ],
-      'Verified': [
-        ' 0',
-        '+1',
-      ],
-    };
-    flush(() => {
-      assert.strictEqual(element.$.labelSelector
-          .items.length, 2);
-      assert.strictEqual(
-          dom(element.root).querySelectorAll('.placeholder').length,
-          3);
-
-      element.permittedLabels = {
-        'Code-Review': [
-          ' 0',
-          '+1',
-        ],
-        'Verified': [
-          '-2',
-          '-1',
-          ' 0',
-          '+1',
-          '+2',
-        ],
-      };
-      flush(() => {
-        assert.strictEqual(element.$.labelSelector
-            .items.length, 5);
-        assert.strictEqual(
-            dom(element.root).querySelectorAll('.placeholder').length,
-            0);
-        done();
-      });
-    });
-  });
-
-  test('default_value', () => {
-    element.permittedLabels = {
-      Verified: [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    element.labels = {
-      Verified: {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: -1,
-      },
-    };
-    element.label = {
-      name: 'Verified',
-      value: null,
-    };
-    flushAsynchronousOperations();
-    assert.strictEqual(element.selectedValue, '-1');
-  });
-
-  test('default_value is null if not permitted', () => {
-    element.permittedLabels = {
-      Verified: [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    element.labels = {
-      'Code-Review': {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: -1,
-      },
-    };
-    element.label = {
-      name: 'Code-Review',
-      value: null,
-    };
-    flushAsynchronousOperations();
-    assert.isNull(element.selectedValue);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
new file mode 100644
index 0000000..0739f73
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
@@ -0,0 +1,370 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-label-score-row.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-label-score-row');
+
+suite('gr-label-row-score tests', () => {
+  let element;
+
+  setup(done => {
+    element = basicFixture.instantiate();
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+        value: 1,
+        all: [{
+          _account_id: 123,
+          value: 1,
+        }],
+      },
+      'Verified': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+        value: 1,
+        all: [{
+          _account_id: 123,
+          value: 1,
+        }],
+      },
+    };
+
+    element.permittedLabels = {
+      'Code-Review': [
+        '-2',
+        '-1',
+        ' 0',
+        '+1',
+        '+2',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+
+    element.labelValues = {'0': 2, '1': 3, '2': 4, '-2': 0, '-1': 1};
+
+    element.label = {
+      name: 'Verified',
+      value: '+1',
+    };
+
+    flush(done);
+  });
+
+  function checkAriaCheckedValid() {
+    const items = element.$.labelSelector.items;
+    const selectedItem = element.selectedItem;
+    for (let i = 0; i < items.length; i++) {
+      const item = items[i];
+      if (items[i] === selectedItem) {
+        assert.isTrue(item.hasAttribute('aria-checked'), `item ${i}`);
+        assert.equal(item.getAttribute('aria-checked'), 'true', `item ${i}`);
+      } else {
+        assert.isFalse(item.hasAttribute('aria-checked'), `item ${i}`);
+      }
+    }
+  }
+
+  test('label picker', () => {
+    const labelsChangedHandler = sinon.stub();
+    element.addEventListener('labels-changed', labelsChangedHandler);
+    assert.ok(element.$.labelSelector);
+    MockInteractions.tap(element.shadowRoot
+        .querySelector(
+            'gr-button[data-value="-1"]'));
+    flushAsynchronousOperations();
+    assert.strictEqual(element.selectedValue, '-1');
+    assert.strictEqual(element.selectedItem
+        .textContent.trim(), '-1');
+    assert.strictEqual(
+        element.$.selectedValueLabel.textContent.trim(), 'bad');
+    const detail = labelsChangedHandler.args[0][0].detail;
+    assert.equal(detail.name, 'Verified');
+    assert.equal(detail.value, '-1');
+    checkAriaCheckedValid();
+  });
+
+  test('_computeVoteAttribute', () => {
+    let value = 1;
+    let index = 0;
+    const totalItems = 5;
+    // positive and first position
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'positive');
+    // negative and first position
+    value = -1;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'min');
+    // negative but not first position
+    index = 1;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'negative');
+    // neutral
+    value = 0;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'neutral');
+    // positive but not last position
+    value = 1;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'positive');
+    // positive and last position
+    index = 4;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'max');
+    // negative and last position
+    value = -1;
+    assert.equal(element._computeVoteAttribute(value, index,
+        totalItems), 'negative');
+  });
+
+  test('correct item is selected', () => {
+    // 1 should be the value of the selected item
+    assert.strictEqual(element.$.labelSelector.selected, '+1');
+    assert.strictEqual(
+        element.$.labelSelector.selectedItem
+            .textContent.trim(), '+1');
+    assert.strictEqual(
+        element.$.selectedValueLabel.textContent.trim(), 'good');
+    checkAriaCheckedValid();
+  });
+
+  test('do not display tooltips on touch devices', () => {
+    const verifiedBtn = element.shadowRoot
+        .querySelector(
+            'iron-selector > gr-button[data-value="-1"]');
+
+    // On touch devices, tooltips should not be shown.
+    verifiedBtn._isTouchDevice = true;
+    verifiedBtn._handleShowTooltip();
+    assert.isNotOk(verifiedBtn._tooltip);
+    verifiedBtn._handleHideTooltip();
+    assert.isNotOk(verifiedBtn._tooltip);
+
+    // On other devices, tooltips should be shown.
+    verifiedBtn._isTouchDevice = false;
+    verifiedBtn._handleShowTooltip();
+    assert.isOk(verifiedBtn._tooltip);
+    verifiedBtn._handleHideTooltip();
+    assert.isNotOk(verifiedBtn._tooltip);
+  });
+
+  test('_computeLabelValue', () => {
+    assert.strictEqual(element._computeLabelValue(element.labels,
+        element.permittedLabels,
+        element.label), '+1');
+  });
+
+  test('_computeBlankItems', () => {
+    element.labelValues = {
+      '-2': 0,
+      '-1': 1,
+      '0': 2,
+      '1': 3,
+      '2': 4,
+    };
+
+    assert.strictEqual(element._computeBlankItems(element.permittedLabels,
+        'Code-Review').length, 0);
+
+    assert.strictEqual(element._computeBlankItems(element.permittedLabels,
+        'Verified').length, 1);
+  });
+
+  test('labelValues returns no keys', () => {
+    element.labelValues = {};
+
+    assert.deepEqual(element._computeBlankItems(element.permittedLabels,
+        'Code-Review'), []);
+  });
+
+  test('changes in label score are reflected in the DOM', () => {
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+      },
+      'Verified': {
+        values: {
+          ' 0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+      },
+    };
+    const selector = element.$.labelSelector;
+    element.set('label', {name: 'Verified', value: ' 0'});
+    flushAsynchronousOperations();
+    assert.strictEqual(selector.selected, ' 0');
+    assert.strictEqual(
+        element.$.selectedValueLabel.textContent.trim(), 'No score');
+    checkAriaCheckedValid();
+  });
+
+  test('without permitted labels', () => {
+    element.permittedLabels = {
+      Verified: [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    flushAsynchronousOperations();
+    assert.isOk(element.$.labelSelector);
+    assert.isFalse(element.$.labelSelector.hidden);
+
+    element.permittedLabels = {};
+    flushAsynchronousOperations();
+    assert.isOk(element.$.labelSelector);
+    assert.isTrue(element.$.labelSelector.hidden);
+
+    element.permittedLabels = {Verified: []};
+    flushAsynchronousOperations();
+    assert.isOk(element.$.labelSelector);
+    assert.isTrue(element.$.labelSelector.hidden);
+  });
+
+  test('asymmetrical labels', done => {
+    element.permittedLabels = {
+      'Code-Review': [
+        '-2',
+        '-1',
+        ' 0',
+        '+1',
+        '+2',
+      ],
+      'Verified': [
+        ' 0',
+        '+1',
+      ],
+    };
+    flush(() => {
+      assert.strictEqual(element.$.labelSelector
+          .items.length, 2);
+      assert.strictEqual(
+          dom(element.root).querySelectorAll('.placeholder').length,
+          3);
+
+      element.permittedLabels = {
+        'Code-Review': [
+          ' 0',
+          '+1',
+        ],
+        'Verified': [
+          '-2',
+          '-1',
+          ' 0',
+          '+1',
+          '+2',
+        ],
+      };
+      flush(() => {
+        assert.strictEqual(element.$.labelSelector
+            .items.length, 5);
+        assert.strictEqual(
+            dom(element.root).querySelectorAll('.placeholder').length,
+            0);
+        done();
+      });
+    });
+  });
+
+  test('default_value', () => {
+    element.permittedLabels = {
+      Verified: [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    element.labels = {
+      Verified: {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: -1,
+      },
+    };
+    element.label = {
+      name: 'Verified',
+      value: null,
+    };
+    flushAsynchronousOperations();
+    assert.strictEqual(element.selectedValue, '-1');
+    checkAriaCheckedValid();
+  });
+
+  test('default_value is null if not permitted', () => {
+    element.permittedLabels = {
+      Verified: [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: -1,
+      },
+    };
+    element.label = {
+      name: 'Code-Review',
+      value: null,
+    };
+    flushAsynchronousOperations();
+    assert.isNull(element.selectedValue);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
index 2d6825b..aa01b01 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-label-score-row/gr-label-score-row.js';
 import '../../../styles/shared-styles.js';
@@ -24,7 +22,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-label-scores_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrLabelScores extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -102,7 +100,7 @@
 
   _computeLabels(labelRecord, account) {
     // Polymer 2: check for undefined
-    if ([labelRecord, account].some(arg => arg === undefined)) {
+    if ([labelRecord, account].includes(undefined)) {
       return undefined;
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.js
deleted file mode 100644
index 1e3e7ec..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .scoresTable {
-      display: table;
-      width: 100%;
-    }
-    .mergedMessage {
-      font-style: italic;
-      text-align: center;
-      width: 100%;
-    }
-    gr-label-score-row:hover {
-      background-color: var(--hover-background-color);
-    }
-    gr-label-score-row {
-      display: table-row;
-    }
-    gr-label-score-row.no-access {
-      display: var(--label-no-access-display, table-row);
-    }
-  </style>
-  <div class="scoresTable">
-    <template is="dom-repeat" items="[[_labels]]" as="label">
-      <gr-label-score-row
-        class$="[[_computeLabelAccessClass(label.name, permittedLabels)]]"
-        label="[[label]]"
-        name="[[label.name]]"
-        labels="[[change.labels]]"
-        permitted-labels="[[permittedLabels]]"
-        label-values="[[_labelValues]]"
-      ></gr-label-score-row>
-    </template>
-  </div>
-  <div class="mergedMessage" hidden$="[[!_changeIsMerged(change.status)]]">
-    Because this change has been merged, votes may not be decreased.
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
new file mode 100644
index 0000000..974e55d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
@@ -0,0 +1,55 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .scoresTable {
+      display: table;
+      width: 100%;
+    }
+    .mergedMessage {
+      font-style: italic;
+      text-align: center;
+      width: 100%;
+    }
+    gr-label-score-row:hover {
+      background-color: var(--hover-background-color);
+    }
+    gr-label-score-row {
+      display: table-row;
+    }
+    gr-label-score-row.no-access {
+      display: var(--label-no-access-display, table-row);
+    }
+  </style>
+  <div class="scoresTable">
+    <template is="dom-repeat" items="[[_labels]]" as="label">
+      <gr-label-score-row
+        class$="[[_computeLabelAccessClass(label.name, permittedLabels)]]"
+        label="[[label]]"
+        name="[[label.name]]"
+        labels="[[change.labels]]"
+        permitted-labels="[[permittedLabels]]"
+        label-values="[[_labelValues]]"
+      ></gr-label-score-row>
+    </template>
+  </div>
+  <div class="mergedMessage" hidden$="[[!_changeIsMerged(change.status)]]">
+    Because this change has been merged, votes may not be decreased.
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
deleted file mode 100644
index 7ee86d6..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
+++ /dev/null
@@ -1,196 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-label-scores</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-label-scores></gr-label-scores>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-label-scores.js';
-suite('gr-label-scores tests', () => {
-  let element;
-  let sandbox;
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-    });
-    element = fixture('basic');
-    element.change = {
-      _number: '123',
-      labels: {
-        'Code-Review': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-          value: 1,
-          all: [{
-            _account_id: 123,
-            value: 1,
-          }],
-        },
-        'Verified': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-          value: 1,
-          all: [{
-            _account_id: 123,
-            value: 1,
-          }],
-        },
-      },
-    };
-
-    element.account = {
-      _account_id: 123,
-    };
-
-    element.permittedLabels = {
-      'Code-Review': [
-        '-2',
-        '-1',
-        ' 0',
-        '+1',
-        '+2',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    flush(done);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('get and set label scores', () => {
-    for (const label in element.permittedLabels) {
-      if (element.permittedLabels.hasOwnProperty(label)) {
-        const row = element.shadowRoot
-            .querySelector('gr-label-score-row[name="' + label + '"]');
-        row.setSelectedValue(-1);
-      }
-    }
-    assert.deepEqual(element.getLabelValues(), {
-      'Code-Review': -1,
-      'Verified': -1,
-    });
-  });
-
-  test('_getVoteForAccount', () => {
-    const labelName = 'Code-Review';
-    assert.strictEqual(element._getVoteForAccount(
-        element.change.labels, labelName, element.account),
-    '+1');
-  });
-
-  test('_computeColumns', () => {
-    element._computeColumns(element.permittedLabels);
-    assert.deepEqual(element._labelValues, {
-      '-2': 0,
-      '-1': 1,
-      '0': 2,
-      '1': 3,
-      '2': 4,
-    });
-  });
-
-  test('_computeLabelAccessClass undefined case', () => {
-    assert.strictEqual(
-        element._computeLabelAccessClass(undefined, undefined), '');
-    assert.strictEqual(
-        element._computeLabelAccessClass('', undefined), '');
-    assert.strictEqual(
-        element._computeLabelAccessClass(undefined, {}), '');
-  });
-
-  test('_computeLabelAccessClass has access', () => {
-    assert.strictEqual(
-        element._computeLabelAccessClass('foo', {foo: ['']}), 'access');
-  });
-
-  test('_computeLabelAccessClass no access', () => {
-    assert.strictEqual(
-        element._computeLabelAccessClass('zap', {foo: ['']}), 'no-access');
-  });
-
-  test('changes in label score are reflected in _labels', () => {
-    element.change = {
-      _number: '123',
-      labels: {
-        'Code-Review': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-        },
-        'Verified': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-        },
-      },
-    };
-    assert.deepEqual(element._labels [
-        ({name: 'Code-Review', value: null}, {name: 'Verified', value: null})
-    ]);
-    element.set(['change', 'labels', 'Verified', 'all'],
-        [{_account_id: 123, value: 1}]);
-    assert.deepEqual(element._labels, [
-      {name: 'Code-Review', value: null},
-      {name: 'Verified', value: '+1'},
-    ]);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
new file mode 100644
index 0000000..ffc17cd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
@@ -0,0 +1,176 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-label-scores.js';
+
+const basicFixture = fixtureFromElement('gr-label-scores');
+
+suite('gr-label-scores tests', () => {
+  let element;
+
+  setup(done => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+    element = basicFixture.instantiate();
+    element.change = {
+      _number: '123',
+      labels: {
+        'Code-Review': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+          value: 1,
+          all: [{
+            _account_id: 123,
+            value: 1,
+          }],
+        },
+        'Verified': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+          value: 1,
+          all: [{
+            _account_id: 123,
+            value: 1,
+          }],
+        },
+      },
+    };
+
+    element.account = {
+      _account_id: 123,
+    };
+
+    element.permittedLabels = {
+      'Code-Review': [
+        '-2',
+        '-1',
+        ' 0',
+        '+1',
+        '+2',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+    flush(done);
+  });
+
+  test('get and set label scores', () => {
+    for (const label in element.permittedLabels) {
+      if (element.permittedLabels.hasOwnProperty(label)) {
+        const row = element.shadowRoot
+            .querySelector('gr-label-score-row[name="' + label + '"]');
+        row.setSelectedValue(-1);
+      }
+    }
+    assert.deepEqual(element.getLabelValues(), {
+      'Code-Review': -1,
+      'Verified': -1,
+    });
+  });
+
+  test('_getVoteForAccount', () => {
+    const labelName = 'Code-Review';
+    assert.strictEqual(element._getVoteForAccount(
+        element.change.labels, labelName, element.account),
+    '+1');
+  });
+
+  test('_computeColumns', () => {
+    element._computeColumns(element.permittedLabels);
+    assert.deepEqual(element._labelValues, {
+      '-2': 0,
+      '-1': 1,
+      '0': 2,
+      '1': 3,
+      '2': 4,
+    });
+  });
+
+  test('_computeLabelAccessClass undefined case', () => {
+    assert.strictEqual(
+        element._computeLabelAccessClass(undefined, undefined), '');
+    assert.strictEqual(
+        element._computeLabelAccessClass('', undefined), '');
+    assert.strictEqual(
+        element._computeLabelAccessClass(undefined, {}), '');
+  });
+
+  test('_computeLabelAccessClass has access', () => {
+    assert.strictEqual(
+        element._computeLabelAccessClass('foo', {foo: ['']}), 'access');
+  });
+
+  test('_computeLabelAccessClass no access', () => {
+    assert.strictEqual(
+        element._computeLabelAccessClass('zap', {foo: ['']}), 'no-access');
+  });
+
+  test('changes in label score are reflected in _labels', () => {
+    element.change = {
+      _number: '123',
+      labels: {
+        'Code-Review': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        },
+        'Verified': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        },
+      },
+    };
+    assert.deepEqual(element._labels [
+        ({name: 'Code-Review', value: null}, {name: 'Verified', value: null})
+    ]);
+    element.set(['change', 'labels', 'Verified', 'all'],
+        [{_account_id: 123, value: 1}]);
+    assert.deepEqual(element._labels, [
+      {name: 'Code-Review', value: null},
+      {name: 'Verified', value: '+1'},
+    ]);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index 906ed34..0da687f 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 
 import '@polymer/iron-icon/iron-icon.js';
 import '../../shared/gr-account-label/gr-account-label.js';
@@ -25,17 +24,17 @@
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../../styles/shared-styles.js';
 import '../../../styles/gr-voting-styles.js';
-import '../gr-comment-list/gr-comment-list.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-message_html.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
 
 const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+:\s*(.*)/;
 const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?$/;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrMessage extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -62,6 +61,8 @@
 
   static get properties() {
     return {
+      /** @type {?} */
+      change: Object,
       changeNum: Number,
       /** @type {?} */
       message: Object,
@@ -69,6 +70,11 @@
         type: Object,
         computed: '_computeAuthor(message)',
       },
+      /**
+       * TODO(taoalpha): remove once the change log experiment is launched
+       *
+       * @type {Object} - a map on file and comments on it
+       */
       comments: {
         type: Object,
       },
@@ -122,11 +128,12 @@
       _messageContentCollapsed: {
         type: String,
         computed:
-            '_computeMessageContentCollapsed(message.message, message.tag)',
+            '_computeMessageContentCollapsed(message.message, message.tag,' +
+            ' message.commentThreads)',
       },
       _commentCountText: {
         type: Number,
-        computed: '_computeCommentCountText(comments)',
+        computed: '_computeCommentCountText(message.commentThreads.length)',
       },
       _loggedIn: {
         type: Boolean,
@@ -149,6 +156,10 @@
     ];
   }
 
+  constructor() {
+    super();
+  }
+
   /** @override */
   created() {
     super.created();
@@ -178,30 +189,57 @@
     }
   }
 
-  _computeCommentCountText(comments) {
-    if (!comments) return undefined;
-    let count = 0;
-    for (const file in comments) {
-      if (comments.hasOwnProperty(file)) {
-        const commentArray = comments[file] || [];
-        count += commentArray.length;
-      }
-    }
-    if (count === 0) {
+  _computeCommentCountText(threadsLength) {
+    if (threadsLength === 0) {
       return undefined;
-    } else if (count === 1) {
+    } else if (threadsLength === 1) {
       return '1 comment';
     } else {
-      return `${count} comments`;
+      return `${threadsLength} comments`;
     }
   }
 
+  _onThreadListModified() {
+    // TODO(taoalpha): this won't propagate the changes to the files
+    // should consider replacing this with either top level events
+    // or gerrit level events
+
+    // emit the event so change-view can also get updated with latest changes
+    this.dispatchEvent(new CustomEvent('comment-refresh', {
+      composed: true, bubbles: true,
+    }));
+  }
+
   _computeMessageContentExpanded(content, tag) {
     return this._computeMessageContent(content, tag, true);
   }
 
-  _computeMessageContentCollapsed(content, tag) {
-    return this._computeMessageContent(content, tag, false);
+  _patchsetCommentSummary(commentThreads) {
+    const id = this.message.id;
+    if (!id) return '';
+    const patchsetThreads = commentThreads.filter(thread =>
+      thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS);
+    for (const thread of patchsetThreads) {
+      // Find if there was a patchset level comment created through the reply
+      // dialog and use it to determine the summary
+      if (thread.comments[0].change_message_id === id) {
+        return thread.comments[0].message;
+      }
+    }
+    // Find if there is a reply to some patchset comment left
+    for (const thread of patchsetThreads) {
+      for (const comment of thread.comments) {
+        if (comment.change_message_id === id) { return comment.message; }
+      }
+    }
+    return '';
+  }
+
+  _computeMessageContentCollapsed(content, tag, commentThreads) {
+    const summary =
+      this._computeMessageContent(content, tag, false);
+    if (summary || !commentThreads) return summary;
+    return this._patchsetCommentSummary(commentThreads);
   }
 
   _computeMessageContent(content, tag, isExpanded) {
@@ -312,7 +350,7 @@
 
   _computeScoreClass(score, labelExtremes) {
     // Polymer 2: check for undefined
-    if ([score, labelExtremes].some(arg => arg === undefined)) {
+    if ([score, labelExtremes].includes(undefined)) {
       return '';
     }
     if (score.value === 'removed') {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
deleted file mode 100644
index 753fd38..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.js
+++ /dev/null
@@ -1,304 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="gr-voting-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-      position: relative;
-      cursor: pointer;
-      overflow-y: hidden;
-    }
-    :host(.expanded) {
-      cursor: auto;
-    }
-    .collapsed .contentContainer {
-      align-items: center;
-      color: var(--deemphasized-text-color);
-      display: flex;
-      white-space: nowrap;
-    }
-    .contentContainer {
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    .collapsed .contentContainer {
-      /* For expanded state we inherit the alternating background color
-           that is set in gr-messages-list. */
-      background-color: var(--background-color-primary);
-    }
-    .name {
-      font-weight: var(--font-weight-bold);
-    }
-    .message {
-      --gr-formatted-text-prose-max-width: 80ch;
-    }
-    .collapsed .message {
-      max-width: none;
-      overflow: hidden;
-      text-overflow: ellipsis;
-    }
-    .collapsed .author,
-    .collapsed .content,
-    .collapsed .message,
-    .collapsed .updateCategory,
-    gr-account-chip {
-      display: inline;
-    }
-    gr-button {
-      margin: 0 -4px;
-    }
-    .collapsed gr-comment-list,
-    .collapsed .replyBtn,
-    .collapsed .deleteBtn,
-    .collapsed .hideOnCollapsed,
-    .hideOnOpen {
-      display: none;
-    }
-    .replyBtn {
-      margin-right: var(--spacing-m);
-    }
-    .collapsed .hideOnOpen {
-      display: block;
-    }
-    .collapsed .content {
-      flex: 1;
-      margin-right: var(--spacing-m);
-      min-width: 0;
-      overflow: hidden;
-    }
-    .collapsed .content.messageContent {
-      text-overflow: ellipsis;
-    }
-    .collapsed .dateContainer {
-      position: static;
-    }
-    .collapsed .author {
-      overflow: hidden;
-      color: var(--primary-text-color);
-      margin-right: var(--spacing-s);
-    }
-    .authorLabel {
-      min-width: 160px;
-      display: inline-block;
-    }
-    .expanded .author {
-      cursor: pointer;
-      margin-bottom: var(--spacing-m);
-    }
-    .expanded .content {
-      padding-left: 40px;
-    }
-    .dateContainer {
-      position: absolute;
-      /* right and top values should match .contentContainer padding */
-      right: var(--spacing-l);
-      top: var(--spacing-m);
-    }
-    .dateContainer .patchset {
-      margin-right: var(--spacing-m);
-      color: var(--deemphasized-text-color);
-    }
-    .dateContainer .patchset:before {
-      content: 'Patchset ';
-    }
-    span.date {
-      color: var(--deemphasized-text-color);
-    }
-    span.date:hover {
-      text-decoration: underline;
-    }
-    .dateContainer iron-icon {
-      cursor: pointer;
-      vertical-align: top;
-    }
-    .score {
-      border-radius: var(--border-radius);
-      color: var(--primary-text-color);
-      display: inline-block;
-      padding: 0 var(--spacing-s);
-      text-align: center;
-    }
-    .score,
-    .commentsSummary {
-      margin-right: var(--spacing-s);
-      min-width: 115px;
-    }
-    .expanded .commentsSummary {
-      display: none;
-    }
-    .commentsIcon {
-      vertical-align: top;
-    }
-    .score.removed {
-      background-color: var(--vote-color-neutral);
-    }
-    .score.negative {
-      background-color: var(--vote-color-disliked);
-    }
-    .score.negative.min {
-      background-color: var(--vote-color-rejected);
-    }
-    .score.positive {
-      background-color: var(--vote-color-recommended);
-    }
-    .score.positive.max {
-      background-color: var(--vote-color-approved);
-    }
-    gr-account-label {
-      --gr-account-label-text-style: {
-        font-weight: var(--font-weight-bold);
-      }
-    }
-    @media screen and (max-width: 50em) {
-      .expanded .content {
-        padding-left: 0;
-      }
-      .score,
-      .commentsSummary,
-      .authorLabel {
-        min-width: 0px;
-      }
-      .dateContainer .patchset:before {
-        content: 'PS ';
-      }
-    }
-  </style>
-  <div class$="[[_computeClass(_expanded)]]">
-    <div class="contentContainer">
-      <div class="author" on-click="_handleAuthorClick">
-        <span hidden$="[[!showOnBehalfOf]]">
-          <span class="name">[[message.real_author.name]]</span>
-          on behalf of
-        </span>
-        <gr-account-label
-          account="[[author]]"
-          class="authorLabel"
-        ></gr-account-label>
-        <template
-          is="dom-repeat"
-          items="[[_getScores(message, labelExtremes)]]"
-          as="score"
-        >
-          <span class$="score [[_computeScoreClass(score, labelExtremes)]]">
-            [[score.label]] [[score.value]]
-          </span>
-        </template>
-      </div>
-      <template is="dom-if" if="[[_commentCountText]]">
-        <div class="commentsSummary">
-          <iron-icon icon="gr-icons:comment" class="commentsIcon"></iron-icon>
-          <span class="numberOfComments">[[_commentCountText]]</span>
-        </div>
-      </template>
-      <template is="dom-if" if="[[message.message]]">
-        <div class="content messageContent">
-          <div class="message hideOnOpen">[[_messageContentCollapsed]]</div>
-          <gr-formatted-text
-            no-trailing-margin=""
-            class="message hideOnCollapsed"
-            content="[[_messageContentExpanded]]"
-            config="[[_projectConfig.commentlinks]]"
-          ></gr-formatted-text>
-          <template is="dom-if" if="[[_messageContentExpanded]]">
-            <div
-              class="replyActionContainer"
-              hidden$="[[!showReplyButton]]"
-              hidden=""
-            >
-              <gr-button
-                class="replyBtn"
-                link=""
-                small=""
-                on-click="_handleReplyTap"
-              >
-                Reply
-              </gr-button>
-              <gr-button
-                disabled$="[[_isDeletingChangeMsg]]"
-                class="deleteBtn"
-                hidden$="[[!_isAdmin]]"
-                hidden=""
-                link=""
-                small=""
-                on-click="_handleDeleteMessage"
-              >
-                Delete
-              </gr-button>
-            </div>
-          </template>
-          <gr-comment-list
-            comments="[[comments]]"
-            change-num="[[changeNum]]"
-            patch-num="[[message._revision_number]]"
-            project-name="[[projectName]]"
-            project-config="[[_projectConfig]]"
-          ></gr-comment-list>
-        </div>
-      </template>
-      <template is="dom-if" if="[[_computeIsReviewerUpdate(message)]]">
-        <div class="content">
-          <template is="dom-repeat" items="[[message.updates]]" as="update">
-            <div class="updateCategory">
-              [[update.message]]
-              <template
-                is="dom-repeat"
-                items="[[update.reviewers]]"
-                as="reviewer"
-              >
-                <gr-account-chip account="[[reviewer]]"> </gr-account-chip>
-              </template>
-            </div>
-          </template>
-        </div>
-      </template>
-      <span class="dateContainer">
-        <template is="dom-if" if="[[message._revision_number]]">
-          <span class="patchset">[[message._revision_number]]</span>
-        </template>
-        <template is="dom-if" if="[[!message.id]]">
-          <span class="date">
-            <gr-date-formatter
-              has-tooltip=""
-              show-date-and-time=""
-              date-str="[[message.date]]"
-            ></gr-date-formatter>
-          </span>
-        </template>
-        <template is="dom-if" if="[[message.id]]">
-          <span class="date" on-click="_handleAnchorClick">
-            <gr-date-formatter
-              has-tooltip=""
-              show-date-and-time=""
-              date-str="[[message.date]]"
-            ></gr-date-formatter>
-          </span>
-        </template>
-        <iron-icon
-          id="expandToggle"
-          on-click="_toggleExpanded"
-          title="Toggle expanded state"
-          icon="[[_computeExpandToggleIcon(_expanded)]]"
-        ></iron-icon>
-      </span>
-    </div>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
new file mode 100644
index 0000000..a79d592
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -0,0 +1,309 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="gr-voting-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="shared-styles">
+    :host {
+      display: block;
+      position: relative;
+      cursor: pointer;
+      overflow-y: hidden;
+    }
+    :host(.expanded) {
+      cursor: auto;
+    }
+    .collapsed .contentContainer {
+      align-items: center;
+      color: var(--deemphasized-text-color);
+      display: flex;
+      white-space: nowrap;
+    }
+    .contentContainer {
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    .collapsed .contentContainer {
+      /* For expanded state we inherit the alternating background color
+           that is set in gr-messages-list. */
+      background-color: var(--background-color-primary);
+    }
+    .name {
+      font-weight: var(--font-weight-bold);
+    }
+    .message {
+      --gr-formatted-text-prose-max-width: 80ch;
+    }
+    .collapsed .message {
+      max-width: none;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+    .collapsed .author,
+    .collapsed .content,
+    .collapsed .message,
+    .collapsed .updateCategory,
+    gr-account-chip {
+      display: inline;
+    }
+    gr-button {
+      margin: 0 -4px;
+    }
+    .collapsed gr-thread-list,
+    .collapsed .replyBtn,
+    .collapsed .deleteBtn,
+    .collapsed .hideOnCollapsed,
+    .hideOnOpen {
+      display: none;
+    }
+    .replyBtn {
+      margin-right: var(--spacing-m);
+    }
+    .collapsed .hideOnOpen {
+      display: block;
+    }
+    .collapsed .content {
+      flex: 1;
+      margin-right: var(--spacing-m);
+      min-width: 0;
+      overflow: hidden;
+    }
+    .collapsed .content.messageContent {
+      text-overflow: ellipsis;
+    }
+    .collapsed .dateContainer {
+      position: static;
+    }
+    .collapsed .author {
+      overflow: hidden;
+      color: var(--primary-text-color);
+      margin-right: var(--spacing-s);
+    }
+    .authorLabel {
+      min-width: 160px;
+      display: inline-block;
+    }
+    .expanded .author {
+      cursor: pointer;
+      margin-bottom: var(--spacing-m);
+    }
+    .expanded .content {
+      padding-left: 40px;
+    }
+    .dateContainer {
+      position: absolute;
+      /* right and top values should match .contentContainer padding */
+      right: var(--spacing-l);
+      top: var(--spacing-m);
+    }
+    .dateContainer .patchset {
+      margin-right: var(--spacing-m);
+      color: var(--deemphasized-text-color);
+    }
+    .dateContainer .patchset:before {
+      content: 'Patchset ';
+    }
+    span.date {
+      color: var(--deemphasized-text-color);
+    }
+    span.date:hover {
+      text-decoration: underline;
+    }
+    .dateContainer iron-icon {
+      cursor: pointer;
+      vertical-align: top;
+    }
+    .score {
+      border-radius: var(--border-radius);
+      color: var(--vote-text-color);
+      display: inline-block;
+      padding: 0 var(--spacing-s);
+      text-align: center;
+    }
+    .score,
+    .commentsSummary {
+      margin-right: var(--spacing-s);
+      min-width: 115px;
+    }
+    .expanded .commentsSummary {
+      display: none;
+    }
+    .commentsIcon {
+      vertical-align: top;
+    }
+    .score.removed {
+      background-color: var(--vote-color-neutral);
+    }
+    .score.negative {
+      background-color: var(--vote-color-disliked);
+    }
+    .score.negative.min {
+      background-color: var(--vote-color-rejected);
+    }
+    .score.positive {
+      background-color: var(--vote-color-recommended);
+    }
+    .score.positive.max {
+      background-color: var(--vote-color-approved);
+    }
+    gr-account-label {
+      --gr-account-label-text-style: {
+        font-weight: var(--font-weight-bold);
+      }
+    }
+    @media screen and (max-width: 50em) {
+      .expanded .content {
+        padding-left: 0;
+      }
+      .score,
+      .commentsSummary,
+      .authorLabel {
+        min-width: 0px;
+      }
+      .dateContainer .patchset:before {
+        content: 'PS ';
+      }
+    }
+  </style>
+  <div class$="[[_computeClass(_expanded)]]">
+    <div class="contentContainer">
+      <div class="author" on-click="_handleAuthorClick">
+        <span hidden$="[[!showOnBehalfOf]]">
+          <span class="name">[[message.real_author.name]]</span>
+          on behalf of
+        </span>
+        <gr-account-label
+          account="[[author]]"
+          class="authorLabel"
+        ></gr-account-label>
+        <template
+          is="dom-repeat"
+          items="[[_getScores(message, labelExtremes)]]"
+          as="score"
+        >
+          <span class$="score [[_computeScoreClass(score, labelExtremes)]]">
+            [[score.label]] [[score.value]]
+          </span>
+        </template>
+      </div>
+      <template is="dom-if" if="[[_commentCountText]]">
+        <div class="commentsSummary">
+          <iron-icon icon="gr-icons:comment" class="commentsIcon"></iron-icon>
+          <span class="numberOfComments">[[_commentCountText]]</span>
+        </div>
+      </template>
+      <template is="dom-if" if="[[message.message]]">
+        <div class="content messageContent">
+          <div class="message hideOnOpen">[[_messageContentCollapsed]]</div>
+          <gr-formatted-text
+            no-trailing-margin=""
+            class="message hideOnCollapsed"
+            content="[[_messageContentExpanded]]"
+            config="[[_projectConfig.commentlinks]]"
+          ></gr-formatted-text>
+          <template is="dom-if" if="[[_expanded]]">
+            <template is="dom-if" if="[[_messageContentExpanded]]">
+              <div
+                class="replyActionContainer"
+                hidden$="[[!showReplyButton]]"
+                hidden=""
+              >
+                <gr-button
+                  class="replyBtn"
+                  link=""
+                  small=""
+                  on-click="_handleReplyTap"
+                >
+                  Reply
+                </gr-button>
+                <gr-button
+                  disabled$="[[_isDeletingChangeMsg]]"
+                  class="deleteBtn"
+                  hidden$="[[!_isAdmin]]"
+                  hidden=""
+                  link=""
+                  small=""
+                  on-click="_handleDeleteMessage"
+                >
+                  Delete
+                </gr-button>
+              </div>
+            </template>
+            <gr-thread-list
+              change="[[change]]"
+              hidden$="[[!message.commentThreads.length]]"
+              threads="[[message.commentThreads]]"
+              change-num="[[changeNum]]"
+              logged-in="[[_loggedIn]]"
+              hide-toggle-buttons
+              on-thread-list-modified="_onThreadListModified"
+            >
+            </gr-thread-list>
+          </template>
+        </div>
+      </template>
+      <template is="dom-if" if="[[_computeIsReviewerUpdate(message)]]">
+        <div class="content">
+          <template is="dom-repeat" items="[[message.updates]]" as="update">
+            <div class="updateCategory">
+              [[update.message]]
+              <template
+                is="dom-repeat"
+                items="[[update.reviewers]]"
+                as="reviewer"
+              >
+                <gr-account-chip account="[[reviewer]]"> </gr-account-chip>
+              </template>
+            </div>
+          </template>
+        </div>
+      </template>
+      <span class="dateContainer">
+        <template is="dom-if" if="[[message._revision_number]]">
+          <span class="patchset">[[message._revision_number]]</span>
+        </template>
+        <template is="dom-if" if="[[!message.id]]">
+          <span class="date">
+            <gr-date-formatter
+              has-tooltip=""
+              show-date-and-time=""
+              date-str="[[message.date]]"
+            ></gr-date-formatter>
+          </span>
+        </template>
+        <template is="dom-if" if="[[message.id]]">
+          <span class="date" on-click="_handleAnchorClick">
+            <gr-date-formatter
+              has-tooltip=""
+              show-date-and-time=""
+              date-str="[[message.date]]"
+            ></gr-date-formatter>
+          </span>
+        </template>
+        <iron-icon
+          id="expandToggle"
+          on-click="_toggleExpanded"
+          title="Toggle expanded state"
+          icon="[[_computeExpandToggleIcon(_expanded)]]"
+        ></iron-icon>
+      </span>
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
deleted file mode 100644
index 78c2229..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ /dev/null
@@ -1,456 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-message</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-message></gr-message>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-message.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-message tests', () => {
-  let element;
-
-  suite('when admin and logged in', () => {
-    setup(done => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getPreferences() { return Promise.resolve({}); },
-        getConfig() { return Promise.resolve({}); },
-        getIsAdmin() { return Promise.resolve(true); },
-        deleteChangeCommitMessage() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      flush(done);
-    });
-
-    test('reply event', done => {
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: true,
-      };
-
-      element.addEventListener('reply', e => {
-        assert.deepEqual(e.detail.message, element.message);
-        done();
-      });
-      flushAsynchronousOperations();
-      assert.isFalse(
-          element.shadowRoot.querySelector('.replyActionContainer').hidden
-      );
-      MockInteractions.tap(element.shadowRoot.querySelector('.replyBtn'));
-    });
-
-    test('can see delete button', () => {
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: false,
-      };
-
-      flushAsynchronousOperations();
-      assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').hidden);
-    });
-
-    test('delete change message', done => {
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: false,
-      };
-
-      element.addEventListener('change-message-deleted', e => {
-        assert.deepEqual(e.detail.message, element.message);
-        assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').disabled);
-        done();
-      });
-      flushAsynchronousOperations();
-      MockInteractions.tap(element.shadowRoot.querySelector('.deleteBtn'));
-      assert.isTrue(element.shadowRoot.querySelector('.deleteBtn').disabled);
-    });
-
-    test('autogenerated prefix hiding', () => {
-      element.message = {
-        tag: 'autogenerated:gerrit:test',
-        updated: '2016-01-12 20:24:49.448000000',
-        expanded: false,
-      };
-
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isTrue(element.hidden);
-    });
-
-    test('reviewer message treated as autogenerated', () => {
-      element.message = {
-        tag: 'autogenerated:gerrit:test',
-        updated: '2016-01-12 20:24:49.448000000',
-        reviewer: {},
-        expanded: false,
-      };
-
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isTrue(element.hidden);
-    });
-
-    test('batch reviewer message treated as autogenerated', () => {
-      element.message = {
-        type: 'REVIEWER_UPDATE',
-        updated: '2016-01-12 20:24:49.448000000',
-        reviewer: {},
-        expanded: false,
-      };
-
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isTrue(element.hidden);
-    });
-
-    test('tag that is not autogenerated prefix does not hide', () => {
-      element.message = {
-        tag: 'something',
-        updated: '2016-01-12 20:24:49.448000000',
-        expanded: false,
-      };
-
-      assert.isFalse(element.isAutomated);
-      assert.isFalse(element.hidden);
-
-      element.hideAutomated = true;
-
-      assert.isFalse(element.hidden);
-    });
-
-    test('reply button hidden unless logged in', () => {
-      const message = {
-        message: 'Uploaded patch set 1.',
-        expanded: false,
-      };
-      assert.isFalse(element._computeShowReplyButton(message, false));
-      assert.isTrue(element._computeShowReplyButton(message, true));
-    });
-
-    test('_computeShowOnBehalfOf', () => {
-      const message = {
-        message: '...',
-        expanded: false,
-      };
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-      message.author = {_account_id: 1115495};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-      message.real_author = {_account_id: 1115495};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-      message.real_author._account_id = 123456;
-      assert.isOk(element._computeShowOnBehalfOf(message));
-      message.updated_by = message.author;
-      delete message.author;
-      assert.isOk(element._computeShowOnBehalfOf(message));
-      delete message.updated_by;
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
-    });
-
-    ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'].forEach(label => {
-      test(`${label} ignored for color voting`, () => {
-        element.message = {
-          author: {},
-          expanded: false,
-          message: `Patch Set 1: ${label}+1`,
-        };
-        assert.isNotOk(
-            dom(element.root).querySelector('.negativeVote'));
-        assert.isNotOk(
-            dom(element.root).querySelector('.positiveVote'));
-      });
-    });
-
-    test('clicking on date link fires event', () => {
-      element.message = {
-        type: 'REVIEWER_UPDATE',
-        updated: '2016-01-12 20:24:49.448000000',
-        reviewer: {},
-        id: '47c43261_55aa2c41',
-        expanded: false,
-      };
-      flushAsynchronousOperations();
-      const stub = sinon.stub();
-      element.addEventListener('message-anchor-tap', stub);
-      const dateEl = element.shadowRoot
-          .querySelector('.date');
-      assert.ok(dateEl);
-      MockInteractions.tap(dateEl);
-
-      assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
-    });
-
-    suite('compute messages', () => {
-      test('empty', () => {
-        assert.equal(element._computeMessageContent('', '', true), '');
-        assert.equal(element._computeMessageContent('', '', false), '');
-      });
-
-      test('new patchset', () => {
-        const original = 'Uploaded patch set 1.';
-        const tag = 'autogenerated:gerrit:newPatchSet';
-        let actual = element._computeMessageContent(original, tag, true);
-        assert.equal(actual, original);
-        actual = element._computeMessageContent(original, tag, false);
-        assert.equal(actual, original);
-      });
-
-      test('new patchset rebased', () => {
-        const original = 'Patch Set 27: Patch Set 26 was rebased';
-        const tag = 'autogenerated:gerrit:newPatchSet';
-        const expected = 'Patch Set 26 was rebased';
-        let actual = element._computeMessageContent(original, tag, true);
-        assert.equal(actual, expected);
-        actual = element._computeMessageContent(original, tag, false);
-        assert.equal(actual, expected);
-      });
-
-      test('ready for review', () => {
-        const original = 'Patch Set 1:\n\nThis change is ready for review.';
-        const tag = undefined;
-        const expected = 'This change is ready for review.';
-        let actual = element._computeMessageContent(original, tag, true);
-        assert.equal(actual, expected);
-        actual = element._computeMessageContent(original, tag, false);
-        assert.equal(actual, expected);
-      });
-
-      test('vote', () => {
-        const original = 'Patch Set 1: Code-Style+1';
-        const tag = undefined;
-        const expected = '';
-        let actual = element._computeMessageContent(original, tag, true);
-        assert.equal(actual, expected);
-        actual = element._computeMessageContent(original, tag, false);
-        assert.equal(actual, expected);
-      });
-
-      test('comments', () => {
-        const original = 'Patch Set 1:\n\n(3 comments)';
-        const tag = undefined;
-        const expected = '';
-        let actual = element._computeMessageContent(original, tag, true);
-        assert.equal(actual, expected);
-        actual = element._computeMessageContent(original, tag, false);
-        assert.equal(actual, expected);
-      });
-    });
-
-    test('votes', () => {
-      element.message = {
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
-      };
-      element.labelExtremes = {
-        'Verified': {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Trybot-Label3': {max: 3, min: 0},
-      };
-      flushAsynchronousOperations();
-      const scoreChips = dom(element.root).querySelectorAll('.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[0].classList.contains('positive'));
-      assert.isTrue(scoreChips[0].classList.contains('max'));
-
-      assert.isTrue(scoreChips[1].classList.contains('negative'));
-      assert.isTrue(scoreChips[1].classList.contains('min'));
-
-      assert.isTrue(scoreChips[2].classList.contains('positive'));
-      assert.isFalse(scoreChips[2].classList.contains('min'));
-    });
-
-    test('removed votes', () => {
-      element.message = {
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Verified+1 -Code-Review -Commit-Queue',
-      };
-      element.labelExtremes = {
-        'Verified': {max: 1, min: -1},
-        'Code-Review': {max: 2, min: -2},
-        'Commit-Queue': {max: 3, min: 0},
-      };
-      flushAsynchronousOperations();
-      const scoreChips = dom(element.root).querySelectorAll('.score');
-      assert.equal(scoreChips.length, 3);
-
-      assert.isTrue(scoreChips[1].classList.contains('removed'));
-      assert.isTrue(scoreChips[2].classList.contains('removed'));
-    });
-
-    test('false negative vote', () => {
-      element.message = {
-        author: {},
-        expanded: false,
-        message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
-      };
-      element.labelExtremes = {};
-      const scoreChips = dom(element.root).querySelectorAll('.score');
-      assert.equal(scoreChips.length, 0);
-    });
-  });
-
-  suite('when not logged in', () => {
-    setup(done => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-        getPreferences() { return Promise.resolve({}); },
-        getConfig() { return Promise.resolve({}); },
-        getIsAdmin() { return Promise.resolve(false); },
-        deleteChangeCommitMessage() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      flush(done);
-    });
-
-    test('reply and delete button should be hidden', () => {
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: false,
-      };
-
-      flushAsynchronousOperations();
-      assert.isTrue(
-          element.shadowRoot.querySelector('.replyActionContainer').hidden
-      );
-      assert.isTrue(
-          element.shadowRoot.querySelector('.deleteBtn').hidden
-      );
-    });
-  });
-
-  suite('when logged in but not admin', () => {
-    setup(done => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(true); },
-        getConfig() { return Promise.resolve({}); },
-        getIsAdmin() { return Promise.resolve(false); },
-        deleteChangeCommitMessage() { return Promise.resolve({}); },
-      });
-      element = fixture('basic');
-      flush(done);
-    });
-
-    test('can see reply but not delete button', () => {
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'Uploaded patch set 1.',
-        _revision_number: 1,
-        expanded: false,
-      };
-
-      flushAsynchronousOperations();
-      assert.isFalse(
-          element.shadowRoot.querySelector('.replyActionContainer').hidden
-      );
-      assert.isTrue(
-          element.shadowRoot.querySelector('.deleteBtn').hidden
-      );
-    });
-
-    test('reply button shown when message is updated', () => {
-      element.message = undefined;
-      flushAsynchronousOperations();
-      let replyEl = element.shadowRoot.querySelector('.replyActionContainer');
-      // We don't even expect the button to show up in the DOM when the message
-      // is undefined.
-      assert.isNotOk(replyEl);
-
-      element.message = {
-        id: '47c43261_55aa2c41',
-        author: {
-          _account_id: 1115495,
-          name: 'Andrew Bonventre',
-          email: 'andybons@chromium.org',
-        },
-        date: '2016-01-12 20:24:49.448000000',
-        message: 'not empty',
-        _revision_number: 1,
-        expanded: false,
-      };
-      flushAsynchronousOperations();
-      replyEl = element.shadowRoot.querySelector('.replyActionContainer');
-      assert.isOk(replyEl);
-      assert.isFalse(replyEl.hidden);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
new file mode 100644
index 0000000..d425398
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
@@ -0,0 +1,510 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-message.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-message');
+
+suite('gr-message tests', () => {
+  let element;
+
+  suite('when admin and logged in', () => {
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
+        getIsAdmin() { return Promise.resolve(true); },
+        deleteChangeCommitMessage() { return Promise.resolve({}); },
+      });
+      element = basicFixture.instantiate();
+      flush(done);
+    });
+
+    test('reply event', done => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+        expanded: true,
+      };
+
+      element.addEventListener('reply', e => {
+        assert.deepEqual(e.detail.message, element.message);
+        done();
+      });
+      flushAsynchronousOperations();
+      assert.isFalse(
+          element.shadowRoot.querySelector('.replyActionContainer').hidden
+      );
+      MockInteractions.tap(element.shadowRoot.querySelector('.replyBtn'));
+    });
+
+    test('can see delete button', () => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+        expanded: true,
+      };
+
+      flushAsynchronousOperations();
+      assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').hidden);
+    });
+
+    test('delete change message', done => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+        expanded: true,
+      };
+
+      element.addEventListener('change-message-deleted', e => {
+        assert.deepEqual(e.detail.message, element.message);
+        assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').disabled);
+        done();
+      });
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.shadowRoot.querySelector('.deleteBtn'));
+      assert.isTrue(element.shadowRoot.querySelector('.deleteBtn').disabled);
+    });
+
+    test('autogenerated prefix hiding', () => {
+      element.message = {
+        tag: 'autogenerated:gerrit:test',
+        updated: '2016-01-12 20:24:49.448000000',
+        expanded: false,
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('reviewer message treated as autogenerated', () => {
+      element.message = {
+        tag: 'autogenerated:gerrit:test',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+        expanded: false,
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('batch reviewer message treated as autogenerated', () => {
+      element.message = {
+        type: 'REVIEWER_UPDATE',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+        expanded: false,
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('tag that is not autogenerated prefix does not hide', () => {
+      element.message = {
+        tag: 'something',
+        updated: '2016-01-12 20:24:49.448000000',
+        expanded: false,
+      };
+
+      assert.isFalse(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isFalse(element.hidden);
+    });
+
+    test('reply button hidden unless logged in', () => {
+      const message = {
+        message: 'Uploaded patch set 1.',
+        expanded: false,
+      };
+      assert.isFalse(element._computeShowReplyButton(message, false));
+      assert.isTrue(element._computeShowReplyButton(message, true));
+    });
+
+    test('_computeShowOnBehalfOf', () => {
+      const message = {
+        message: '...',
+        expanded: false,
+      };
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author._account_id = 123456;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      message.updated_by = message.author;
+      delete message.author;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      delete message.updated_by;
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+    });
+
+    ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'].forEach(label => {
+      test(`${label} ignored for color voting`, () => {
+        element.message = {
+          author: {},
+          expanded: false,
+          message: `Patch Set 1: ${label}+1`,
+        };
+        assert.isNotOk(
+            dom(element.root).querySelector('.negativeVote'));
+        assert.isNotOk(
+            dom(element.root).querySelector('.positiveVote'));
+      });
+    });
+
+    test('clicking on date link fires event', () => {
+      element.message = {
+        type: 'REVIEWER_UPDATE',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+        id: '47c43261_55aa2c41',
+        expanded: false,
+      };
+      flushAsynchronousOperations();
+      const stub = sinon.stub();
+      element.addEventListener('message-anchor-tap', stub);
+      const dateEl = element.shadowRoot
+          .querySelector('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
+    });
+
+    suite('compute messages', () => {
+      test('empty', () => {
+        assert.equal(element._computeMessageContent('', '', true), '');
+        assert.equal(element._computeMessageContent('', '', false), '');
+      });
+
+      test('new patchset', () => {
+        const original = 'Uploaded patch set 1.';
+        const tag = 'autogenerated:gerrit:newPatchSet';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, element._computeMessageContentCollapsed(
+            original, tag, []));
+        assert.equal(actual, original);
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, original);
+      });
+
+      test('new patchset rebased', () => {
+        const original = 'Patch Set 27: Patch Set 26 was rebased';
+        const tag = 'autogenerated:gerrit:newPatchSet';
+        const expected = 'Patch Set 26 was rebased';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, expected);
+        assert.equal(actual, element._computeMessageContentCollapsed(
+            original, tag, []));
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, expected);
+      });
+
+      test('ready for review', () => {
+        const original = 'Patch Set 1:\n\nThis change is ready for review.';
+        const tag = undefined;
+        const expected = 'This change is ready for review.';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, expected);
+        assert.equal(actual, element._computeMessageContentCollapsed(
+            original, tag, []));
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, expected);
+      });
+
+      test('vote', () => {
+        const original = 'Patch Set 1: Code-Style+1';
+        const tag = undefined;
+        const expected = '';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, expected);
+      });
+
+      test('comments', () => {
+        const original = 'Patch Set 1:\n\n(3 comments)';
+        const tag = undefined;
+        const expected = '';
+        let actual = element._computeMessageContent(original, tag, true);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(original, tag, false);
+        assert.equal(actual, expected);
+      });
+    });
+
+    test('votes', () => {
+      element.message = {
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
+      };
+      element.labelExtremes = {
+        'Verified': {max: 1, min: -1},
+        'Code-Review': {max: 2, min: -2},
+        'Trybot-Label3': {max: 3, min: 0},
+      };
+      flushAsynchronousOperations();
+      const scoreChips = dom(element.root).querySelectorAll('.score');
+      assert.equal(scoreChips.length, 3);
+
+      assert.isTrue(scoreChips[0].classList.contains('positive'));
+      assert.isTrue(scoreChips[0].classList.contains('max'));
+
+      assert.isTrue(scoreChips[1].classList.contains('negative'));
+      assert.isTrue(scoreChips[1].classList.contains('min'));
+
+      assert.isTrue(scoreChips[2].classList.contains('positive'));
+      assert.isFalse(scoreChips[2].classList.contains('min'));
+    });
+
+    test('removed votes', () => {
+      element.message = {
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Verified+1 -Code-Review -Commit-Queue',
+      };
+      element.labelExtremes = {
+        'Verified': {max: 1, min: -1},
+        'Code-Review': {max: 2, min: -2},
+        'Commit-Queue': {max: 3, min: 0},
+      };
+      flushAsynchronousOperations();
+      const scoreChips = dom(element.root).querySelectorAll('.score');
+      assert.equal(scoreChips.length, 3);
+
+      assert.isTrue(scoreChips[1].classList.contains('removed'));
+      assert.isTrue(scoreChips[2].classList.contains('removed'));
+    });
+
+    test('false negative vote', () => {
+      element.message = {
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
+      };
+      element.labelExtremes = {};
+      const scoreChips = dom(element.root).querySelectorAll('.score');
+      assert.equal(scoreChips.length, 0);
+    });
+  });
+
+  suite('when not logged in', () => {
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+        getPreferences() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
+        getIsAdmin() { return Promise.resolve(false); },
+        deleteChangeCommitMessage() { return Promise.resolve({}); },
+      });
+      element = basicFixture.instantiate();
+      flush(done);
+    });
+
+    test('reply and delete button should be hidden', () => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+        expanded: true,
+      };
+
+      flushAsynchronousOperations();
+      assert.isTrue(
+          element.shadowRoot.querySelector('.replyActionContainer').hidden
+      );
+      assert.isTrue(
+          element.shadowRoot.querySelector('.deleteBtn').hidden
+      );
+    });
+  });
+
+  suite('patchset comment summary', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.message = {id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3'};
+    });
+
+    test('single patchset comment posted', () => {
+      const threads = [{
+        comments: [{
+          __path: '/PATCHSET_LEVEL',
+          change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
+          patch_set: 1,
+          id: 'e365b138_bed65caa',
+          updated: '2020-05-15 13:35:56.000000000',
+          message: 'testing the load',
+          unresolved: false,
+          path: '/PATCHSET_LEVEL',
+          collapsed: false,
+        }],
+        patchNum: 1,
+        path: '/PATCHSET_LEVEL',
+        rootId: 'e365b138_bed65caa',
+      }];
+      assert.equal(element._computeMessageContentCollapsed(
+          '', undefined, threads), 'testing the load');
+      assert.equal(element._computeMessageContent('', undefined, false), '');
+    });
+
+    test('single patchset comment with reply', () => {
+      const threads = [{
+        comments: [{
+          __path: '/PATCHSET_LEVEL',
+          patch_set: 1,
+          id: 'e365b138_bed65caa',
+          updated: '2020-05-15 13:35:56.000000000',
+          message: 'testing the load',
+          unresolved: false,
+          path: '/PATCHSET_LEVEL',
+          collapsed: false,
+        }, {
+          __path: '/PATCHSET_LEVEL',
+          change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
+          patch_set: 1,
+          id: 'd6efcc85_4cbbb6f4',
+          in_reply_to: 'e365b138_bed65caa',
+          updated: '2020-05-15 16:55:28.000000000',
+          message: 'n',
+          unresolved: false,
+          path: '/PATCHSET_LEVEL',
+          __draft: true,
+          collapsed: true,
+        }],
+        patchNum: 1,
+        path: '/PATCHSET_LEVEL',
+        rootId: 'e365b138_bed65caa',
+      }];
+      assert.equal(element._computeMessageContentCollapsed(
+          '', undefined, threads), 'n');
+      assert.equal(element._computeMessageContent('', undefined, false), '');
+    });
+  });
+
+  suite('when logged in but not admin', () => {
+    setup(done => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getConfig() { return Promise.resolve({}); },
+        getIsAdmin() { return Promise.resolve(false); },
+        deleteChangeCommitMessage() { return Promise.resolve({}); },
+      });
+      element = basicFixture.instantiate();
+      flush(done);
+    });
+
+    test('can see reply but not delete button', () => {
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
+        expanded: true,
+      };
+
+      flushAsynchronousOperations();
+      assert.isFalse(
+          element.shadowRoot.querySelector('.replyActionContainer').hidden
+      );
+      assert.isTrue(
+          element.shadowRoot.querySelector('.deleteBtn').hidden
+      );
+    });
+
+    test('reply button shown when message is updated', () => {
+      element.message = undefined;
+      flushAsynchronousOperations();
+      let replyEl = element.shadowRoot.querySelector('.replyActionContainer');
+      // We don't even expect the button to show up in the DOM when the message
+      // is undefined.
+      assert.isNotOk(replyEl);
+
+      element.message = {
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
+        },
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'not empty',
+        _revision_number: 1,
+        expanded: true,
+      };
+      flushAsynchronousOperations();
+      replyEl = element.shadowRoot.querySelector('.replyActionContainer');
+      assert.isOk(replyEl);
+      assert.isFalse(replyEl.hidden);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
deleted file mode 100644
index 2da1432..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental.js
+++ /dev/null
@@ -1,346 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import '@polymer/paper-toggle-button/paper-toggle-button.js';
-import '../../core/gr-reporting/gr-reporting.js';
-import '../../shared/gr-button/gr-button.js';
-import '../gr-message/gr-message.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-messages-list-experimental_html.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {util} from '../../../scripts/util.js';
-
-/**
- * The content of the enum is also used in the UI for the button text.
- *
- * @enum {string}
- */
-const ExpandAllState = {
-  EXPAND_ALL: 'Expand All',
-  COLLAPSE_ALL: 'Collapse All',
-};
-
-/**
- * @extends Polymer.Element
- */
-class GrMessagesListExperimental extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-messages-list-experimental'; }
-
-  static get properties() {
-    return {
-      changeNum: Number,
-      /**
-       * These are just the change messages. They are combined with reviewer
-       * updates below. So _combinedMessages is the more important property.
-       */
-      messages: {
-        type: Array,
-        value() { return []; },
-      },
-      /**
-       * These are just the reviewer updates. They are combined with change
-       * messages above. So _combinedMessages is the more important property.
-       */
-      reviewerUpdates: {
-        type: Array,
-        value() { return []; },
-      },
-      changeComments: Object,
-      projectName: String,
-      showReplyButtons: {
-        type: Boolean,
-        value: false,
-      },
-      labels: Object,
-
-      /**
-       * Keeps track of the state of the "Expand All" toggle button. Note that
-       * you can individually expand/collapse some messages without affecting
-       * the toggle button's state.
-       *
-       * @type {ExpandAllState}
-       */
-      _expandAllState: {
-        type: String,
-        value: ExpandAllState.EXPAND_ALL,
-      },
-      _expandAllTitle: {
-        type: String,
-        computed: '_computeExpandAllTitle(_expandAllState)',
-      },
-
-      _hideAutomated: {
-        type: Boolean,
-        value: false,
-        observer: '_hideAutomatedChanged',
-      },
-      /**
-       * The merged array of change messages and reviewer updates.
-       */
-      _combinedMessages: {
-        type: Array,
-        computed: '_computeCombinedMessages(messages, reviewerUpdates)',
-        observer: '_combinedMessagesChanged',
-      },
-
-      _labelExtremes: {
-        type: Object,
-        computed: '_computeLabelExtremes(labels.*)',
-      },
-    };
-  }
-
-  scrollToMessage(messageID) {
-    const selector = `[data-message-id="${messageID}"]`;
-    const el = this.shadowRoot.querySelector(selector);
-
-    if (!el && !this._hideAutomated) {
-      console.warn(`Failed to scroll to message: ${messageID}`);
-      return;
-    }
-    if (!el) {
-      this._hideAutomated = false;
-      setTimeout(() => this.scrollToMessage(messageID));
-      return;
-    }
-
-    el.set('message.expanded', true);
-    let top = el.offsetTop;
-    for (let offsetParent = el.offsetParent;
-      offsetParent;
-      offsetParent = offsetParent.offsetParent) {
-      top += offsetParent.offsetTop;
-    }
-    window.scrollTo(0, top);
-    this._highlightEl(el);
-  }
-
-  _isAutomated(message) {
-    const isReviewerUpdate =
-        !!(message.reviewer || message.type === 'REVIEWER_UPDATE');
-    const isAutoGenerated =
-        !!(message.tag && message.tag.startsWith('autogenerated'));
-    return isReviewerUpdate || isAutoGenerated;
-  }
-
-  _hideAutomatedChanged(hideAutomated) {
-    // We have to call render() such that the dom-repeat filter picks up the
-    // change.
-    this.$.messageRepeat.render();
-  }
-
-  /**
-   * Filter for the dom-repeat of combinedMessages.
-   */
-  _isMessageVisible(message) {
-    return !(this._hideAutomated && this._isAutomated(message));
-  }
-
-  /**
-   * Merges change messages and reviewer updates into one array.
-   */
-  _computeCombinedMessages(messages, reviewerUpdates) {
-    messages = messages || [];
-    reviewerUpdates = reviewerUpdates || [];
-    let mi = 0;
-    let ri = 0;
-    let combinedMessages = [];
-    let mDate;
-    let rDate;
-    for (let i = 0; i < messages.length; i++) {
-      messages[i]._index = i;
-    }
-
-    while (mi < messages.length || ri < reviewerUpdates.length) {
-      if (mi >= messages.length) {
-        combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
-        break;
-      }
-      if (ri >= reviewerUpdates.length) {
-        combinedMessages = combinedMessages.concat(messages.slice(mi));
-        break;
-      }
-      mDate = mDate || util.parseDate(messages[mi].date);
-      rDate = rDate || util.parseDate(reviewerUpdates[ri].date);
-      if (rDate < mDate) {
-        combinedMessages.push(reviewerUpdates[ri++]);
-        rDate = null;
-      } else {
-        combinedMessages.push(messages[mi++]);
-        mDate = null;
-      }
-    }
-    combinedMessages.forEach(m => {
-      if (m.expanded === undefined) {
-        m.expanded = false;
-      }
-    });
-    return combinedMessages;
-  }
-
-  _updateExpandedStateOfAllMessages(exp) {
-    if (this._combinedMessages) {
-      for (let i = 0; i < this._combinedMessages.length; i++) {
-        this._combinedMessages[i].expanded = exp;
-        this.notifyPath(`_combinedMessages.${i}.expanded`);
-      }
-    }
-  }
-
-  _computeExpandAllTitle(_expandAllState) {
-    if (_expandAllState === ExpandAllState.COLLAPSED_ALL) {
-      return this.createTitle(
-          this.Shortcut.COLLAPSE_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
-    }
-    if (_expandAllState === ExpandAllState.EXPAND_ALL) {
-      return this.createTitle(
-          this.Shortcut.EXPAND_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
-    }
-    return '';
-  }
-
-  _highlightEl(el) {
-    const highlightedEls =
-        dom(this.root).querySelectorAll('.highlighted');
-    for (const highlighedEl of highlightedEls) {
-      highlighedEl.classList.remove('highlighted');
-    }
-    function handleAnimationEnd() {
-      el.removeEventListener('animationend', handleAnimationEnd);
-      el.classList.remove('highlighted');
-    }
-    el.addEventListener('animationend', handleAnimationEnd);
-    el.classList.add('highlighted');
-  }
-
-  /**
-   * @param {boolean} expand
-   */
-  handleExpandCollapse(expand) {
-    this._expandAllState = expand ? ExpandAllState.COLLAPSE_ALL
-      : ExpandAllState.EXPAND_ALL;
-    this._updateExpandedStateOfAllMessages(expand);
-  }
-
-  _handleExpandCollapseTap(e) {
-    e.preventDefault();
-    this.handleExpandCollapse(
-        this._expandAllState === ExpandAllState.EXPAND_ALL);
-  }
-
-  _handleAnchorClick(e) {
-    this.scrollToMessage(e.detail.id);
-  }
-
-  _hasAutomatedMessages(messages) {
-    if (!messages) { return false; }
-    for (const message of messages) {
-      if (this._isAutomated(message)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  /**
-   * Computes message author's file comments for change's message. The backend
-   * sets comment.change_message_id for matching, so this computation is fairly
-   * straightforward.
-   *
-   * @param {!Object} changeComments changeComment object, which includes
-   *     a method to get all published comments (including robot comments),
-   *     which returns a Hash of arrays of comments, filename as key.
-   * @param {!Object} message
-   * @return {!Object} Hash of arrays of comments, filename as key.
-   */
-  _computeCommentsForMessage(changeComments, message) {
-    if ([changeComments, message].some(arg => arg === undefined)) {
-      return {};
-    }
-    const comments = changeComments.getAllPublishedComments();
-    if (message._index === undefined || !comments || !this.messages) {
-      return {};
-    }
-    const idFilter = comment => comment.change_message_id === message.id;
-
-    const msgComments = {};
-    for (const file in comments) {
-      if (!comments.hasOwnProperty(file)) { continue; }
-      const filtered = comments[file].filter(idFilter);
-      if (filtered.length) msgComments[file] = filtered;
-    }
-    return msgComments;
-  }
-
-  /**
-   * This method is for reporting stats only.
-   */
-  _combinedMessagesChanged(combinedMessages) {
-    if (combinedMessages) {
-      if (combinedMessages.length === 0) return;
-      const tags = combinedMessages.map(
-          message => message.tag || message.type ||
-              (message.comments ? 'comments' : 'none'));
-      const tagsCounted = tags.reduce((acc, val) => {
-        acc[val] = (acc[val] || 0) + 1;
-        return acc;
-      }, {all: combinedMessages.length});
-      this.$.reporting.reportInteraction('messages-count', tagsCounted);
-    }
-  }
-
-  /**
-   * Compute a mapping from label name to objects representing the minimum and
-   * maximum possible values for that label.
-   */
-  _computeLabelExtremes(labelRecord) {
-    const extremes = {};
-    const labels = labelRecord.base;
-    if (!labels) { return extremes; }
-    for (const key of Object.keys(labels)) {
-      if (!labels[key] || !labels[key].values) { continue; }
-      const values = Object.keys(labels[key].values)
-          .map(v => parseInt(v, 10));
-      values.sort((a, b) => a - b);
-      if (!values.length) { continue; }
-      extremes[key] = {min: values[0], max: values[values.length - 1]};
-    }
-    return extremes;
-  }
-
-  /**
-   * Work around a issue on iOS when clicking turns into double tap
-   */
-  _onTapAutomatedMessageToggle(e) {
-    e.preventDefault();
-  }
-}
-
-customElements.define(GrMessagesListExperimental.is,
-    GrMessagesListExperimental);
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
deleted file mode 100644
index 394d728..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_html.js
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: flex;
-      justify-content: space-between;
-    }
-    .header {
-      align-items: center;
-      border-top: 1px solid var(--border-color);
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    .highlighted {
-      animation: 3s fadeOut;
-    }
-    @keyframes fadeOut {
-      0% {
-        background-color: var(--emphasis-color);
-      }
-      100% {
-        background-color: var(--view-background-color);
-      }
-    }
-    .container {
-      align-items: center;
-      display: flex;
-    }
-    gr-message:not(:last-of-type) {
-      border-bottom: 1px solid var(--border-color);
-    }
-    gr-message:nth-child(2n) {
-      background-color: var(--background-color-secondary);
-    }
-    gr-message:nth-child(2n + 1) {
-      background-color: var(--background-color-tertiary);
-    }
-  </style>
-  <div class="header">
-    <span
-      id="automatedMessageToggleContainer"
-      class="container"
-      hidden$="[[!_hasAutomatedMessages(messages)]]"
-    >
-      <paper-toggle-button
-        id="automatedMessageToggle"
-        checked="{{_hideAutomated}}"
-        on-tap="_onTapAutomatedMessageToggle"
-      ></paper-toggle-button
-      >Only comments
-      <span class="transparent separator"></span>
-    </span>
-    <gr-button
-      id="collapse-messages"
-      link=""
-      title="[[_expandAllTitle]]"
-      on-click="_handleExpandCollapseTap"
-    >
-      [[_expandAllState]]
-    </gr-button>
-  </div>
-  <template
-    id="messageRepeat"
-    is="dom-repeat"
-    items="[[_combinedMessages]]"
-    as="message"
-    filter="_isMessageVisible"
-  >
-    <gr-message
-      change-num="[[changeNum]]"
-      message="[[message]]"
-      comments="[[_computeCommentsForMessage(changeComments, message)]]"
-      project-name="[[projectName]]"
-      show-reply-button="[[showReplyButtons]]"
-      on-message-anchor-tap="_handleAnchorClick"
-      label-extremes="[[_labelExtremes]]"
-      data-message-id$="[[message.id]]"
-    ></gr-message>
-  </template>
-  <gr-reporting id="reporting" category="message-list"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html
deleted file mode 100644
index 9c22ab5..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list-experimental_test.html
+++ /dev/null
@@ -1,453 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2020 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-messages-list-experimental</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<dom-module id="comment-api-mock">
-  <template>
-    <gr-messages-list-experimental
-        id="messagesList"
-        change-comments="[[_changeComments]]"></gr-messages-list-experimental>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
-  </template>
-  </dom-module>
-
-<test-fixture id="basic">
-  <template>
-    <comment-api-mock>
-      <gr-messages-list-experimental></gr-messages-list-experimental>
-    </comment-api-mock>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../diff/gr-comment-api/gr-comment-api.js';
-import './gr-messages-list-experimental.js';
-import '../../diff/gr-comment-api/gr-comment-api-mock_test.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-const randomMessage = function(opt_params) {
-  const params = opt_params || {};
-  const author1 = {
-    _account_id: 1115495,
-    name: 'Andrew Bonventre',
-    email: 'andybons@chromium.org',
-  };
-  return {
-    id: params.id || Math.random().toString(),
-    date: params.date || '2016-01-12 20:28:33.038000',
-    message: params.message || Math.random().toString(),
-    _revision_number: params._revision_number || 1,
-    author: params.author || author1,
-  };
-};
-
-const randomAutomated = function(opt_params) {
-  return Object.assign({tag: 'autogenerated:gerrit:replace'},
-      randomMessage(opt_params));
-};
-
-suite('gr-messages-list-experimental tests', () => {
-  let element;
-  let messages;
-  let sandbox;
-  let commentApiWrapper;
-
-  const getMessages = function() {
-    return dom(element.root).querySelectorAll('gr-message');
-  };
-
-  const MESSAGE_ID_0 = '1234ccc949c6d482b061be6a28e10782abf0e7af';
-  const MESSAGE_ID_1 = '8c19ccc949c6d482b061be6a28e10782abf0e7af';
-  const MESSAGE_ID_2 = 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5';
-
-  const author = {
-    _account_id: 42,
-    name: 'Marvin the Paranoid Android',
-    email: 'marvin@sirius.org',
-  };
-
-  const comments = {
-    file1: [
-      {
-        message: 'message text',
-        change_message_id: MESSAGE_ID_0,
-        updated: '2016-09-27 00:18:03.000000000',
-        in_reply_to: '6505d749_f0bec0aa',
-        line: 62,
-        id: '6505d749_10ed44b2',
-        patch_set: 2,
-        author: {
-          email: 'some@email.com',
-          _account_id: 123,
-        },
-      },
-      {
-        message: 'message text',
-        change_message_id: MESSAGE_ID_1,
-        updated: '2016-09-27 00:18:03.000000000',
-        in_reply_to: 'c5912363_6b820105',
-        line: 42,
-        id: '450a935e_0f1c05db',
-        patch_set: 2,
-        author,
-      },
-      {
-        message: 'message text',
-        change_message_id: MESSAGE_ID_1,
-        updated: '2016-09-27 00:18:03.000000000',
-        in_reply_to: '6505d749_f0bec0aa',
-        line: 62,
-        id: '6505d749_10ed44b2',
-        patch_set: 2,
-        author,
-      },
-      {
-        message: 'message text',
-        change_message_id: MESSAGE_ID_2,
-        updated: '2016-09-27 00:18:03.000000000',
-        line: 64,
-        id: '34ed05d749_10ed44b2',
-        patch_set: 2,
-        author,
-      },
-    ],
-    file2: [
-      {
-        message: 'message text',
-        change_message_id: MESSAGE_ID_1,
-        updated: '2016-09-27 00:18:03.000000000',
-        in_reply_to: 'c5912363_4b7d450a',
-        line: 132,
-        id: '450a935e_4f260d25',
-        patch_set: 2,
-        author,
-      },
-    ],
-  };
-
-  suite('basic tests', () => {
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        getDiffComments() { return Promise.resolve(comments); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
-      sandbox = sinon.sandbox.create();
-      messages = _.times(3, randomMessage);
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = fixture('basic');
-      element = commentApiWrapper.$.messagesList;
-      element.messages = messages;
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('expand/collapse all', () => {
-      let allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        message._expanded = false;
-      }
-      MockInteractions.tap(allMessageEls[1]);
-      assert.isTrue(allMessageEls[1]._expanded);
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages'));
-      allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        assert.isTrue(message._expanded);
-      }
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages'));
-      allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        assert.isFalse(message._expanded);
-      }
-    });
-
-    test('expand/collapse from external keypress', () => {
-      // Start with one expanded message. -> not all collapsed
-      element.scrollToMessage(messages[1].id);
-      assert.isFalse([...getMessages()].filter(m => m._expanded).length == 0);
-
-      // Press 'z' -> all collapsed
-      element.handleExpandCollapse(false);
-      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
-
-      // Press 'x' -> all expanded
-      element.handleExpandCollapse(true);
-      assert.isTrue([...getMessages()].filter(m => !m._expanded).length == 0);
-
-      // Press 'z' -> all collapsed
-      element.handleExpandCollapse(false);
-      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
-    });
-
-    test('hide messages does not appear when no automated messages', () => {
-      assert.isOk(element.shadowRoot
-          .querySelector('#automatedMessageToggleContainer[hidden]'));
-    });
-
-    test('scroll to message', () => {
-      const allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        message.set('message.expanded', false);
-      }
-
-      const scrollToStub = sandbox.stub(window, 'scrollTo');
-      const highlightStub = sandbox.stub(element, '_highlightEl');
-
-      element.scrollToMessage('invalid');
-
-      for (const message of allMessageEls) {
-        assert.isFalse(message._expanded,
-            'expected gr-message to not be expanded');
-      }
-
-      const messageID = messages[1].id;
-      element.scrollToMessage(messageID);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('[data-message-id="' + messageID + '"]')
-              ._expanded);
-
-      assert.isTrue(scrollToStub.calledOnce);
-      assert.isTrue(highlightStub.calledOnce);
-    });
-
-    test('scroll to message offscreen', () => {
-      const scrollToStub = sandbox.stub(window, 'scrollTo');
-      const highlightStub = sandbox.stub(element, '_highlightEl');
-      element.messages = _.times(25, randomMessage);
-      flushAsynchronousOperations();
-      assert.isFalse(scrollToStub.called);
-      assert.isFalse(highlightStub.called);
-
-      const messageID = element.messages[1].id;
-      element.scrollToMessage(messageID);
-      assert.isTrue(scrollToStub.calledOnce);
-      assert.isTrue(highlightStub.calledOnce);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('[data-message-id="' + messageID + '"]')
-              ._expanded);
-    });
-
-    test('messages', () => {
-      const messages = [].concat(
-          randomMessage(),
-          {
-            _index: 5,
-            _revision_number: 4,
-            message: 'Uploaded patch set 4.',
-            date: '2016-09-28 13:36:33.000000000',
-            author,
-            id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
-          },
-          {
-            _index: 6,
-            _revision_number: 4,
-            message: 'Patch Set 4:\n\n(6 comments)',
-            date: '2016-09-28 13:36:33.000000000',
-            author,
-            id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5',
-          }
-      );
-      element.messages = messages;
-      const isAuthor = function(author, comment) {
-        return comment.author._account_id === author._account_id;
-      };
-      const isMarvin = isAuthor.bind(null, author);
-      flushAsynchronousOperations();
-      const messageElements = getMessages();
-      assert.equal(messageElements.length, messages.length);
-      assert.deepEqual(messageElements[1].message, messages[1]);
-      assert.deepEqual(messageElements[2].message, messages[2]);
-      assert.deepEqual(messageElements[1].comments.file1,
-          comments.file1.filter(isMarvin).filter(
-              c => c.change_message_id === messages[1].id));
-      assert.deepEqual(messageElements[1].comments.file2,
-          comments.file2.filter(isMarvin).filter(
-              c => c.change_message_id === messages[1].id));
-      assert.deepEqual(messageElements[2].comments.file1,
-          comments.file1.filter(isMarvin).filter(
-              c => c.change_message_id === messages[2].id));
-      assert.isUndefined(messageElements[2].comments.file2);
-    });
-
-    test('messages without author do not throw', () => {
-      const messages = [{
-        _index: 5,
-        _revision_number: 4,
-        message: 'Uploaded patch set 4.',
-        date: '2016-09-28 13:36:33.000000000',
-        id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
-      }];
-      element.messages = messages;
-      flushAsynchronousOperations();
-      const messageEls = getMessages();
-      assert.equal(messageEls.length, 1);
-      assert.equal(messageEls[0].message.message, messages[0].message);
-    });
-  });
-
-  suite('gr-messages-list-experimental automate tests', () => {
-    let element;
-    let messages;
-    let sandbox;
-    let commentApiWrapper;
-
-    const getMessages = function() {
-      return dom(element.root).querySelectorAll('gr-message');
-    };
-    const getHiddenMessages = function() {
-      return dom(element.root).querySelectorAll('gr-message[hidden]');
-    };
-
-    const randomMessageReviewer = {
-      reviewer: {},
-      date: '2016-01-13 20:30:33.038000',
-    };
-
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
-
-      sandbox = sinon.sandbox.create();
-      messages = _.times(2, randomAutomated);
-      messages.push(randomMessageReviewer);
-
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = fixture('basic');
-      element = commentApiWrapper.$.messagesList;
-      sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
-      element.messages = messages;
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('hide autogenerated button is not hidden', () => {
-      assert.isNotOk(element.shadowRoot
-          .querySelector('#automatedMessageToggle[hidden]'));
-    });
-
-    test('autogenerated messages are not hidden initially', () => {
-      const allHiddenMessageEls = getHiddenMessages();
-
-      // There are no hidden messages.
-      assert.isFalse(!!allHiddenMessageEls.length);
-    });
-
-    test('autogenerated messages hidden after comments only toggle', () => {
-      let allHiddenMessageEls = getHiddenMessages();
-
-      element._hideAutomated = false;
-      MockInteractions.tap(element.$.automatedMessageToggle);
-      flushAsynchronousOperations();
-      const allMessageEls = getMessages();
-      allHiddenMessageEls = getHiddenMessages();
-
-      // Autogenerated messages are now hidden.
-      assert.equal(allHiddenMessageEls.length, allMessageEls.length);
-    });
-
-    test('autogenerated messages not hidden after comments only toggle',
-        () => {
-          let allHiddenMessageEls = getHiddenMessages();
-
-          element._hideAutomated = true;
-          MockInteractions.tap(element.$.automatedMessageToggle);
-          allHiddenMessageEls = getHiddenMessages();
-
-          // Autogenerated messages are now hidden.
-          assert.isFalse(!!allHiddenMessageEls.length);
-        });
-
-    test('_computeLabelExtremes', () => {
-      const computeSpy = sandbox.spy(element, '_computeLabelExtremes');
-
-      element.labels = null;
-      assert.isTrue(computeSpy.calledOnce);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {};
-      assert.isTrue(computeSpy.calledTwice);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {'my-label': {}};
-      assert.isTrue(computeSpy.calledThrice);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {'my-label': {values: {}}};
-      assert.equal(computeSpy.callCount, 4);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {'my-label': {values: {'-12': {}}}};
-      assert.equal(computeSpy.callCount, 5);
-      assert.deepEqual(computeSpy.lastCall.returnValue,
-          {'my-label': {min: -12, max: -12}});
-
-      element.labels = {
-        'my-label': {values: {'-2': {}, '-1': {}, '0': {}, '+1': {}, '+2': {}}},
-      };
-      assert.equal(computeSpy.callCount, 6);
-      assert.deepEqual(computeSpy.lastCall.returnValue,
-          {'my-label': {min: -2, max: 2}});
-
-      element.labels = {
-        'my-label': {values: {'-12': {}}},
-        'other-label': {values: {'-1': {}, ' 0': {}, '+1': {}}},
-      };
-      assert.equal(computeSpy.callCount, 7);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {
-        'my-label': {min: -12, max: -12},
-        'other-label': {min: -1, max: 1},
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index 3cd23d5..342d5bb 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
+ * Copyright (C) 2020 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,29 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/paper-toggle-button/paper-toggle-button.js';
-import '../../core/gr-reporting/gr-reporting.js';
 import '../../shared/gr-button/gr-button.js';
+import '../../shared/gr-icons/gr-icons.js';
 import '../gr-message/gr-message.js';
 import '../../../styles/shared-styles.js';
-import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-messages-list_html.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {util} from '../../../scripts/util.js';
-
-const MAX_INITIAL_SHOWN_MESSAGES = 20;
-const MESSAGES_INCREMENT = 5;
-
-const ReportingEvent = {
-  SHOW_ALL: 'show-all-messages',
-  SHOW_MORE: 'show-more-messages',
-};
+import {
+  KeyboardShortcutMixin,
+  Shortcut, ShortcutSection,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {parseDate} from '../../../utils/date-util.js';
+import {MessageTag} from '../../../constants/constants.js';
+import {appContext} from '../../../services/app-context.js';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -49,24 +43,141 @@
 };
 
 /**
- * @extends Polymer.Element
+ * Computes message author's comments for this change message. The backend
+ * sets comment.change_message_id for matching, so this computation is fairly
+ * straightforward.
  */
-class GrMessagesList extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+function computeThreads(message, allMessages, changeComments) {
+  if ([message, allMessages, changeComments].includes(undefined)) {
+    return [];
+  }
+  if (message._index === undefined) {
+    return [];
+  }
+
+  return changeComments.getAllThreadsForChange().filter(
+      thread => thread.comments.map(comment => {
+        // collapse all by default
+        comment.collapsed = true;
+        return comment;
+      }).some(comment => {
+        const condition = comment.change_message_id === message.id;
+        // Since getAllThreadsForChange() always returns a new copy of
+        // all comments we can modify them here without worrying about
+        // polluting other threads.
+        comment.collapsed = !condition;
+        return condition;
+      })
+  );
+}
+
+/**
+ * If messages have the same tag, then that influences grouping and whether
+ * a message is initally hidden or not, see isImportant(). So we are applying
+ * some "magic" rules here in order to hide exactly the right messages.
+ *
+ * 1. If a message does not have a tag, but is associated with robot comments,
+ * then it gets a tag.
+ *
+ * 2. Use the same tag for some of Gerrit's standard events, if they should be
+ * considered one group, e.g. normal and wip patchset uploads.
+ *
+ * 3. Everything beyond the ~ character is cut off from the tag. That gives
+ * tools control over which messages will be hidden.
+ */
+function computeTag(message) {
+  if (!message.tag) {
+    const threads = message.commentThreads || [];
+    const comments = threads.map(
+        t => t.comments.find(c => c.change_message_id === message.id));
+    const isRobot = comments.some(c => c && !!c.robot_id);
+    return isRobot ? 'autogenerated:has-robot-comments' : undefined;
+  }
+
+  if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) {
+    return MessageTag.TAG_NEW_PATCHSET;
+  }
+  if (message.tag === MessageTag.TAG_UNSET_ASSIGNEE) {
+    return MessageTag.TAG_SET_ASSIGNEE;
+  }
+  if (message.tag === MessageTag.TAG_UNSET_PRIVATE) {
+    return MessageTag.TAG_SET_PRIVATE;
+  }
+  if (message.tag === MessageTag.TAG_SET_WIP) {
+    return MessageTag.TAG_SET_READY;
+  }
+
+  return message.tag.replace(/~.*/, '');
+}
+
+/**
+ * Try to set a revision number that makes sense, if none is set. Just copy
+ * over the revision number of the next older message. This is mostly relevant
+ * for reviewer updates. Other messages should typically have the revision
+ * number already set.
+ */
+function computeRevision(message, allMessages) {
+  if (message._revision_number > 0) return message._revision_number;
+  let revision = 0;
+  for (const m of allMessages) {
+    if (m.date > message.date) break;
+    if (m._revision_number > revision) revision = m._revision_number;
+  }
+  return revision > 0 ? revision : undefined;
+}
+
+/**
+ * Unimportant messages are initially hidden.
+ *
+ * Human messages are always important. They have an undefined tag.
+ *
+ * Autogenerated messages are unimportant, if there is a message with the same
+ * tag and a higher revision number.
+ */
+function computeIsImportant(message, allMessages) {
+  if (!message.tag) return true;
+
+  const hasSameTag = m => m.tag === message.tag;
+  const revNumber = message._revision_number || 0;
+  const hasHigherRevisionNumber = m => m._revision_number > revNumber;
+  return !allMessages.filter(hasSameTag).some(hasHigherRevisionNumber);
+}
+
+export const TEST_ONLY = {
+  computeThreads,
+  computeTag,
+  computeRevision,
+  computeIsImportant,
+};
+
+/**
+ * @extends PolymerElement
+ */
+class GrMessagesList extends KeyboardShortcutMixin(
+    GestureEventListeners(
+        LegacyElementMixin(
+            PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-messages-list'; }
 
   static get properties() {
     return {
+      /** @type {?} */
+      change: Object,
       changeNum: Number,
+      /**
+       * These are just the change messages. They are combined with reviewer
+       * updates below. So _combinedMessages is the more important property.
+       */
       messages: {
         type: Array,
         value() { return []; },
       },
+      /**
+       * These are just the reviewer updates. They are combined with change
+       * messages above. So _combinedMessages is the more important property.
+       */
       reviewerUpdates: {
         type: Array,
         value() { return []; },
@@ -95,24 +206,19 @@
         computed: '_computeExpandAllTitle(_expandAllState)',
       },
 
-      _hideAutomated: {
+      _showAllActivity: {
         type: Boolean,
         value: false,
+        observer: '_observeShowAllActivity',
       },
       /**
-       * The messages after processing and including merged reviewer updates.
+       * The merged array of change messages and reviewer updates.
        */
-      _processedMessages: {
+      _combinedMessages: {
         type: Array,
-        computed: '_computeItems(messages, reviewerUpdates)',
-        observer: '_processedMessagesChanged',
-      },
-      /**
-       * The subset of _processedMessages that is visible to the user.
-       */
-      _visibleMessages: {
-        type: Array,
-        value() { return []; },
+        computed: '_computeCombinedMessages(messages, reviewerUpdates, '
+            + 'changeComments)',
+        observer: '_combinedMessagesChanged',
       },
 
       _labelExtremes: {
@@ -122,28 +228,23 @@
     };
   }
 
-  scrollToMessage(messageID) {
-    let el = this.shadowRoot
-        .querySelector('[data-message-id="' + messageID + '"]');
-    // If the message is hidden, expand the hidden messages back to that
-    // point.
-    if (!el) {
-      let index;
-      for (index = 0; index < this._processedMessages.length; index++) {
-        if (this._processedMessages[index].id === messageID) {
-          break;
-        }
-      }
-      if (index === this._processedMessages.length) { return; }
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
 
-      const newMessages = this._processedMessages.slice(index,
-          -this._visibleMessages.length);
-      // Add newMessages to the beginning of _visibleMessages.
-      this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
-      // Allow the dom-repeat to stamp.
-      flush();
-      el = this.shadowRoot
-          .querySelector('[data-message-id="' + messageID + '"]');
+  scrollToMessage(messageID) {
+    const selector = `[data-message-id="${messageID}"]`;
+    const el = this.shadowRoot.querySelector(selector);
+
+    if (!el && this._showAllActivity) {
+      console.warn(`Failed to scroll to message: ${messageID}`);
+      return;
+    }
+    if (!el) {
+      this._showAllActivity = true;
+      setTimeout(() => this.scrollToMessage(messageID));
+      return;
     }
 
     el.set('message.expanded', true);
@@ -157,22 +258,30 @@
     this._highlightEl(el);
   }
 
-  _isAutomated(message) {
-    return !!(message.reviewer ||
-        (message.tag && message.tag.startsWith('autogenerated')));
+  _observeShowAllActivity(showAllActivity) {
+    // We have to call render() such that the dom-repeat filter picks up the
+    // change.
+    this.$.messageRepeat.render();
   }
 
-  _computeItems(messages, reviewerUpdates) {
-    // Polymer 2: check for undefined
-    if ([messages, reviewerUpdates].some(arg => arg === undefined)) {
-      return [];
-    }
+  /**
+   * Filter for the dom-repeat of combinedMessages.
+   */
+  _isMessageVisible(message) {
+    return this._showAllActivity || message.isImportant;
+  }
 
-    messages = messages || [];
-    reviewerUpdates = reviewerUpdates || [];
+  /**
+   * Merges change messages and reviewer updates into one array. Also processes
+   * all messages and updates, aligns or massages some of the properties.
+   */
+  _computeCombinedMessages(messages, reviewerUpdates, changeComments) {
+    const params = [messages, reviewerUpdates, changeComments];
+    if (params.some(o => o === undefined)) return [];
+
     let mi = 0;
     let ri = 0;
-    let result = [];
+    let combinedMessages = [];
     let mDate;
     let rDate;
     for (let i = 0; i < messages.length; i++) {
@@ -181,56 +290,57 @@
 
     while (mi < messages.length || ri < reviewerUpdates.length) {
       if (mi >= messages.length) {
-        result = result.concat(reviewerUpdates.slice(ri));
+        combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
         break;
       }
       if (ri >= reviewerUpdates.length) {
-        result = result.concat(messages.slice(mi));
+        combinedMessages = combinedMessages.concat(messages.slice(mi));
         break;
       }
-      mDate = mDate || util.parseDate(messages[mi].date);
-      rDate = rDate || util.parseDate(reviewerUpdates[ri].date);
+      mDate = mDate || parseDate(messages[mi].date);
+      rDate = rDate || parseDate(reviewerUpdates[ri].date);
       if (rDate < mDate) {
-        result.push(reviewerUpdates[ri++]);
+        combinedMessages.push(reviewerUpdates[ri++]);
         rDate = null;
       } else {
-        result.push(messages[mi++]);
+        combinedMessages.push(messages[mi++]);
         mDate = null;
       }
     }
-    result.forEach(m => {
+    combinedMessages.forEach(m => {
       if (m.expanded === undefined) {
         m.expanded = false;
       }
+      m.commentThreads = computeThreads(m, combinedMessages, changeComments);
+      m._revision_number = computeRevision(m, combinedMessages);
+      m.tag = computeTag(m);
     });
-    return result;
+    // computeIsImportant() depends on tags and revision numbers already being
+    // updated for all messages, so we have to compute this in its own forEach
+    // loop.
+    combinedMessages.forEach(m => {
+      m.isImportant = computeIsImportant(m, combinedMessages);
+    });
+    return combinedMessages;
   }
 
-  _updateExpandedStateOfAllMessages(expanded) {
-    if (this._processedMessages) {
-      for (let i = 0; i < this._processedMessages.length; i++) {
-        this._processedMessages[i].expanded = expanded;
-      }
-    }
-    // _visibleMessages is a subarray of _processedMessages
-    // _processedMessages contains all items from _visibleMessages
-    // At this point all _visibleMessages.expanded values are set,
-    // and notifyPath must be used to notify Polymer about changes.
-    if (this._visibleMessages) {
-      for (let i = 0; i < this._visibleMessages.length; i++) {
-        this.notifyPath(`_visibleMessages.${i}.expanded`);
+  _updateExpandedStateOfAllMessages(exp) {
+    if (this._combinedMessages) {
+      for (let i = 0; i < this._combinedMessages.length; i++) {
+        this._combinedMessages[i].expanded = exp;
+        this.notifyPath(`_combinedMessages.${i}.expanded`);
       }
     }
   }
 
   _computeExpandAllTitle(_expandAllState) {
-    if (_expandAllState === ExpandAllState.COLLAPSED_ALL) {
+    if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
       return this.createTitle(
-          this.Shortcut.COLLAPSE_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
+          Shortcut.COLLAPSE_ALL_MESSAGES, ShortcutSection.ACTIONS);
     }
     if (_expandAllState === ExpandAllState.EXPAND_ALL) {
       return this.createTitle(
-          this.Shortcut.EXPAND_ALL_MESSAGES, this.ShortcutSection.ACTIONS);
+          Shortcut.EXPAND_ALL_MESSAGES, ShortcutSection.ACTIONS);
     }
     return '';
   }
@@ -238,8 +348,8 @@
   _highlightEl(el) {
     const highlightedEls =
         dom(this.root).querySelectorAll('.highlighted');
-    for (const highlighedEl of highlightedEls) {
-      highlighedEl.classList.remove('highlighted');
+    for (const highlightedEl of highlightedEls) {
+      highlightedEl.classList.remove('highlighted');
     }
     function handleAnimationEnd() {
       el.removeEventListener('animationend', handleAnimationEnd);
@@ -268,181 +378,31 @@
     this.scrollToMessage(e.detail.id);
   }
 
-  _hasAutomatedMessages(messages) {
-    if (!messages) { return false; }
-    for (const message of messages) {
-      if (this._isAutomated(message)) {
-        return true;
-      }
-    }
-    return false;
+  _isVisibleShowAllActivityToggle(messages = []) {
+    return messages.some(m => !m.isImportant);
+  }
+
+  _computeHiddenEntriesCount(messages = []) {
+    return messages.filter(m => !m.isImportant).length;
   }
 
   /**
-   * Computes message author's file comments for change's message.
-   * Method uses this.messages to find next message and relies on messages
-   * to be sorted by date field descending.
-   *
-   * @param {!Object} changeComments changeComment object, which includes
-   *     a method to get all published comments (including robot comments),
-   *     which returns a Hash of arrays of comments, filename as key.
-   * @param {!Object} message
-   * @return {!Object} Hash of arrays of comments, filename as key.
+   * This method is for reporting stats only.
    */
-  _computeCommentsForMessage(changeComments, message) {
-    if ([changeComments, message].some(arg => arg === undefined)) {
-      return {};
-    }
-    const comments = changeComments.getAllPublishedComments();
-    if (message._index === undefined || !comments || !this.messages) {
-      return {};
-    }
-    const messages = this.messages || [];
-    const index = message._index;
-    const authorId = message.author && message.author._account_id;
-    const mDate = util.parseDate(message.date).getTime();
-    // NB: Messages array has oldest messages first.
-    let nextMDate;
-    if (index > 0) {
-      for (let i = index - 1; i >= 0; i--) {
-        if (messages[i] && messages[i].author &&
-            messages[i].author._account_id === authorId) {
-          nextMDate = util.parseDate(messages[i].date).getTime();
-          break;
-        }
-      }
-    }
-    const msgComments = {};
-    for (const file in comments) {
-      if (!comments.hasOwnProperty(file)) { continue; }
-      const fileComments = comments[file];
-      for (let i = 0; i < fileComments.length; i++) {
-        if (fileComments[i].author &&
-            fileComments[i].author._account_id !== authorId) {
-          continue;
-        }
-        const cDate = util.parseDate(fileComments[i].updated).getTime();
-        if (cDate <= mDate) {
-          if (nextMDate && cDate <= nextMDate) {
-            continue;
-          }
-          msgComments[file] = msgComments[file] || [];
-          msgComments[file].push(fileComments[i]);
-        }
-      }
-    }
-    return msgComments;
-  }
-
-  /**
-   * Returns the number of messages to splice to the beginning of
-   * _visibleMessages. This is the minimum of the total number of messages
-   * remaining in the list and the number of messages needed to display five
-   * more visible messages in the list.
-   */
-  _getDelta(visibleMessages, messages, hideAutomated) {
-    if ([visibleMessages, messages].some(arg => arg === undefined)) {
-      return 0;
-    }
-
-    let delta = MESSAGES_INCREMENT;
-    const msgsRemaining = messages.length - visibleMessages.length;
-
-    if (hideAutomated) {
-      let counter = 0;
-      let i;
-      for (i = msgsRemaining; i > 0 && counter < MESSAGES_INCREMENT; i--) {
-        if (!this._isAutomated(messages[i - 1])) { counter++; }
-      }
-      delta = msgsRemaining - i;
-    }
-    return Math.min(msgsRemaining, delta);
-  }
-
-  /**
-   * Gets the number of messages that would be visible, but do not currently
-   * exist in _visibleMessages.
-   */
-  _numRemaining(visibleMessages, messages, hideAutomated) {
-    if ([visibleMessages, messages].some(arg => arg === undefined)) {
-      return 0;
-    }
-
-    if (hideAutomated) {
-      return this._getHumanMessages(messages).length -
-          this._getHumanMessages(visibleMessages).length;
-    }
-    return messages.length - visibleMessages.length;
-  }
-
-  _computeIncrementText(visibleMessages, messages, hideAutomated) {
-    let delta = this._getDelta(visibleMessages, messages, hideAutomated);
-    delta = Math.min(
-        this._numRemaining(visibleMessages, messages, hideAutomated), delta);
-    return 'Show ' + Math.min(MESSAGES_INCREMENT, delta) + ' more';
-  }
-
-  _getHumanMessages(messages) {
-    return messages.filter(msg => !this._isAutomated(msg));
-  }
-
-  _computeShowHideTextHidden(visibleMessages, messages,
-      hideAutomated) {
-    if ([visibleMessages, messages].some(arg => arg === undefined)) {
-      return 0;
-    }
-
-    if (hideAutomated) {
-      messages = this._getHumanMessages(messages);
-      visibleMessages = this._getHumanMessages(visibleMessages);
-    }
-    return visibleMessages.length >= messages.length;
-  }
-
-  _handleShowAllTap() {
-    this._visibleMessages = this._processedMessages;
-    this.$.reporting.reportInteraction(ReportingEvent.SHOW_ALL);
-  }
-
-  _handleIncrementShownMessages() {
-    const delta = this._getDelta(this._visibleMessages,
-        this._processedMessages, this._hideAutomated);
-    const len = this._visibleMessages.length;
-    const newMessages = this._processedMessages.slice(-(len + delta), -len);
-    // Add newMessages to the beginning of _visibleMessages
-    this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
-    this.$.reporting.reportInteraction(ReportingEvent.SHOW_MORE);
-  }
-
-  _processedMessagesChanged(messages) {
-    if (messages) {
-      this._visibleMessages = messages.slice(-MAX_INITIAL_SHOWN_MESSAGES);
-
-      if (messages.length === 0) return;
-      const tags = messages.map(message => message.tag || message.type ||
-          (message.comments ? 'comments' : 'none'));
+  _combinedMessagesChanged(combinedMessages) {
+    if (combinedMessages) {
+      if (combinedMessages.length === 0) return;
+      const tags = combinedMessages.map(
+          message => message.tag || message.type ||
+              (message.comments ? 'comments' : 'none'));
       const tagsCounted = tags.reduce((acc, val) => {
         acc[val] = (acc[val] || 0) + 1;
         return acc;
-      }, {all: messages.length});
-      this.$.reporting.reportInteraction('messages-count', tagsCounted);
+      }, {all: combinedMessages.length});
+      this.reporting.reportInteraction('messages-count', tagsCounted);
     }
   }
 
-  _computeNumMessagesText(visibleMessages, messages,
-      hideAutomated) {
-    const total =
-        this._numRemaining(visibleMessages, messages, hideAutomated);
-    return total === 1 ? 'Show 1 message' : 'Show all ' + total + ' messages';
-  }
-
-  _computeIncrementHidden(visibleMessages, messages,
-      hideAutomated) {
-    const total =
-        this._numRemaining(visibleMessages, messages, hideAutomated);
-    return total <= this._getDelta(visibleMessages, messages, hideAutomated);
-  }
-
   /**
    * Compute a mapping from label name to objects representing the minimum and
    * maximum possible values for that label.
@@ -465,9 +425,10 @@
   /**
    * Work around a issue on iOS when clicking turns into double tap
    */
-  _onTapHideAutomated(e) {
+  _onTapShowAllActivityToggle(e) {
     e.preventDefault();
   }
 }
 
-customElements.define(GrMessagesList.is, GrMessagesList);
+customElements.define(GrMessagesList.is,
+    GrMessagesList);
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
deleted file mode 100644
index e47af55..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.js
+++ /dev/null
@@ -1,133 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host,
-    .messageListControls {
-      display: flex;
-      justify-content: space-between;
-    }
-    .header {
-      align-items: center;
-      border-top: 1px solid var(--border-color);
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    #messageControlsContainer {
-      padding: 0 var(--spacing-l);
-    }
-    .highlighted {
-      animation: 3s fadeOut;
-    }
-    @keyframes fadeOut {
-      0% {
-        background-color: var(--emphasis-color);
-      }
-      100% {
-        background-color: var(--view-background-color);
-      }
-    }
-    #messageControlsContainer {
-      align-items: center;
-      background-color: var(--background-color-secondary);
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-      height: 2.25em;
-      justify-content: center;
-    }
-    #messageControlsContainer gr-button {
-      padding: var(--spacing-s) 0;
-    }
-    .container {
-      align-items: center;
-      display: flex;
-    }
-    gr-message:not(:last-of-type) {
-      border-bottom: 1px solid var(--border-color);
-    }
-    gr-message:nth-child(2n) {
-      background-color: var(--background-color-secondary);
-    }
-    gr-message:nth-child(2n + 1) {
-      background-color: var(--background-color-tertiary);
-    }
-  </style>
-  <div class="header">
-    <span
-      id="automatedMessageToggleContainer"
-      class="container"
-      hidden$="[[!_hasAutomatedMessages(messages)]]"
-    >
-      <paper-toggle-button
-        id="automatedMessageToggle"
-        checked="{{_hideAutomated}}"
-        on-tap="_onTapHideAutomated"
-      ></paper-toggle-button>
-      Only comments
-      <span class="transparent separator"></span>
-    </span>
-    <gr-button
-      id="collapse-messages"
-      link=""
-      title="[[_expandAllTitle]]"
-      on-click="_handleExpandCollapseTap"
-    >
-      [[_expandAllState]]
-    </gr-button>
-  </div>
-  <span
-    id="messageControlsContainer"
-    hidden$="[[_computeShowHideTextHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]"
-  >
-    <gr-button id="oldMessagesBtn" link="" on-click="_handleShowAllTap">
-      [[_computeNumMessagesText(_visibleMessages, _processedMessages,
-      _hideAutomated, _visibleMessages.length)]]
-    </gr-button>
-    <span
-      class="container"
-      hidden$="[[_computeIncrementHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]"
-    >
-      <span class="transparent separator"></span>
-      <gr-button
-        id="incrementMessagesBtn"
-        link=""
-        on-click="_handleIncrementShownMessages"
-      >
-        [[_computeIncrementText(_visibleMessages, _processedMessages,
-        _hideAutomated, _visibleMessages.length)]]
-      </gr-button>
-    </span>
-  </span>
-  <template is="dom-repeat" items="[[_visibleMessages]]" as="message">
-    <gr-message
-      change-num="[[changeNum]]"
-      message="[[message]]"
-      comments="[[_computeCommentsForMessage(changeComments, message)]]"
-      hide-automated="[[_hideAutomated]]"
-      project-name="[[projectName]]"
-      show-reply-button="[[showReplyButtons]]"
-      on-message-anchor-tap="_handleAnchorClick"
-      label-extremes="[[_labelExtremes]]"
-      data-message-id$="[[message.id]]"
-    ></gr-message>
-  </template>
-  <gr-reporting id="reporting" category="message-list"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
new file mode 100644
index 0000000..9c4ef04
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: flex;
+      justify-content: space-between;
+    }
+    .header {
+      align-items: center;
+      border-top: 1px solid var(--border-color);
+      border-bottom: 1px solid var(--border-color);
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-s) var(--spacing-l);
+    }
+    .highlighted {
+      animation: 3s fadeOut;
+    }
+    @keyframes fadeOut {
+      0% {
+        background-color: var(--emphasis-color);
+      }
+      100% {
+        background-color: var(--view-background-color);
+      }
+    }
+    .container {
+      align-items: center;
+      display: flex;
+    }
+    .hiddenEntries {
+      color: var(--deemphasized-text-color);
+    }
+    gr-message:not(:last-of-type) {
+      border-bottom: 1px solid var(--border-color);
+    }
+    gr-message {
+      background-color: var(--background-color-secondary);
+    }
+  </style>
+  <div class="header">
+    <div id="showAllActivityToggleContainer" class="container">
+      <template
+        is="dom-if"
+        if="[[_isVisibleShowAllActivityToggle(_combinedMessages)]]"
+      >
+        <paper-toggle-button
+          class="showAllActivityToggle"
+          checked="{{_showAllActivity}}"
+          aria-labelledby="showAllEntriesLabel"
+          role="switch"
+          on-tap="_onTapShowAllActivityToggle"
+        ></paper-toggle-button>
+        <div id="showAllEntriesLabel">
+          <span>Show all entries</span>
+          <span class="hiddenEntries" hidden$="[[_showAllActivity]]">
+            ([[_computeHiddenEntriesCount(_combinedMessages)]] hidden)
+          </span>
+        </div>
+        <span class="transparent separator"></span>
+      </template>
+    </div>
+    <gr-button
+      id="collapse-messages"
+      link=""
+      title="[[_expandAllTitle]]"
+      on-click="_handleExpandCollapseTap"
+    >
+      [[_expandAllState]]
+    </gr-button>
+  </div>
+  <template
+    id="messageRepeat"
+    is="dom-repeat"
+    items="[[_combinedMessages]]"
+    as="message"
+    filter="_isMessageVisible"
+  >
+    <gr-message
+      change="[[change]]"
+      change-num="[[changeNum]]"
+      message="[[message]]"
+      project-name="[[projectName]]"
+      show-reply-button="[[showReplyButtons]]"
+      on-message-anchor-tap="_handleAnchorClick"
+      label-extremes="[[_labelExtremes]]"
+      data-message-id$="[[message.id]]"
+    ></gr-message>
+  </template>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
deleted file mode 100644
index 80896aa..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ /dev/null
@@ -1,612 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-messages-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<dom-module id="comment-api-mock">
-  <template>
-    <gr-messages-list
-        id="messagesList"
-        change-comments="[[_changeComments]]"></gr-messages-list>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
-  </template>
-  </dom-module>
-
-<test-fixture id="basic">
-  <template>
-    <comment-api-mock>
-      <gr-messages-list></gr-messages-list>
-    </comment-api-mock>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../diff/gr-comment-api/gr-comment-api.js';
-import './gr-messages-list.js';
-import '../../diff/gr-comment-api/gr-comment-api-mock_test.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-const randomMessage = function(opt_params) {
-  const params = opt_params || {};
-  const author1 = {
-    _account_id: 1115495,
-    name: 'Andrew Bonventre',
-    email: 'andybons@chromium.org',
-  };
-  return {
-    id: params.id || Math.random().toString(),
-    date: params.date || '2016-01-12 20:28:33.038000',
-    message: params.message || Math.random().toString(),
-    _revision_number: params._revision_number || 1,
-    author: params.author || author1,
-  };
-};
-
-const randomAutomated = function(opt_params) {
-  return Object.assign({tag: 'autogenerated:gerrit:replace'},
-      randomMessage(opt_params));
-};
-
-suite('gr-messages-list tests', () => {
-  let element;
-  let messages;
-  let sandbox;
-  let commentApiWrapper;
-
-  const getMessages = function() {
-    return dom(element.root).querySelectorAll('gr-message');
-  };
-
-  const author = {
-    _account_id: 42,
-    name: 'Marvin the Paranoid Android',
-    email: 'marvin@sirius.org',
-  };
-
-  const comments = {
-    file1: [
-      {
-        message: 'message text',
-        updated: '2016-09-27 00:18:03.000000000',
-        in_reply_to: '6505d749_f0bec0aa',
-        line: 62,
-        id: '6505d749_10ed44b2',
-        patch_set: 2,
-        author: {
-          email: 'some@email.com',
-          _account_id: 123,
-        },
-      },
-      {
-        message: 'message text',
-        updated: '2016-09-27 00:18:03.000000000',
-        in_reply_to: 'c5912363_6b820105',
-        line: 42,
-        id: '450a935e_0f1c05db',
-        patch_set: 2,
-        author,
-      },
-      {
-        message: 'message text',
-        updated: '2016-09-27 00:18:03.000000000',
-        in_reply_to: '6505d749_f0bec0aa',
-        line: 62,
-        id: '6505d749_10ed44b2',
-        patch_set: 2,
-        author,
-      },
-    ],
-    file2: [
-      {
-        message: 'message text',
-        updated: '2016-09-27 00:18:03.000000000',
-        in_reply_to: 'c5912363_4b7d450a',
-        line: 132,
-        id: '450a935e_4f260d25',
-        patch_set: 2,
-        author,
-      },
-    ],
-  };
-
-  suite('basic tests', () => {
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        getDiffComments() { return Promise.resolve(comments); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
-      sandbox = sinon.sandbox.create();
-      messages = _.times(3, randomMessage);
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = fixture('basic');
-      element = commentApiWrapper.$.messagesList;
-      element.messages = messages;
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('show some old messages', () => {
-      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-      element.messages = _.times(26, randomMessage);
-      flushAsynchronousOperations();
-
-      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-      assert.equal(getMessages().length, 20);
-      assert.equal(element.$.incrementMessagesBtn.innerText.toUpperCase()
-          .trim(), 'SHOW 5 MORE');
-      MockInteractions.tap(element.$.incrementMessagesBtn);
-      flushAsynchronousOperations();
-
-      assert.equal(getMessages().length, 25);
-      assert.equal(element.$.incrementMessagesBtn.innerText.toUpperCase()
-          .trim(), 'SHOW 1 MORE');
-      MockInteractions.tap(element.$.incrementMessagesBtn);
-      flushAsynchronousOperations();
-
-      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-      assert.equal(getMessages().length, 26);
-    });
-
-    test('show all old messages', () => {
-      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-      element.messages = _.times(26, randomMessage);
-      flushAsynchronousOperations();
-
-      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-      assert.equal(getMessages().length, 20);
-      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
-          'SHOW ALL 6 MESSAGES');
-      MockInteractions.tap(element.$.oldMessagesBtn);
-      flushAsynchronousOperations();
-
-      assert.equal(getMessages().length, 26);
-      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-    });
-
-    test('message count respects automated', () => {
-      element.messages = _.times(10, randomAutomated)
-          .concat(_.times(11, randomMessage));
-      flushAsynchronousOperations();
-
-      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
-          'SHOW 1 MESSAGE');
-      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-      MockInteractions.tap(element.$.automatedMessageToggle);
-      flushAsynchronousOperations();
-
-      assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
-    });
-
-    test('message count still respects non-automated on toggle', () => {
-      element.messages = _.times(10, randomMessage)
-          .concat(_.times(11, randomAutomated));
-      flushAsynchronousOperations();
-
-      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
-          'SHOW 1 MESSAGE');
-      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-      MockInteractions.tap(element.$.automatedMessageToggle);
-      flushAsynchronousOperations();
-
-      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
-          'SHOW 1 MESSAGE');
-      assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
-    });
-
-    test('show all messages respects expand', () => {
-      element.messages = _.times(10, randomAutomated)
-          .concat(_.times(11, randomMessage));
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages')); // Expand all.
-      flushAsynchronousOperations();
-
-      let messages = getMessages();
-      assert.equal(messages.length, 20);
-      for (const message of messages) {
-        assert.isTrue(message._expanded);
-      }
-
-      MockInteractions.tap(element.$.oldMessagesBtn);
-      flushAsynchronousOperations();
-
-      messages = getMessages();
-      assert.equal(messages.length, 21);
-      for (const message of messages) {
-        assert.isTrue(message._expanded);
-      }
-    });
-
-    test('show all messages respects collapse', () => {
-      element.messages = _.times(10, randomAutomated)
-          .concat(_.times(11, randomMessage));
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages')); // Expand all.
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages')); // Collapse all.
-      flushAsynchronousOperations();
-
-      let messages = getMessages();
-      assert.equal(messages.length, 20);
-      for (const message of messages) {
-        assert.isFalse(message._expanded);
-      }
-
-      MockInteractions.tap(element.$.oldMessagesBtn);
-      flushAsynchronousOperations();
-
-      messages = getMessages();
-      assert.equal(messages.length, 21);
-      for (const message of messages) {
-        assert.isFalse(message._expanded);
-      }
-    });
-
-    test('expand/collapse all', () => {
-      let allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        message._expanded = false;
-      }
-      MockInteractions.tap(allMessageEls[1]);
-      assert.isTrue(allMessageEls[1]._expanded);
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages'));
-      allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        assert.isTrue(message._expanded);
-      }
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages'));
-      allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        assert.isFalse(message._expanded);
-      }
-    });
-
-    test('expand/collapse from external keypress', () => {
-      // Start with one expanded message. -> not all collapsed
-      element.scrollToMessage(messages[1].id);
-      assert.isFalse([...getMessages()].filter(m => m._expanded).length == 0);
-
-      // Press 'z' -> all collapsed
-      element.handleExpandCollapse(false);
-      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
-
-      // Press 'x' -> all expanded
-      element.handleExpandCollapse(true);
-      assert.isTrue([...getMessages()].filter(m => !m._expanded).length == 0);
-
-      // Press 'z' -> all collapsed
-      element.handleExpandCollapse(false);
-      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
-    });
-
-    test('hide messages does not appear when no automated messages', () => {
-      assert.isOk(element.shadowRoot
-          .querySelector('#automatedMessageToggleContainer[hidden]'));
-    });
-
-    test('scroll to message', () => {
-      const allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        message.set('message.expanded', false);
-      }
-
-      const scrollToStub = sandbox.stub(window, 'scrollTo');
-      const highlightStub = sandbox.stub(element, '_highlightEl');
-
-      element.scrollToMessage('invalid');
-
-      for (const message of allMessageEls) {
-        assert.isFalse(message._expanded,
-            'expected gr-message to not be expanded');
-      }
-
-      const messageID = messages[1].id;
-      element.scrollToMessage(messageID);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('[data-message-id="' + messageID + '"]')
-              ._expanded);
-
-      assert.isTrue(scrollToStub.calledOnce);
-      assert.isTrue(highlightStub.calledOnce);
-    });
-
-    test('scroll to message offscreen', () => {
-      const scrollToStub = sandbox.stub(window, 'scrollTo');
-      const highlightStub = sandbox.stub(element, '_highlightEl');
-      element.messages = _.times(25, randomMessage);
-      flushAsynchronousOperations();
-      assert.isFalse(scrollToStub.called);
-      assert.isFalse(highlightStub.called);
-
-      const messageID = element.messages[1].id;
-      element.scrollToMessage(messageID);
-      assert.isTrue(scrollToStub.calledOnce);
-      assert.isTrue(highlightStub.calledOnce);
-      assert.equal(element._visibleMessages.length, 24);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('[data-message-id="' + messageID + '"]')
-              ._expanded);
-    });
-
-    test('messages', () => {
-      const messages = [].concat(
-          randomMessage(),
-          {
-            _index: 5,
-            _revision_number: 4,
-            message: 'Uploaded patch set 4.',
-            date: '2016-09-28 13:36:33.000000000',
-            author,
-            id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
-          },
-          {
-            _index: 6,
-            _revision_number: 4,
-            message: 'Patch Set 4:\n\n(6 comments)',
-            date: '2016-09-28 13:36:33.000000000',
-            author,
-            id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5',
-          }
-      );
-      element.messages = messages;
-      const isAuthor = function(author, message) {
-        return message.author._account_id === author._account_id;
-      };
-      const isMarvin = isAuthor.bind(null, author);
-      flushAsynchronousOperations();
-      const messageElements = getMessages();
-      assert.equal(messageElements.length, messages.length);
-      assert.deepEqual(messageElements[1].message, messages[1]);
-      assert.deepEqual(messageElements[2].message, messages[2]);
-      assert.deepEqual(messageElements[1].comments.file1,
-          comments.file1.filter(isMarvin));
-      assert.deepEqual(messageElements[1].comments.file2,
-          comments.file2.filter(isMarvin));
-      assert.deepEqual(messageElements[2].comments, {});
-    });
-
-    test('messages without author do not throw', () => {
-      const messages = [{
-        _index: 5,
-        _revision_number: 4,
-        message: 'Uploaded patch set 4.',
-        date: '2016-09-28 13:36:33.000000000',
-        id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
-      }];
-      element.messages = messages;
-      flushAsynchronousOperations();
-      const messageEls = getMessages();
-      assert.equal(messageEls.length, 1);
-      assert.equal(messageEls[0].message.message, messages[0].message);
-    });
-
-    test('hide increment text if increment >= total remaining', () => {
-      // Test with stubbed return values, as _numRemaining and _getDelta have
-      // their own tests.
-      sandbox.stub(element, '_getDelta').returns(5);
-      const remainingStub = sandbox.stub(element, '_numRemaining').returns(6);
-      assert.isFalse(element._computeIncrementHidden(null, null, null));
-      remainingStub.restore();
-
-      sandbox.stub(element, '_numRemaining').returns(4);
-      assert.isTrue(element._computeIncrementHidden(null, null, null));
-    });
-  });
-
-  suite('gr-messages-list automate tests', () => {
-    let element;
-    let messages;
-    let sandbox;
-    let commentApiWrapper;
-
-    const getMessages = function() {
-      return dom(element.root).querySelectorAll('gr-message');
-    };
-    const getHiddenMessages = function() {
-      return dom(element.root).querySelectorAll('gr-message[hidden]');
-    };
-
-    const randomMessageReviewer = {
-      reviewer: {},
-      date: '2016-01-13 20:30:33.038000',
-    };
-
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
-
-      sandbox = sinon.sandbox.create();
-      messages = _.times(2, randomAutomated);
-      messages.push(randomMessageReviewer);
-
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = fixture('basic');
-      element = commentApiWrapper.$.messagesList;
-      sandbox.spy(commentApiWrapper.$.commentAPI, 'loadAll');
-      element.messages = messages;
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('hide autogenerated button is not hidden', () => {
-      assert.isNotOk(element.shadowRoot
-          .querySelector('#automatedMessageToggle[hidden]'));
-    });
-
-    test('autogenerated messages are not hidden initially', () => {
-      const allHiddenMessageEls = getHiddenMessages();
-
-      // There are no hidden messages.
-      assert.isFalse(!!allHiddenMessageEls.length);
-    });
-
-    test('autogenerated messages hidden after comments only toggle', () => {
-      let allHiddenMessageEls = getHiddenMessages();
-
-      element._hideAutomated = false;
-      MockInteractions.tap(element.$.automatedMessageToggle);
-      flushAsynchronousOperations();
-      const allMessageEls = getMessages();
-      allHiddenMessageEls = getHiddenMessages();
-
-      // Autogenerated messages are now hidden.
-      assert.equal(allHiddenMessageEls.length, allMessageEls.length);
-    });
-
-    test('autogenerated messages not hidden after comments only toggle',
-        () => {
-          let allHiddenMessageEls = getHiddenMessages();
-
-          element._hideAutomated = true;
-          MockInteractions.tap(element.$.automatedMessageToggle);
-          allHiddenMessageEls = getHiddenMessages();
-
-          // Autogenerated messages are now hidden.
-          assert.isFalse(!!allHiddenMessageEls.length);
-        });
-
-    test('_getDelta', () => {
-      let messages = [randomMessage()];
-      assert.equal(element._getDelta([], messages, false), 1);
-      assert.equal(element._getDelta([], messages, true), 1);
-
-      messages = _.times(7, randomMessage);
-      assert.equal(element._getDelta([], messages, false), 5);
-      assert.equal(element._getDelta([], messages, true), 5);
-
-      messages = _.times(4, randomMessage)
-          .concat(_.times(2, randomAutomated))
-          .concat(_.times(3, randomMessage));
-
-      const dummyArr = _.times(2, randomMessage);
-      assert.equal(element._getDelta(dummyArr, messages, false), 5);
-      assert.equal(element._getDelta(dummyArr, messages, true), 7);
-    });
-
-    test('_getHumanMessages', () => {
-      assert.equal(
-          element._getHumanMessages(_.times(5, randomAutomated)).length, 0);
-      assert.equal(
-          element._getHumanMessages(_.times(5, randomMessage)).length, 5);
-
-      let messages = _.shuffle(_.times(5, randomMessage)
-          .concat(_.times(5, randomAutomated)));
-      messages = element._getHumanMessages(messages);
-      assert.equal(messages.length, 5);
-      assert.isFalse(element._hasAutomatedMessages(messages));
-    });
-
-    test('initially show only 20 messages', () => {
-      sandbox.stub(element.$.reporting, 'reportInteraction',
-          (eventName, details) => {
-            assert.equal(typeof(eventName), 'string');
-            if (details) {
-              assert.equal(typeof(details), 'object');
-            }
-          });
-      const messages = Array.from(Array(23).keys())
-          .map(() => {
-            return {};
-          });
-      element._processedMessagesChanged(messages);
-
-      assert.equal(element._visibleMessages.length, 20);
-    });
-
-    test('_computeLabelExtremes', () => {
-      const computeSpy = sandbox.spy(element, '_computeLabelExtremes');
-
-      element.labels = null;
-      assert.isTrue(computeSpy.calledOnce);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {};
-      assert.isTrue(computeSpy.calledTwice);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {'my-label': {}};
-      assert.isTrue(computeSpy.calledThrice);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {'my-label': {values: {}}};
-      assert.equal(computeSpy.callCount, 4);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {'my-label': {values: {'-12': {}}}};
-      assert.equal(computeSpy.callCount, 5);
-      assert.deepEqual(computeSpy.lastCall.returnValue,
-          {'my-label': {min: -12, max: -12}});
-
-      element.labels = {
-        'my-label': {values: {'-2': {}, '-1': {}, '0': {}, '+1': {}, '+2': {}}},
-      };
-      assert.equal(computeSpy.callCount, 6);
-      assert.deepEqual(computeSpy.lastCall.returnValue,
-          {'my-label': {min: -2, max: 2}});
-
-      element.labels = {
-        'my-label': {values: {'-12': {}}},
-        'other-label': {values: {'-1': {}, ' 0': {}, '+1': {}}},
-      };
-      assert.equal(computeSpy.callCount, 7);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {
-        'my-label': {min: -12, max: -12},
-        'other-label': {min: -1, max: 1},
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
new file mode 100644
index 0000000..c3245fe
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -0,0 +1,544 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../diff/gr-comment-api/gr-comment-api.js';
+import './gr-messages-list.js';
+import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {TEST_ONLY} from './gr-messages-list.js';
+import {MessageTag} from '../../../constants/constants.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+createCommentApiMockWithTemplateElement(
+    'gr-messages-list-comment-mock-api', html`
+     <gr-messages-list
+         id="messagesList"
+         change-comments="[[_changeComments]]"></gr-messages-list>
+     <gr-comment-api id="commentAPI"></gr-comment-api>
+`);
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-messages-list-comment-mock-api>
+  <gr-messages-list></gr-messages-list>
+</gr-messages-list-comment-mock-api>
+`);
+
+const randomMessage = function(opt_params) {
+  const params = opt_params || {};
+  const author1 = {
+    _account_id: 1115495,
+    name: 'Andrew Bonventre',
+    email: 'andybons@chromium.org',
+  };
+  return {
+    id: params.id || Math.random().toString(),
+    date: params.date || '2016-01-12 20:28:33.038000',
+    message: params.message || Math.random().toString(),
+    _revision_number: params._revision_number || 1,
+    author: params.author || author1,
+    tag: params.tag,
+  };
+};
+
+function generateRandomMessages(count) {
+  return new Array(count).fill()
+      .map(() => randomMessage());
+}
+
+suite('gr-messages-list tests', () => {
+  let element;
+  let messages;
+
+  let commentApiWrapper;
+
+  const getMessages = function() {
+    return dom(element.root).querySelectorAll('gr-message');
+  };
+
+  const MESSAGE_ID_0 = '1234ccc949c6d482b061be6a28e10782abf0e7af';
+  const MESSAGE_ID_1 = '8c19ccc949c6d482b061be6a28e10782abf0e7af';
+  const MESSAGE_ID_2 = 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5';
+
+  const author = {
+    _account_id: 42,
+    name: 'Marvin the Paranoid Android',
+    email: 'marvin@sirius.org',
+  };
+
+  const createComment = function() {
+    return {
+      id: '1a2b3c4d',
+      message: 'some random test text',
+      change_message_id: '8a7b6c5d',
+      updated: '2016-01-01 01:02:03.000000000',
+      line: 1,
+      patch_set: 1,
+      author,
+    };
+  };
+
+  const comments = {
+    file1: [
+      {
+        ...createComment(),
+        change_message_id: MESSAGE_ID_0,
+        in_reply_to: '6505d749_f0bec0aa',
+        author: {
+          email: 'some@email.com',
+          _account_id: 123,
+        },
+      },
+      {
+        ...createComment(),
+        id: '2b3c4d5e',
+        change_message_id: MESSAGE_ID_1,
+        in_reply_to: 'c5912363_6b820105',
+      },
+      {
+        ...createComment(),
+        id: '2b3c4d5e',
+        change_message_id: MESSAGE_ID_1,
+        in_reply_to: '6505d749_f0bec0aa',
+      },
+      {
+        ...createComment(),
+        id: '34ed05d749_10ed44b2',
+        change_message_id: MESSAGE_ID_2,
+      },
+    ],
+    file2: [
+      {
+        ...createComment(),
+        change_message_id: MESSAGE_ID_1,
+        in_reply_to: 'c5912363_4b7d450a',
+        id: '450a935e_4f260d25',
+      },
+    ],
+  };
+
+  suite('basic tests', () => {
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getDiffComments() { return Promise.resolve(comments); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+      });
+
+      messages = generateRandomMessages(3);
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = basicFixture.instantiate();
+      element = commentApiWrapper.$.messagesList;
+      element.messages = messages;
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      return commentApiWrapper.loadComments();
+    });
+
+    test('expand/collapse all', () => {
+      let allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        message._expanded = false;
+      }
+      MockInteractions.tap(allMessageEls[1]);
+      assert.isTrue(allMessageEls[1]._expanded);
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages'));
+      allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isTrue(message._expanded);
+      }
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('#collapse-messages'));
+      allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isFalse(message._expanded);
+      }
+    });
+
+    test('expand/collapse from external keypress', () => {
+      // Start with one expanded message. -> not all collapsed
+      element.scrollToMessage(messages[1].id);
+      assert.isFalse([...getMessages()].filter(m => m._expanded).length == 0);
+
+      // Press 'z' -> all collapsed
+      element.handleExpandCollapse(false);
+      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
+
+      // Press 'x' -> all expanded
+      element.handleExpandCollapse(true);
+      assert.isTrue([...getMessages()].filter(m => !m._expanded).length == 0);
+
+      // Press 'z' -> all collapsed
+      element.handleExpandCollapse(false);
+      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
+    });
+
+    test('showAllActivity does not appear when all msgs are important', () => {
+      assert.isOk(element.shadowRoot
+          .querySelector('#showAllActivityToggleContainer'));
+      assert.isNotOk(element.shadowRoot
+          .querySelector('.showAllActivityToggle'));
+    });
+
+    test('scroll to message', () => {
+      const allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        message.set('message.expanded', false);
+      }
+
+      const scrollToStub = sinon.stub(window, 'scrollTo');
+      const highlightStub = sinon.stub(element, '_highlightEl');
+
+      element.scrollToMessage('invalid');
+
+      for (const message of allMessageEls) {
+        assert.isFalse(message._expanded,
+            'expected gr-message to not be expanded');
+      }
+
+      const messageID = messages[1].id;
+      element.scrollToMessage(messageID);
+      assert.isTrue(
+          element.shadowRoot
+              .querySelector('[data-message-id="' + messageID + '"]')
+              ._expanded);
+
+      assert.isTrue(scrollToStub.calledOnce);
+      assert.isTrue(highlightStub.calledOnce);
+    });
+
+    test('scroll to message offscreen', () => {
+      const scrollToStub = sinon.stub(window, 'scrollTo');
+      const highlightStub = sinon.stub(element, '_highlightEl');
+      element.messages = generateRandomMessages(25);
+      flushAsynchronousOperations();
+      assert.isFalse(scrollToStub.called);
+      assert.isFalse(highlightStub.called);
+
+      const messageID = element.messages[1].id;
+      element.scrollToMessage(messageID);
+      assert.isTrue(scrollToStub.calledOnce);
+      assert.isTrue(highlightStub.calledOnce);
+      assert.isTrue(
+          element.shadowRoot
+              .querySelector('[data-message-id="' + messageID + '"]')
+              ._expanded);
+    });
+
+    test('associating messages with comments', () => {
+      const messages = [].concat(
+          randomMessage(),
+          {
+            _index: 5,
+            _revision_number: 4,
+            message: 'Uploaded patch set 4.',
+            date: '2016-09-28 13:36:33.000000000',
+            author,
+            id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
+          },
+          {
+            _index: 6,
+            _revision_number: 4,
+            message: 'Patch Set 4:\n\n(6 comments)',
+            date: '2016-09-28 13:36:33.000000000',
+            author,
+            id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5',
+          }
+      );
+      element.messages = messages;
+      flushAsynchronousOperations();
+      const messageElements = getMessages();
+      assert.equal(messageElements.length, messages.length);
+      assert.deepEqual(messageElements[1].message, messages[1]);
+      assert.deepEqual(messageElements[2].message, messages[2]);
+    });
+
+    test('threads', () => {
+      const messages = [
+        {
+          _index: 5,
+          _revision_number: 4,
+          message: 'Uploaded patch set 4.',
+          date: '2016-09-28 13:36:33.000000000',
+          author,
+          id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
+        },
+      ];
+      element.messages = messages;
+      flushAsynchronousOperations();
+      const messageElements = getMessages();
+      // threads
+      assert.equal(
+          messageElements[0].message.commentThreads.length,
+          3);
+      // first thread contains 1 comment
+      assert.equal(
+          messageElements[0].message.commentThreads[0].comments.length,
+          1);
+    });
+
+    test('updateTag human message', () => {
+      const m = randomMessage();
+      assert.equal(TEST_ONLY.computeTag(m), undefined);
+    });
+
+    test('updateTag nothing to change', () => {
+      const m = randomMessage();
+      const tag = 'something-normal';
+      m.tag = tag;
+      assert.equal(TEST_ONLY.computeTag(m), tag);
+    });
+
+    test('updateTag TAG_NEW_WIP_PATCHSET', () => {
+      const m = randomMessage();
+      m.tag = MessageTag.TAG_NEW_WIP_PATCHSET;
+      assert.equal(TEST_ONLY.computeTag(m), MessageTag.TAG_NEW_PATCHSET);
+    });
+
+    test('updateTag remove postfix', () => {
+      const m = randomMessage();
+      m.tag = 'something~withpostfix';
+      assert.equal(TEST_ONLY.computeTag(m), 'something');
+    });
+
+    test('updateTag with robot comments', () => {
+      const m = randomMessage();
+      m.commentThreads = [{
+        comments: [{
+          robot_id: 'id314',
+          change_message_id: m.id,
+        }],
+      }];
+      assert.notEqual(TEST_ONLY.computeTag(m), undefined);
+    });
+
+    test('setRevisionNumber nothing to change', () => {
+      const m1 = randomMessage();
+      const m2 = randomMessage();
+      assert.equal(TEST_ONLY.computeRevision(m1, [m1, m2]), 1);
+      assert.equal(TEST_ONLY.computeRevision(m2, [m1, m2]), 1);
+    });
+
+    test('setRevisionNumber reviewer updates', () => {
+      const m1 = randomMessage(
+          {
+            tag: MessageTag.TAG_REVIEWER_UPDATE,
+            date: '2020-01-01 10:00:00.000000000',
+          });
+      m1._revision_number = undefined;
+      const m2 = randomMessage(
+          {
+            date: '2020-01-02 10:00:00.000000000',
+          });
+      m2._revision_number = 1;
+      const m3 = randomMessage(
+          {
+            tag: MessageTag.TAG_REVIEWER_UPDATE,
+            date: '2020-01-03 10:00:00.000000000',
+          });
+      m3._revision_number = undefined;
+      const m4 = randomMessage(
+          {
+            date: '2020-01-04 10:00:00.000000000',
+          });
+      m4._revision_number = 2;
+      const m5 = randomMessage(
+          {
+            tag: MessageTag.TAG_REVIEWER_UPDATE,
+            date: '2020-01-05 10:00:00.000000000',
+          });
+      m5._revision_number = undefined;
+      const allMessages = [m1, m2, m3, m4, m5];
+      assert.equal(TEST_ONLY.computeRevision(m1, allMessages), undefined);
+      assert.equal(TEST_ONLY.computeRevision(m2, allMessages), 1);
+      assert.equal(TEST_ONLY.computeRevision(m3, allMessages), 1);
+      assert.equal(TEST_ONLY.computeRevision(m4, allMessages), 2);
+      assert.equal(TEST_ONLY.computeRevision(m5, allMessages), 2);
+    });
+
+    test('isImportant human message', () => {
+      const m = randomMessage();
+      assert.isTrue(TEST_ONLY.computeIsImportant(m, []));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m, [m]));
+    });
+
+    test('isImportant even with a tag', () => {
+      const m1 = randomMessage();
+      const m2 = randomMessage({tag: 'autogenerated:gerrit1'});
+      const m3 = randomMessage({tag: 'autogenerated:gerrit2'});
+      assert.isTrue(TEST_ONLY.computeIsImportant(m2, []));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2, m3]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m2, [m1, m2, m3]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
+    });
+
+    test('isImportant filters same tag and older revision', () => {
+      const m1 = randomMessage({tag: 'auto', _revision_number: 2});
+      const m2 = randomMessage({tag: 'auto', _revision_number: 1});
+      const m3 = randomMessage({tag: 'auto'});
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m2, [m2]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m2, [m1, m2]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m3]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m3]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2, m3]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m2, [m1, m2, m3]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
+    });
+
+    test('isImportant is evaluated after tag update', () => {
+      const m1 = randomMessage(
+          {tag: MessageTag.TAG_NEW_PATCHSET, _revision_number: 1});
+      const m2 = randomMessage(
+          {tag: MessageTag.TAG_NEW_WIP_PATCHSET, _revision_number: 2});
+      element.messages = [m1, m2];
+      flushAsynchronousOperations();
+      assert.isFalse(m1.isImportant);
+      assert.isTrue(m2.isImportant);
+    });
+
+    test('messages without author do not throw', () => {
+      const messages = [{
+        _index: 5,
+        _revision_number: 4,
+        message: 'Uploaded patch set 4.',
+        date: '2016-09-28 13:36:33.000000000',
+        id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
+      }];
+      element.messages = messages;
+      flushAsynchronousOperations();
+      const messageEls = getMessages();
+      assert.equal(messageEls.length, 1);
+      assert.equal(messageEls[0].message.message, messages[0].message);
+    });
+  });
+
+  suite('gr-messages-list automate tests', () => {
+    let element;
+    let messages;
+
+    let commentApiWrapper;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+      });
+
+      messages = [
+        randomMessage(),
+        randomMessage({tag: 'auto', _revision_number: 2}),
+        randomMessage({tag: 'auto', _revision_number: 3}),
+      ];
+
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = basicFixture.instantiate();
+      element = commentApiWrapper.$.messagesList;
+      sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+      element.messages = messages;
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initialized.
+      return commentApiWrapper.loadComments();
+    });
+
+    test('hide autogenerated button is not hidden', () => {
+      const toggle = dom(element.root).querySelector('.showAllActivityToggle');
+      assert.isOk(toggle);
+    });
+
+    test('one unimportant message is hidden initially', () => {
+      const displayedMsgs = dom(element.root).querySelectorAll('gr-message');
+      assert.equal(displayedMsgs.length, 2);
+    });
+
+    test('unimportant messages hidden after toggle', () => {
+      element._showAllActivity = true;
+      const toggle = dom(element.root).querySelector('.showAllActivityToggle');
+      assert.isOk(toggle);
+      MockInteractions.tap(toggle);
+      flushAsynchronousOperations();
+      const displayedMsgs = dom(element.root).querySelectorAll('gr-message');
+      assert.equal(displayedMsgs.length, 2);
+    });
+
+    test('unimportant messages shown after toggle', () => {
+      element._showAllActivity = false;
+      const toggle = dom(element.root).querySelector('.showAllActivityToggle');
+      assert.isOk(toggle);
+      MockInteractions.tap(toggle);
+      flushAsynchronousOperations();
+      const displayedMsgs = dom(element.root).querySelectorAll('gr-message');
+      assert.equal(displayedMsgs.length, 3);
+    });
+
+    test('_computeLabelExtremes', () => {
+      const computeSpy = sinon.spy(element, '_computeLabelExtremes');
+
+      element.labels = null;
+      assert.isTrue(computeSpy.calledOnce);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {};
+      assert.isTrue(computeSpy.calledTwice);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {'my-label': {}};
+      assert.isTrue(computeSpy.calledThrice);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {'my-label': {values: {}}};
+      assert.equal(computeSpy.callCount, 4);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {});
+
+      element.labels = {'my-label': {values: {'-12': {}}}};
+      assert.equal(computeSpy.callCount, 5);
+      assert.deepEqual(computeSpy.lastCall.returnValue,
+          {'my-label': {min: -12, max: -12}});
+
+      element.labels = {
+        'my-label': {values: {'-2': {}, '-1': {}, '0': {}, '+1': {}, '+2': {}}},
+      };
+      assert.equal(computeSpy.callCount, 6);
+      assert.deepEqual(computeSpy.lastCall.returnValue,
+          {'my-label': {min: -2, max: 2}});
+
+      element.labels = {
+        'my-label': {values: {'-12': {}}},
+        'other-label': {values: {'-1': {}, ' 0': {}, '+1': {}}},
+      };
+      assert.equal(computeSpy.callCount, 7);
+      assert.deepEqual(computeSpy.lastCall.returnValue, {
+        'my-label': {min: -12, max: -12},
+        'other-label': {min: -1, max: 1},
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index 2183dde..9b6d674 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -14,33 +14,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../../styles/shared-styles.js';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot.js';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-related-changes-list_html.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
-import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {ChangeStatus} from '../../../constants/constants.js';
+import {patchNumEquals} from '../../../utils/patch-set-util.js';
+import {changeIsOpen} from '../../../utils/change-util.js';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrRelatedChangesList extends mixinBehaviors( [
-  PatchSetBehavior,
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrRelatedChangesList extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-related-changes-list'; }
@@ -143,7 +137,7 @@
     ];
 
     // Get conflicts if change is open and is mergeable.
-    if (this.changeIsOpen(this.change) && this.mergeable) {
+    if (changeIsOpen(this.change) && this.mergeable) {
       promises.push(this._getConflicts().then(response => {
         // Because the server doesn't always return a response and the
         // template expects an array, always return an array.
@@ -230,7 +224,7 @@
 
   _computeChangeContainerClass(currentChange, relatedChange) {
     const classes = ['changeContainer'];
-    if ([relatedChange, currentChange].some(arg => arg === undefined)) {
+    if ([relatedChange, currentChange].includes(undefined)) {
       return classes;
     }
     if (this._changesEqual(relatedChange, currentChange)) {
@@ -280,7 +274,7 @@
 
   _computeLinkClass(change) {
     const statuses = [];
-    if (change.status == this.ChangeStatus.ABANDONED) {
+    if (change.status == ChangeStatus.ABANDONED) {
       statuses.push('strikethrough');
     }
     if (change.submittable) {
@@ -297,7 +291,7 @@
       classes.push('indirectAncestor');
     } else if (change.submittable) {
       classes.push('submittable');
-    } else if (change.status == this.ChangeStatus.NEW) {
+    } else if (change.status == ChangeStatus.NEW) {
       classes.push('hidden');
     }
     return classes.join(' ');
@@ -305,9 +299,9 @@
 
   _computeChangeStatus(change) {
     switch (change.status) {
-      case this.ChangeStatus.MERGED:
+      case ChangeStatus.MERGED:
         return 'Merged';
-      case this.ChangeStatus.ABANDONED:
+      case ChangeStatus.ABANDONED:
         return 'Abandoned';
     }
     if (change._revision_number != change._current_revision_number) {
@@ -345,7 +339,7 @@
       conflicts,
       cherryPicks,
       sameTopic,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return;
     }
 
@@ -378,7 +372,7 @@
     // to check for that and stay visible if we find any such visible content.
     // (We consider plugins visible except if it's main element has the hidden
     // attribute set to true.)
-    const plugins = pluginEndpoints.getDetails('related-changes-section');
+    const plugins = getPluginEndpoints().getDetails('related-changes-section');
     this.hidden = !(plugins.some(plugin => (
       (!plugin.domHook)
         || plugin.domHook.getAllAttached().some(
@@ -391,7 +385,7 @@
 
   _computeConnectedRevisions(change, patchNum, relatedChanges) {
     // Polymer 2: check for undefined
-    if ([change, patchNum, relatedChanges].some(arg => arg === undefined)) {
+    if ([change, patchNum, relatedChanges].includes(undefined)) {
       return undefined;
     }
 
@@ -399,7 +393,7 @@
     let changeRevision;
     if (!change) { return []; }
     for (const rev in change.revisions) {
-      if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
+      if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
         changeRevision = rev;
       }
     }
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js
deleted file mode 100644
index 8241165..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.js
+++ /dev/null
@@ -1,208 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    h3 {
-      margin: var(--spacing-m) 0 0;
-    }
-    section {
-      margin-bottom: 1.4em; /* Same as line height for collapse purposes */
-    }
-    a {
-      display: block;
-    }
-    .changeContainer,
-    a {
-      max-width: 100%;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    .changeContainer {
-      display: flex;
-    }
-    .changeContainer.thisChange:before {
-      content: '➔';
-      width: 1.2em;
-    }
-    h4,
-    section div {
-      display: flex;
-    }
-    h4:before,
-    section div:before {
-      content: ' ';
-      flex-shrink: 0;
-      width: 1.2em;
-    }
-    .note {
-      color: var(--error-text-color);
-    }
-    .relatedChanges a {
-      display: inline-block;
-    }
-    .strikethrough {
-      color: var(--deemphasized-text-color);
-      text-decoration: line-through;
-    }
-    .status {
-      color: var(--deemphasized-text-color);
-      font-weight: var(--font-weight-bold);
-      margin-left: var(--spacing-xs);
-    }
-    .notCurrent {
-      color: #e65100;
-    }
-    .indirectAncestor {
-      color: #33691e;
-    }
-    .submittable {
-      color: #1b5e20;
-    }
-    .submittableCheck {
-      color: var(--vote-text-color-recommended);
-      display: none;
-    }
-    .submittableCheck.submittable {
-      display: inline;
-    }
-    .hidden,
-    .mobile {
-      display: none;
-    }
-    @media screen and (max-width: 60em) {
-      .mobile {
-        display: block;
-      }
-    }
-  </style>
-  <div>
-    <gr-endpoint-decorator name="related-changes-section">
-      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      <gr-endpoint-slot name="top"></gr-endpoint-slot>
-      <section
-        class="relatedChanges"
-        hidden$="[[!_relatedResponse.changes.length]]"
-        hidden=""
-      >
-        <h4>Relation chain</h4>
-        <template
-          is="dom-repeat"
-          items="[[_relatedResponse.changes]]"
-          as="related"
-        >
-          <div
-            class$="rightIndent [[_computeChangeContainerClass(change, related)]]"
-          >
-            <a
-              href$="[[_computeChangeURL(related._change_number, related.project, related._revision_number)]]"
-              class$="[[_computeLinkClass(related)]]"
-              title$="[[related.commit.subject]]"
-            >
-              [[related.commit.subject]]
-            </a>
-            <span class$="[[_computeChangeStatusClass(related)]]">
-              ([[_computeChangeStatus(related)]])
-            </span>
-          </div>
-        </template>
-      </section>
-      <section
-        id="submittedTogether"
-        class$="[[_computeSubmittedTogetherClass(_submittedTogether)]]"
-      >
-        <h4>Submitted together</h4>
-        <template
-          is="dom-repeat"
-          items="[[_submittedTogether.changes]]"
-          as="related"
-        >
-          <div class$="[[_computeChangeContainerClass(change, related)]]">
-            <a
-              href$="[[_computeChangeURL(related._number, related.project)]]"
-              class$="[[_computeLinkClass(related)]]"
-              title$="[[related.project]]: [[related.branch]]: [[related.subject]]"
-            >
-              [[related.project]]: [[related.branch]]: [[related.subject]]
-            </a>
-            <span
-              tabindex="-1"
-              title="Submittable"
-              class$="submittableCheck [[_computeLinkClass(related)]]"
-              >✓</span
-            >
-          </div>
-        </template>
-        <template is="dom-if" if="[[_submittedTogether.non_visible_changes]]">
-          <div class="note">
-            [[_computeNonVisibleChangesNote(_submittedTogether.non_visible_changes)]]
-          </div>
-        </template>
-      </section>
-      <section hidden$="[[!_sameTopic.length]]" hidden="">
-        <h4>Same topic</h4>
-        <template is="dom-repeat" items="[[_sameTopic]]" as="change">
-          <div>
-            <a
-              href$="[[_computeChangeURL(change._number, change.project)]]"
-              class$="[[_computeLinkClass(change)]]"
-              title$="[[change.project]]: [[change.branch]]: [[change.subject]]"
-            >
-              [[change.project]]: [[change.branch]]: [[change.subject]]
-            </a>
-          </div>
-        </template>
-      </section>
-      <section hidden$="[[!_conflicts.length]]" hidden="">
-        <h4>Merge conflicts</h4>
-        <template is="dom-repeat" items="[[_conflicts]]" as="change">
-          <div>
-            <a
-              href$="[[_computeChangeURL(change._number, change.project)]]"
-              class$="[[_computeLinkClass(change)]]"
-              title$="[[change.subject]]"
-            >
-              [[change.subject]]
-            </a>
-          </div>
-        </template>
-      </section>
-      <section hidden$="[[!_cherryPicks.length]]" hidden="">
-        <h4>Cherry picks</h4>
-        <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
-          <div>
-            <a
-              href$="[[_computeChangeURL(change._number, change.project)]]"
-              class$="[[_computeLinkClass(change)]]"
-              title$="[[change.branch]]: [[change.subject]]"
-            >
-              [[change.branch]]: [[change.subject]]
-            </a>
-          </div>
-        </template>
-      </section>
-      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
-    </gr-endpoint-decorator>
-  </div>
-  <div hidden$="[[!loading]]">Loading...</div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
new file mode 100644
index 0000000..6fe8cea
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
@@ -0,0 +1,205 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    section {
+      margin-bottom: 1.4em; /* Same as line height for collapse purposes */
+    }
+    a {
+      display: block;
+    }
+    .changeContainer,
+    a {
+      max-width: 100%;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    .changeContainer {
+      display: flex;
+    }
+    .changeContainer.thisChange:before {
+      content: '➔';
+      width: 1.2em;
+    }
+    h4,
+    section div {
+      display: flex;
+    }
+    h4:before,
+    section div:before {
+      content: ' ';
+      flex-shrink: 0;
+      width: 1.2em;
+    }
+    .note {
+      color: var(--error-text-color);
+    }
+    .relatedChanges a {
+      display: inline-block;
+    }
+    .strikethrough {
+      color: var(--deemphasized-text-color);
+      text-decoration: line-through;
+    }
+    .status {
+      color: var(--deemphasized-text-color);
+      font-weight: var(--font-weight-bold);
+      margin-left: var(--spacing-xs);
+    }
+    .notCurrent {
+      color: #e65100;
+    }
+    .indirectAncestor {
+      color: #33691e;
+    }
+    .submittable {
+      color: #1b5e20;
+    }
+    .submittableCheck {
+      color: var(--positive-green-text-color);
+      display: none;
+    }
+    .submittableCheck.submittable {
+      display: inline;
+    }
+    .hidden,
+    .mobile {
+      display: none;
+    }
+    @media screen and (max-width: 60em) {
+      .mobile {
+        display: block;
+      }
+    }
+  </style>
+  <div>
+    <gr-endpoint-decorator name="related-changes-section">
+      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+      <gr-endpoint-slot name="top"></gr-endpoint-slot>
+      <section
+        class="relatedChanges"
+        hidden$="[[!_relatedResponse.changes.length]]"
+        hidden=""
+      >
+        <h4>Relation chain</h4>
+        <template
+          is="dom-repeat"
+          items="[[_relatedResponse.changes]]"
+          as="related"
+        >
+          <div
+            class$="rightIndent [[_computeChangeContainerClass(change, related)]]"
+          >
+            <a
+              href$="[[_computeChangeURL(related._change_number, related.project, related._revision_number)]]"
+              class$="[[_computeLinkClass(related)]]"
+              title$="[[related.commit.subject]]"
+            >
+              [[related.commit.subject]]
+            </a>
+            <span class$="[[_computeChangeStatusClass(related)]]">
+              ([[_computeChangeStatus(related)]])
+            </span>
+          </div>
+        </template>
+      </section>
+      <section
+        id="submittedTogether"
+        class$="[[_computeSubmittedTogetherClass(_submittedTogether)]]"
+      >
+        <h4>Submitted together</h4>
+        <template
+          is="dom-repeat"
+          items="[[_submittedTogether.changes]]"
+          as="related"
+        >
+          <div class$="[[_computeChangeContainerClass(change, related)]]">
+            <a
+              href$="[[_computeChangeURL(related._number, related.project)]]"
+              class$="[[_computeLinkClass(related)]]"
+              title$="[[related.project]]: [[related.branch]]: [[related.subject]]"
+            >
+              [[related.project]]: [[related.branch]]: [[related.subject]]
+            </a>
+            <span
+              tabindex="-1"
+              title="Submittable"
+              class$="submittableCheck [[_computeLinkClass(related)]]"
+              >✓</span
+            >
+          </div>
+        </template>
+        <template is="dom-if" if="[[_submittedTogether.non_visible_changes]]">
+          <div class="note">
+            [[_computeNonVisibleChangesNote(_submittedTogether.non_visible_changes)]]
+          </div>
+        </template>
+      </section>
+      <section hidden$="[[!_sameTopic.length]]" hidden="">
+        <h4>Same topic</h4>
+        <template is="dom-repeat" items="[[_sameTopic]]" as="change">
+          <div>
+            <a
+              href$="[[_computeChangeURL(change._number, change.project)]]"
+              class$="[[_computeLinkClass(change)]]"
+              title$="[[change.project]]: [[change.branch]]: [[change.subject]]"
+            >
+              [[change.project]]: [[change.branch]]: [[change.subject]]
+            </a>
+          </div>
+        </template>
+      </section>
+      <section hidden$="[[!_conflicts.length]]" hidden="">
+        <h4>Merge conflicts</h4>
+        <template is="dom-repeat" items="[[_conflicts]]" as="change">
+          <div>
+            <a
+              href$="[[_computeChangeURL(change._number, change.project)]]"
+              class$="[[_computeLinkClass(change)]]"
+              title$="[[change.subject]]"
+            >
+              [[change.subject]]
+            </a>
+          </div>
+        </template>
+      </section>
+      <section hidden$="[[!_cherryPicks.length]]" hidden="">
+        <h4>Cherry picks</h4>
+        <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
+          <div>
+            <a
+              href$="[[_computeChangeURL(change._number, change.project)]]"
+              class$="[[_computeLinkClass(change)]]"
+              title$="[[change.branch]]: [[change.subject]]"
+            >
+              [[change.branch]]: [[change.subject]]
+            </a>
+          </div>
+        </template>
+      </section>
+      <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+    </gr-endpoint-decorator>
+  </div>
+  <div hidden$="[[!loading]]">Loading...</div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
deleted file mode 100644
index 9054aa3..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ /dev/null
@@ -1,682 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-related-changes-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-related-changes-list></gr-related-changes-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-related-changes-list.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-related-changes-list tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    stub('gr-endpoint-decorator', {
-      _import: sandbox.stub().returns(Promise.resolve()),
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('connected revisions', () => {
-    const change = {
-      revisions: {
-        'e3c6d60783bfdec9ebae7dcfec4662360433449e': {
-          _number: 1,
-        },
-        '26e5e4c9c7ae31cbd876271cca281ce22b413997': {
-          _number: 2,
-        },
-        'bf7884d695296ca0c91702ba3e2bc8df0f69a907': {
-          _number: 7,
-        },
-        'b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3': {
-          _number: 5,
-        },
-        'd6bcee67570859ccb684873a85cf50b1f0e96fda': {
-          _number: 6,
-        },
-        'cc960918a7f90388f4a9e05753d0f7b90ad44546': {
-          _number: 3,
-        },
-        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': {
-          _number: 4,
-        },
-      },
-    };
-    let patchNum = 7;
-    let relatedChanges = [
-      {
-        commit: {
-          commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-          parents: [
-            {
-              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-          parents: [
-            {
-              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-          parents: [
-            {
-              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-          parents: [
-            {
-              commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-          parents: [
-            {
-              commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-          parents: [
-            {
-              commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75',
-            },
-          ],
-        },
-      },
-    ];
-
-    let connectedChanges =
-        element._computeConnectedRevisions(change, patchNum, relatedChanges);
-    assert.deepEqual(connectedChanges, [
-      '613bc4f81741a559c6667ac08d71dcc3348f73ce',
-      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
-      'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-      '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-      '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-      '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-    ]);
-
-    patchNum = 4;
-    relatedChanges = [
-      {
-        commit: {
-          commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
-          parents: [
-            {
-              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
-          parents: [
-            {
-              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
-          parents: [
-            {
-              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
-          parents: [
-            {
-              commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-          parents: [
-            {
-              commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
-            },
-          ],
-        },
-      },
-      {
-        commit: {
-          commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
-          parents: [
-            {
-              commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c',
-            },
-          ],
-        },
-      },
-    ];
-
-    connectedChanges =
-        element._computeConnectedRevisions(change, patchNum, relatedChanges);
-    assert.deepEqual(connectedChanges, [
-      'af815dac54318826b7f1fa468acc76349ffc588e',
-      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
-      'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
-    ]);
-  });
-
-  test('_computeChangeContainerClass', () => {
-    const change1 = {change_id: 123, _number: 0};
-    const change2 = {change_id: 456, _change_number: 1};
-    const change3 = {change_id: 123, _number: 2};
-
-    assert.notEqual(element._computeChangeContainerClass(
-        change1, change1).indexOf('thisChange'), -1);
-    assert.equal(element._computeChangeContainerClass(
-        change1, change2).indexOf('thisChange'), -1);
-    assert.equal(element._computeChangeContainerClass(
-        change1, change3).indexOf('thisChange'), -1);
-  });
-
-  test('_changesEqual', () => {
-    const change1 = {change_id: 123, _number: 0};
-    const change2 = {change_id: 456, _number: 1};
-    const change3 = {change_id: 123, _number: 2};
-    const change4 = {change_id: 123, _change_number: 1};
-
-    assert.isTrue(element._changesEqual(change1, change1));
-    assert.isFalse(element._changesEqual(change1, change2));
-    assert.isFalse(element._changesEqual(change1, change3));
-    assert.isTrue(element._changesEqual(change2, change4));
-  });
-
-  test('_getChangeNumber', () => {
-    const change1 = {change_id: 123, _number: 0};
-    const change2 = {change_id: 456, _change_number: 1};
-    assert.equal(element._getChangeNumber(change1), 0);
-    assert.equal(element._getChangeNumber(change2), 1);
-  });
-
-  test('event for section loaded fires for each section ', () => {
-    const loadedStub = sandbox.stub();
-    element.patchNum = 7;
-    element.change = {
-      change_id: 123,
-      status: 'NEW',
-    };
-    element.mergeable = true;
-    element.addEventListener('new-section-loaded', loadedStub);
-    sandbox.stub(element, '_getRelatedChanges')
-        .returns(Promise.resolve({changes: []}));
-    sandbox.stub(element, '_getSubmittedTogether')
-        .returns(Promise.resolve());
-    sandbox.stub(element, '_getCherryPicks')
-        .returns(Promise.resolve());
-    sandbox.stub(element, '_getConflicts')
-        .returns(Promise.resolve());
-
-    return element.reload().then(() => {
-      assert.equal(loadedStub.callCount, 4);
-    });
-  });
-
-  suite('_getConflicts resolves undefined', () => {
-    let element;
-
-    setup(() => {
-      element = fixture('basic');
-
-      sandbox.stub(element, '_getRelatedChanges')
-          .returns(Promise.resolve({changes: []}));
-      sandbox.stub(element, '_getSubmittedTogether')
-          .returns(Promise.resolve());
-      sandbox.stub(element, '_getCherryPicks')
-          .returns(Promise.resolve());
-      sandbox.stub(element, '_getConflicts')
-          .returns(Promise.resolve());
-    });
-
-    test('_conflicts are an empty array', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'NEW',
-      };
-      element.mergeable = true;
-      element.reload();
-      assert.equal(element._conflicts.length, 0);
-    });
-  });
-
-  suite('get conflicts tests', () => {
-    let element;
-    let conflictsStub;
-
-    setup(() => {
-      element = fixture('basic');
-
-      sandbox.stub(element, '_getRelatedChanges')
-          .returns(Promise.resolve({changes: []}));
-      sandbox.stub(element, '_getSubmittedTogether')
-          .returns(Promise.resolve());
-      sandbox.stub(element, '_getCherryPicks')
-          .returns(Promise.resolve());
-      conflictsStub = sandbox.stub(element, '_getConflicts')
-          .returns(Promise.resolve());
-    });
-
-    test('request conflicts if open and mergeable', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'NEW',
-      };
-      element.mergeable = true;
-      element.reload();
-      assert.isTrue(conflictsStub.called);
-    });
-
-    test('does not request conflicts if closed and mergeable', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'MERGED',
-      };
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-
-    test('does not request conflicts if open and not mergeable', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'NEW',
-      };
-      element.mergeable = false;
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-
-    test('doesnt request conflicts if closed and not mergeable', () => {
-      element.patchNum = 7;
-      element.change = {
-        change_id: 123,
-        status: 'MERGED',
-      };
-      element.mergeable = false;
-      element.reload();
-      assert.isFalse(conflictsStub.called);
-    });
-  });
-
-  test('_calculateHasParent', () => {
-    const changeId = 123;
-    const relatedChanges = [];
-
-    assert.equal(element._calculateHasParent(changeId, relatedChanges),
-        false);
-
-    relatedChanges.push({change_id: 123});
-    assert.equal(element._calculateHasParent(changeId, relatedChanges),
-        false);
-
-    relatedChanges.push({change_id: 234});
-    assert.equal(element._calculateHasParent(changeId, relatedChanges),
-        true);
-  });
-
-  suite('hidden attribute and update event', () => {
-    const changes = [{
-      project: 'foo/bar',
-      change_id: 'Ideadbeef',
-      commit: {
-        commit: 'deadbeef',
-        parents: [{commit: 'abc123'}],
-        author: {},
-        subject: 'do that thing',
-      },
-      _change_number: 12345,
-      _revision_number: 1,
-      _current_revision_number: 1,
-      status: 'NEW',
-    }];
-
-    test('clear and empties', () => {
-      element._relatedResponse = {changes};
-      element._submittedTogether = {changes};
-      element._conflicts = changes;
-      element._cherryPicks = changes;
-      element._sameTopic = changes;
-
-      element.hidden = false;
-      element.clear();
-      assert.isTrue(element.hidden);
-      assert.equal(element._relatedResponse.changes.length, 0);
-      assert.equal(element._submittedTogether.changes.length, 0);
-      assert.equal(element._conflicts.length, 0);
-      assert.equal(element._cherryPicks.length, 0);
-      assert.equal(element._sameTopic.length, 0);
-    });
-
-    test('update fires', () => {
-      const updateHandler = sandbox.stub();
-      element.addEventListener('update', updateHandler);
-
-      element._resultsChanged({}, {}, [], [], []);
-      assert.isTrue(element.hidden);
-      assert.isFalse(updateHandler.called);
-
-      element._resultsChanged({}, {}, [], [], ['test']);
-      assert.isFalse(element.hidden);
-      assert.isTrue(updateHandler.called);
-      updateHandler.reset();
-
-      element._resultsChanged(
-          {}, {changes: [], non_visible_changes: 0}, [], [], []);
-      assert.isTrue(element.hidden);
-      assert.isFalse(updateHandler.called);
-
-      element._resultsChanged(
-          {}, {changes: ['test'], non_visible_changes: 0}, [], [], []);
-      assert.isFalse(element.hidden);
-      assert.isTrue(updateHandler.called);
-      updateHandler.reset();
-
-      element._resultsChanged(
-          {}, {changes: [], non_visible_changes: 1}, [], [], []);
-      assert.isFalse(element.hidden);
-      assert.isTrue(updateHandler.called);
-    });
-
-    suite('hiding and unhiding', () => {
-      test('related response', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({changes}, {}, [], [], []);
-        assert.isFalse(element.hidden);
-      });
-
-      test('submitted together', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({}, {changes}, [], [], []);
-        assert.isFalse(element.hidden);
-      });
-
-      test('conflicts', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({}, {}, changes, [], []);
-        assert.isFalse(element.hidden);
-      });
-
-      test('cherrypicks', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({}, {}, [], changes, []);
-        assert.isFalse(element.hidden);
-      });
-
-      test('same topic', () => {
-        assert.isTrue(element.hidden);
-        element._resultsChanged({}, {}, [], [], changes);
-        assert.isFalse(element.hidden);
-      });
-    });
-  });
-
-  test('_computeChangeURL uses GerritNav', () => {
-    const getUrlStub = sandbox.stub(GerritNav, 'getUrlForChangeById');
-    element._computeChangeURL(123, 'abc/def', 12);
-    assert.isTrue(getUrlStub.called);
-  });
-
-  suite('submitted together changes', () => {
-    const change = {
-      project: 'foo/bar',
-      change_id: 'Ideadbeef',
-      commit: {
-        commit: 'deadbeef',
-        parents: [{commit: 'abc123'}],
-        author: {},
-        subject: 'do that thing',
-      },
-      _change_number: 12345,
-      _revision_number: 1,
-      _current_revision_number: 1,
-      status: 'NEW',
-    };
-
-    test('_computeSubmittedTogetherClass', () => {
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass(undefined),
-          'hidden');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({changes: []}),
-          'hidden');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({changes: [{}]}),
-          '');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({
-            changes: [],
-            non_visible_changes: 0,
-          }),
-          'hidden');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({
-            changes: [],
-            non_visible_changes: 1,
-          }),
-          '');
-      assert.strictEqual(
-          element._computeSubmittedTogetherClass({
-            changes: [{}],
-            non_visible_changes: 1,
-          }),
-          '');
-    });
-
-    test('no submitted together changes', () => {
-      flushAsynchronousOperations();
-      assert.include(element.$.submittedTogether.className, 'hidden');
-    });
-
-    test('no non-visible submitted together changes', () => {
-      element._submittedTogether = {changes: [change]};
-      flushAsynchronousOperations();
-      assert.notInclude(element.$.submittedTogether.className, 'hidden');
-      assert.isNull(element.shadowRoot
-          .querySelector('.note'));
-    });
-
-    test('no visible submitted together changes', () => {
-      // Technically this should never happen, but worth asserting the logic.
-      element._submittedTogether = {changes: [], non_visible_changes: 1};
-      flushAsynchronousOperations();
-      assert.notInclude(element.$.submittedTogether.className, 'hidden');
-      assert.isNotNull(element.shadowRoot
-          .querySelector('.note'));
-      assert.strictEqual(
-          element.shadowRoot
-              .querySelector('.note').innerText, '(+ 1 non-visible change)');
-    });
-
-    test('visible and non-visible submitted together changes', () => {
-      element._submittedTogether = {changes: [change], non_visible_changes: 2};
-      flushAsynchronousOperations();
-      assert.notInclude(element.$.submittedTogether.className, 'hidden');
-      assert.isNotNull(element.shadowRoot
-          .querySelector('.note'));
-      assert.strictEqual(
-          element.shadowRoot
-              .querySelector('.note').innerText, '(+ 2 non-visible changes)');
-    });
-  });
-});
-
-suite('gr-related-changes-list plugin tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    resetPlugins();
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    stub('gr-endpoint-decorator', {
-      _import: sandbox.stub().returns(Promise.resolve()),
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-    resetPlugins();
-  });
-
-  test('endpoint params', done => {
-    element.change = {labels: {}};
-    let hookEl;
-    let plugin;
-    pluginApi.install(
-        p => {
-          plugin = p;
-          plugin.hook('related-changes-section').getLastAttached()
-              .then(el => hookEl = el);
-        },
-        '0.1',
-        'http://some/plugins/url1.html');
-    pluginLoader.loadPlugins([]);
-    flush(() => {
-      assert.strictEqual(hookEl.plugin, plugin);
-      assert.strictEqual(hookEl.change, element.change);
-      done();
-    });
-  });
-
-  test('hiding and unhiding', done => {
-    element.change = {labels: {}};
-    let hookEl;
-    let plugin;
-
-    // No changes, and no plugin. The element is still hidden.
-    element._resultsChanged({}, {}, [], [], []);
-    assert.isTrue(element.hidden);
-    pluginApi.install(
-        p => {
-          plugin = p;
-          plugin.hook('related-changes-section').getLastAttached()
-              .then(el => hookEl = el);
-        },
-        '0.1',
-        'http://some/plugins/url2.html');
-    pluginLoader.loadPlugins([]);
-    flush(() => {
-      // No changes, and plugin without hidden attribute. So it's visible.
-      element._resultsChanged({}, {}, [], [], []);
-      assert.isFalse(element.hidden);
-
-      // No changes, but plugin with true hidden attribute. So it's invisible.
-      hookEl.hidden = true;
-
-      element._resultsChanged({}, {}, [], [], []);
-      assert.isTrue(element.hidden);
-
-      // No changes, and plugin with false hidden attribute. So it's visible.
-      hookEl.hidden = false;
-      element._resultsChanged({}, {}, [], [], []);
-      assert.isFalse(element.hidden);
-
-      // Hiding triggered by plugin itself
-      hookEl.hidden = true;
-      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
-        composed: true, bubbles: true,
-      }));
-      assert.isTrue(element.hidden);
-
-      // Unhiding triggered by plugin itself
-      hookEl.hidden = false;
-      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
-        composed: true, bubbles: true,
-      }));
-      assert.isFalse(element.hidden);
-
-      // Hiding plugin keeps list visible, if there are changes
-      hookEl.hidden = false;
-      element._sameTopic = ['test'];
-      element._resultsChanged({}, {}, [], [], ['test']);
-      assert.isFalse(element.hidden);
-      hookEl.hidden = true;
-      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
-        composed: true, bubbles: true,
-      }));
-      assert.isFalse(element.hidden);
-
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
new file mode 100644
index 0000000..08921b4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
@@ -0,0 +1,654 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-related-changes-list.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+const basicFixture = fixtureFromElement('gr-related-changes-list');
+
+suite('gr-related-changes-list tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('connected revisions', () => {
+    const change = {
+      revisions: {
+        'e3c6d60783bfdec9ebae7dcfec4662360433449e': {
+          _number: 1,
+        },
+        '26e5e4c9c7ae31cbd876271cca281ce22b413997': {
+          _number: 2,
+        },
+        'bf7884d695296ca0c91702ba3e2bc8df0f69a907': {
+          _number: 7,
+        },
+        'b5fc49f2e67d1889d5275cac04ad3648f2ec7fe3': {
+          _number: 5,
+        },
+        'd6bcee67570859ccb684873a85cf50b1f0e96fda': {
+          _number: 6,
+        },
+        'cc960918a7f90388f4a9e05753d0f7b90ad44546': {
+          _number: 3,
+        },
+        '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': {
+          _number: 4,
+        },
+      },
+    };
+    let patchNum = 7;
+    let relatedChanges = [
+      {
+        commit: {
+          commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+          parents: [
+            {
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+          parents: [
+            {
+              commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+          parents: [
+            {
+              commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+          parents: [
+            {
+              commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75',
+            },
+          ],
+        },
+      },
+    ];
+
+    let connectedChanges =
+        element._computeConnectedRevisions(change, patchNum, relatedChanges);
+    assert.deepEqual(connectedChanges, [
+      '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+      'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+      '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+      '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+      '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+    ]);
+
+    patchNum = 4;
+    relatedChanges = [
+      {
+        commit: {
+          commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
+          parents: [
+            {
+              commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+          parents: [
+            {
+              commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+          parents: [
+            {
+              commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+          parents: [
+            {
+              commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+          parents: [
+            {
+              commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
+            },
+          ],
+        },
+      },
+      {
+        commit: {
+          commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
+          parents: [
+            {
+              commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c',
+            },
+          ],
+        },
+      },
+    ];
+
+    connectedChanges =
+        element._computeConnectedRevisions(change, patchNum, relatedChanges);
+    assert.deepEqual(connectedChanges, [
+      'af815dac54318826b7f1fa468acc76349ffc588e',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+      'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
+    ]);
+  });
+
+  test('_computeChangeContainerClass', () => {
+    const change1 = {change_id: 123, _number: 0};
+    const change2 = {change_id: 456, _change_number: 1};
+    const change3 = {change_id: 123, _number: 2};
+
+    assert.notEqual(element._computeChangeContainerClass(
+        change1, change1).indexOf('thisChange'), -1);
+    assert.equal(element._computeChangeContainerClass(
+        change1, change2).indexOf('thisChange'), -1);
+    assert.equal(element._computeChangeContainerClass(
+        change1, change3).indexOf('thisChange'), -1);
+  });
+
+  test('_changesEqual', () => {
+    const change1 = {change_id: 123, _number: 0};
+    const change2 = {change_id: 456, _number: 1};
+    const change3 = {change_id: 123, _number: 2};
+    const change4 = {change_id: 123, _change_number: 1};
+
+    assert.isTrue(element._changesEqual(change1, change1));
+    assert.isFalse(element._changesEqual(change1, change2));
+    assert.isFalse(element._changesEqual(change1, change3));
+    assert.isTrue(element._changesEqual(change2, change4));
+  });
+
+  test('_getChangeNumber', () => {
+    const change1 = {change_id: 123, _number: 0};
+    const change2 = {change_id: 456, _change_number: 1};
+    assert.equal(element._getChangeNumber(change1), 0);
+    assert.equal(element._getChangeNumber(change2), 1);
+  });
+
+  test('event for section loaded fires for each section ', () => {
+    const loadedStub = sinon.stub();
+    element.patchNum = 7;
+    element.change = {
+      change_id: 123,
+      status: 'NEW',
+    };
+    element.mergeable = true;
+    element.addEventListener('new-section-loaded', loadedStub);
+    sinon.stub(element, '_getRelatedChanges')
+        .returns(Promise.resolve({changes: []}));
+    sinon.stub(element, '_getSubmittedTogether')
+        .returns(Promise.resolve());
+    sinon.stub(element, '_getCherryPicks')
+        .returns(Promise.resolve());
+    sinon.stub(element, '_getConflicts')
+        .returns(Promise.resolve());
+
+    return element.reload().then(() => {
+      assert.equal(loadedStub.callCount, 4);
+    });
+  });
+
+  suite('_getConflicts resolves undefined', () => {
+    let element;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+
+      sinon.stub(element, '_getRelatedChanges')
+          .returns(Promise.resolve({changes: []}));
+      sinon.stub(element, '_getSubmittedTogether')
+          .returns(Promise.resolve());
+      sinon.stub(element, '_getCherryPicks')
+          .returns(Promise.resolve());
+      sinon.stub(element, '_getConflicts')
+          .returns(Promise.resolve());
+    });
+
+    test('_conflicts are an empty array', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'NEW',
+      };
+      element.mergeable = true;
+      element.reload();
+      assert.equal(element._conflicts.length, 0);
+    });
+  });
+
+  suite('get conflicts tests', () => {
+    let element;
+    let conflictsStub;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+
+      sinon.stub(element, '_getRelatedChanges')
+          .returns(Promise.resolve({changes: []}));
+      sinon.stub(element, '_getSubmittedTogether')
+          .returns(Promise.resolve());
+      sinon.stub(element, '_getCherryPicks')
+          .returns(Promise.resolve());
+      conflictsStub = sinon.stub(element, '_getConflicts')
+          .returns(Promise.resolve());
+    });
+
+    test('request conflicts if open and mergeable', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'NEW',
+      };
+      element.mergeable = true;
+      element.reload();
+      assert.isTrue(conflictsStub.called);
+    });
+
+    test('does not request conflicts if closed and mergeable', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'MERGED',
+      };
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('does not request conflicts if open and not mergeable', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'NEW',
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+
+    test('doesnt request conflicts if closed and not mergeable', () => {
+      element.patchNum = 7;
+      element.change = {
+        change_id: 123,
+        status: 'MERGED',
+      };
+      element.mergeable = false;
+      element.reload();
+      assert.isFalse(conflictsStub.called);
+    });
+  });
+
+  test('_calculateHasParent', () => {
+    const changeId = 123;
+    const relatedChanges = [];
+
+    assert.equal(element._calculateHasParent(changeId, relatedChanges),
+        false);
+
+    relatedChanges.push({change_id: 123});
+    assert.equal(element._calculateHasParent(changeId, relatedChanges),
+        false);
+
+    relatedChanges.push({change_id: 234});
+    assert.equal(element._calculateHasParent(changeId, relatedChanges),
+        true);
+  });
+
+  suite('hidden attribute and update event', () => {
+    const changes = [{
+      project: 'foo/bar',
+      change_id: 'Ideadbeef',
+      commit: {
+        commit: 'deadbeef',
+        parents: [{commit: 'abc123'}],
+        author: {},
+        subject: 'do that thing',
+      },
+      _change_number: 12345,
+      _revision_number: 1,
+      _current_revision_number: 1,
+      status: 'NEW',
+    }];
+
+    test('clear and empties', () => {
+      element._relatedResponse = {changes};
+      element._submittedTogether = {changes};
+      element._conflicts = changes;
+      element._cherryPicks = changes;
+      element._sameTopic = changes;
+
+      element.hidden = false;
+      element.clear();
+      assert.isTrue(element.hidden);
+      assert.equal(element._relatedResponse.changes.length, 0);
+      assert.equal(element._submittedTogether.changes.length, 0);
+      assert.equal(element._conflicts.length, 0);
+      assert.equal(element._cherryPicks.length, 0);
+      assert.equal(element._sameTopic.length, 0);
+    });
+
+    test('update fires', () => {
+      const updateHandler = sinon.stub();
+      element.addEventListener('update', updateHandler);
+
+      element._resultsChanged({}, {}, [], [], []);
+      assert.isTrue(element.hidden);
+      assert.isFalse(updateHandler.called);
+
+      element._resultsChanged({}, {}, [], [], ['test']);
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+      updateHandler.reset();
+
+      element._resultsChanged(
+          {}, {changes: [], non_visible_changes: 0}, [], [], []);
+      assert.isTrue(element.hidden);
+      assert.isFalse(updateHandler.called);
+
+      element._resultsChanged(
+          {}, {changes: ['test'], non_visible_changes: 0}, [], [], []);
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+      updateHandler.reset();
+
+      element._resultsChanged(
+          {}, {changes: [], non_visible_changes: 1}, [], [], []);
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+    });
+
+    suite('hiding and unhiding', () => {
+      test('related response', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({changes}, {}, [], [], []);
+        assert.isFalse(element.hidden);
+      });
+
+      test('submitted together', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({}, {changes}, [], [], []);
+        assert.isFalse(element.hidden);
+      });
+
+      test('conflicts', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({}, {}, changes, [], []);
+        assert.isFalse(element.hidden);
+      });
+
+      test('cherrypicks', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({}, {}, [], changes, []);
+        assert.isFalse(element.hidden);
+      });
+
+      test('same topic', () => {
+        assert.isTrue(element.hidden);
+        element._resultsChanged({}, {}, [], [], changes);
+        assert.isFalse(element.hidden);
+      });
+    });
+  });
+
+  test('_computeChangeURL uses GerritNav', () => {
+    const getUrlStub = sinon.stub(GerritNav, 'getUrlForChangeById');
+    element._computeChangeURL(123, 'abc/def', 12);
+    assert.isTrue(getUrlStub.called);
+  });
+
+  suite('submitted together changes', () => {
+    const change = {
+      project: 'foo/bar',
+      change_id: 'Ideadbeef',
+      commit: {
+        commit: 'deadbeef',
+        parents: [{commit: 'abc123'}],
+        author: {},
+        subject: 'do that thing',
+      },
+      _change_number: 12345,
+      _revision_number: 1,
+      _current_revision_number: 1,
+      status: 'NEW',
+    };
+
+    test('_computeSubmittedTogetherClass', () => {
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass(undefined),
+          'hidden');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({changes: []}),
+          'hidden');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({changes: [{}]}),
+          '');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({
+            changes: [],
+            non_visible_changes: 0,
+          }),
+          'hidden');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({
+            changes: [],
+            non_visible_changes: 1,
+          }),
+          '');
+      assert.strictEqual(
+          element._computeSubmittedTogetherClass({
+            changes: [{}],
+            non_visible_changes: 1,
+          }),
+          '');
+    });
+
+    test('no submitted together changes', () => {
+      flushAsynchronousOperations();
+      assert.include(element.$.submittedTogether.className, 'hidden');
+    });
+
+    test('no non-visible submitted together changes', () => {
+      element._submittedTogether = {changes: [change]};
+      flushAsynchronousOperations();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.isNull(element.shadowRoot
+          .querySelector('.note'));
+    });
+
+    test('no visible submitted together changes', () => {
+      // Technically this should never happen, but worth asserting the logic.
+      element._submittedTogether = {changes: [], non_visible_changes: 1};
+      flushAsynchronousOperations();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.isNotNull(element.shadowRoot
+          .querySelector('.note'));
+      assert.strictEqual(
+          element.shadowRoot
+              .querySelector('.note').innerText.trim(),
+          '(+ 1 non-visible change)');
+    });
+
+    test('visible and non-visible submitted together changes', () => {
+      element._submittedTogether = {changes: [change], non_visible_changes: 2};
+      flushAsynchronousOperations();
+      assert.notInclude(element.$.submittedTogether.className, 'hidden');
+      assert.isNotNull(element.shadowRoot
+          .querySelector('.note'));
+      assert.strictEqual(
+          element.shadowRoot
+              .querySelector('.note').innerText.trim(),
+          '(+ 2 non-visible changes)');
+    });
+  });
+});
+
+suite('gr-related-changes-list plugin tests', () => {
+  let element;
+
+  setup(() => {
+    resetPlugins();
+    element = basicFixture.instantiate();
+  });
+
+  teardown(() => {
+    resetPlugins();
+  });
+
+  test('endpoint params', done => {
+    element.change = {labels: {}};
+    let hookEl;
+    let plugin;
+    pluginApi.install(
+        p => {
+          plugin = p;
+          plugin.hook('related-changes-section').getLastAttached()
+              .then(el => hookEl = el);
+        },
+        '0.1',
+        'http://some/plugins/url1.html');
+    pluginLoader.loadPlugins([]);
+    flush(() => {
+      assert.strictEqual(hookEl.plugin, plugin);
+      assert.strictEqual(hookEl.change, element.change);
+      done();
+    });
+  });
+
+  test('hiding and unhiding', done => {
+    element.change = {labels: {}};
+    let hookEl;
+    let plugin;
+
+    // No changes, and no plugin. The element is still hidden.
+    element._resultsChanged({}, {}, [], [], []);
+    assert.isTrue(element.hidden);
+    pluginApi.install(
+        p => {
+          plugin = p;
+          plugin.hook('related-changes-section').getLastAttached()
+              .then(el => hookEl = el);
+        },
+        '0.1',
+        'http://some/plugins/url2.html');
+    pluginLoader.loadPlugins([]);
+    flush(() => {
+      // No changes, and plugin without hidden attribute. So it's visible.
+      element._resultsChanged({}, {}, [], [], []);
+      assert.isFalse(element.hidden);
+
+      // No changes, but plugin with true hidden attribute. So it's invisible.
+      hookEl.hidden = true;
+
+      element._resultsChanged({}, {}, [], [], []);
+      assert.isTrue(element.hidden);
+
+      // No changes, and plugin with false hidden attribute. So it's visible.
+      hookEl.hidden = false;
+      element._resultsChanged({}, {}, [], [], []);
+      assert.isFalse(element.hidden);
+
+      // Hiding triggered by plugin itself
+      hookEl.hidden = true;
+      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
+        composed: true, bubbles: true,
+      }));
+      assert.isTrue(element.hidden);
+
+      // Unhiding triggered by plugin itself
+      hookEl.hidden = false;
+      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
+        composed: true, bubbles: true,
+      }));
+      assert.isFalse(element.hidden);
+
+      // Hiding plugin keeps list visible, if there are changes
+      hookEl.hidden = false;
+      element._sameTopic = ['test'];
+      element._resultsChanged({}, {}, [], [], ['test']);
+      assert.isFalse(element.hidden);
+      hookEl.hidden = true;
+      hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
+        composed: true, bubbles: true,
+      }));
+      assert.isFalse(element.hidden);
+
+      done();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
deleted file mode 100644
index d3232e9..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
+++ /dev/null
@@ -1,175 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-reply-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-reply-dialog></gr-reply-dialog>
-  </template>
-</test-fixture>
-
-<test-fixture id="plugin-host">
-  <template>
-    <gr-plugin-host></gr-plugin-host>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../plugins/gr-plugin-host/gr-plugin-host.js';
-import './gr-reply-dialog.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-_testOnly_initGerritPluginApi();
-
-suite('gr-reply-dialog tests', () => {
-  let element;
-  let changeNum;
-  let patchNum;
-
-  let sandbox;
-
-  const setupElement = element => {
-    element.change = {
-      _number: changeNum,
-      labels: {
-        'Verified': {
-          values: {
-            '-1': 'Fails',
-            ' 0': 'No score',
-            '+1': 'Verified',
-          },
-          default_value: 0,
-        },
-        'Code-Review': {
-          values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didn\'t submit this',
-            ' 0': 'No score',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          all: [{_account_id: 42, value: 0}],
-          default_value: 0,
-        },
-      },
-    };
-    element.patchNum = patchNum;
-    element.permittedLabels = {
-      'Code-Review': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    sandbox.stub(element, 'fetchChangeUpdates')
-        .returns(Promise.resolve({isLatest: true}));
-  };
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-
-    changeNum = 42;
-    patchNum = 1;
-
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getAccount() { return Promise.resolve({_account_id: 42}); },
-    });
-
-    element = fixture('basic');
-    setupElement(element);
-
-    // Allow the elements created by dom-repeat to be stamped.
-    flushAsynchronousOperations();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_submit blocked when invalid email is supplied to ccs', () => {
-    const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
-    // Stub the below function to avoid side effects from the send promise
-    // resolving.
-    sandbox.stub(element, '_purgeReviewersPendingRemove');
-
-    element.$.ccs.$.entry.setText('test');
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
-    assert.isFalse(sendStub.called);
-    flushAsynchronousOperations();
-
-    element.$.ccs.$.entry.setText('test@test.test');
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
-    assert.isTrue(sendStub.called);
-  });
-
-  test('lgtm plugin', done => {
-    resetPlugins();
-    const pluginHost = fixture('plugin-host');
-    pluginHost.config = {
-      plugin: {
-        js_resource_paths: [],
-        html_resource_paths: [
-          new URL('test/plugin.html?' + Math.random(),
-              window.location.href).toString(),
-        ],
-      },
-    };
-    element = fixture('basic');
-    setupElement(element);
-    const importSpy =
-        sandbox.spy(element.shadowRoot
-            .querySelector('gr-endpoint-decorator'), '_import');
-    pluginLoader.awaitPluginsLoaded().then(() => {
-      Promise.all(importSpy.returnValues).then(() => {
-        flush(() => {
-          const textarea = element.$.textarea.getNativeTextarea();
-          textarea.value = 'LGTM';
-          textarea.dispatchEvent(new CustomEvent(
-              'input', {bubbles: true, composed: true}));
-          const labelScoreRows = dom(element.$.labelScores.root)
-              .querySelector('gr-label-score-row[name="Code-Review"]');
-          const selectedBtn = dom(labelScoreRows.root)
-              .querySelector('gr-button[data-value="+1"].iron-selected');
-          assert.isOk(selectedBtn);
-          done();
-        });
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
new file mode 100644
index 0000000..56612ff
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -0,0 +1,141 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import './gr-reply-dialog.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+const basicFixture = fixtureFromElement('gr-reply-dialog');
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-reply-dialog-it tests', () => {
+  let element;
+  let changeNum;
+  let patchNum;
+
+  const setupElement = element => {
+    element.change = {
+      _number: changeNum,
+      labels: {
+        'Verified': {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified',
+          },
+          default_value: 0,
+        },
+        'Code-Review': {
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didn\'t submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          all: [{_account_id: 42, value: 0}],
+          default_value: 0,
+        },
+      },
+    };
+    element.patchNum = patchNum;
+    element.permittedLabels = {
+      'Code-Review': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+  };
+
+  setup(() => {
+    changeNum = 42;
+    patchNum = 1;
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getAccount() { return Promise.resolve({_account_id: 42}); },
+    });
+
+    element = basicFixture.instantiate();
+    setupElement(element);
+    // Allow the elements created by dom-repeat to be stamped.
+    flushAsynchronousOperations();
+  });
+
+  teardown(() => {
+    resetPlugins();
+  });
+
+  test('_submit blocked when invalid email is supplied to ccs', () => {
+    const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
+    // Stub the below function to avoid side effects from the send promise
+    // resolving.
+    sinon.stub(element, '_purgeReviewersPendingRemove');
+
+    element.$.ccs.$.entry.setText('test');
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isFalse(sendStub.called);
+    flushAsynchronousOperations();
+
+    element.$.ccs.$.entry.setText('test@test.test');
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isTrue(sendStub.called);
+  });
+
+  test('lgtm plugin', done => {
+    resetPlugins();
+    pluginApi.install(plugin => {
+      const replyApi = plugin.changeReply();
+      replyApi.addReplyTextChangedCallback(text => {
+        const label = 'Code-Review';
+        const labelValue = replyApi.getLabelValue(label);
+        if (labelValue &&
+            labelValue === ' 0' &&
+            text.indexOf('LGTM') === 0) {
+          replyApi.setLabelValue(label, '+1');
+        }
+      });
+    }, null, 'http://test.com/plugins/lgtm.js');
+    element = basicFixture.instantiate();
+    setupElement(element);
+    pluginLoader.loadPlugins([]);
+    pluginLoader.awaitPluginsLoaded().then(() => {
+      flush(() => {
+        const textarea = element.$.textarea.getNativeTextarea();
+        textarea.value = 'LGTM';
+        textarea.dispatchEvent(new CustomEvent(
+            'input', {bubbles: true, composed: true}));
+        const labelScoreRows = dom(element.$.labelScores.root)
+            .querySelector('gr-label-score-row[name="Code-Review"]');
+        const selectedBtn = dom(labelScoreRows.root)
+            .querySelector('gr-button[data-value="+1"].iron-selected');
+        assert.isOk(selectedBtn);
+        done();
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index 419e95b..6ccca94 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -14,10 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '../../core/gr-reporting/gr-reporting.js';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
 import '../../shared/gr-account-chip/gr-account-chip.js';
 import '../../shared/gr-textarea/gr-textarea.js';
@@ -31,18 +28,17 @@
 import '../gr-label-scores/gr-label-scores.js';
 import '../gr-thread-list/gr-thread-list.js';
 import '../../../styles/shared-styles.js';
-import '../gr-comment-list/gr-comment-list.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-reply-dialog_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {appContext} from '../../../services/app-context.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
+import {KnownExperimentId} from '../../../services/flags/flags.js';
+import {fetchChangeUpdates} from '../../../utils/patch-set-util.js';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {getDisplayName} from '../../../utils/display-name-util.js';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -80,16 +76,10 @@
 const SEND_REPLY_TIMING_LABEL = 'SendReply';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrReplyDialog extends mixinBehaviors( [
-  BaseUrlBehavior,
-  KeyboardShortcutBehavior,
-  PatchSetBehavior,
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrReplyDialog extends KeyboardShortcutMixin(GestureEventListeners(
+    LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-reply-dialog'; }
@@ -131,9 +121,17 @@
    * @event send-disabled-changed
    */
 
+  /**
+   * Fired to reload the change page.
+   *
+   * @event reload
+   */
+
   constructor() {
     super();
     this.FocusTarget = FocusTarget;
+    this.reporting = appContext.reportingService;
+    this.flagsService = appContext.flagsService;
   }
 
   static get properties() {
@@ -180,6 +178,7 @@
        * @type {{ commentlinks: Array }}
        */
       projectConfig: Object,
+      serverConfig: Object,
       knownLatestState: String,
       underReview: {
         type: Boolean,
@@ -198,6 +197,14 @@
         computed: '_computeMessagePlaceholder(canBeStarted)',
       },
       _owner: Object,
+      /**
+       * This is only set, if an uploader exists for the latest patchset, and
+       * it is NOT the owner.
+       */
+      _uploader: {
+        type: Object,
+        computed: '_computeUploader(change)',
+      },
       /** @type {?} */
       _pendingConfirmationDetails: Object,
       _includeComments: {
@@ -248,17 +255,59 @@
         type: Boolean,
         value: false,
       },
+      /**
+       * Is the UI in the state where the user individually modifies attention
+       * set entries?
+       */
+      _attentionModified: {
+        type: Boolean,
+        value: false,
+      },
+      /**
+       * Set of account IDs that currently constitutes the attention set, read
+       * from change.attention_set. Will be updated by the
+       * _computeNewAttention() observer.
+       */
+      _currentAttentionSet: {
+        type: Object,
+        value: () => new Set(),
+      },
+      /**
+       * Set of account IDs that should constitute the attention set after
+       * publishing the votes/comments. Will be initialized with a default (that
+       * matches the default rules that the backend would also apply) by the
+       * _computeNewAttention(_account, _reviewers, change) observer.
+       */
+      _newAttentionSet: {
+        type: Object,
+        value: () => new Set(),
+      },
       _sendDisabled: {
         type: Boolean,
         computed: '_computeSendButtonDisabled(canBeStarted, ' +
           'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
-          '_includeComments, disabled, _commentEditing)',
+          '_includeComments, disabled, _commentEditing, _attentionModified)',
         observer: '_sendDisabledChanged',
       },
       draftCommentThreads: {
         type: Array,
         observer: '_handleHeightChanged',
       },
+      // Track if the message typed in the reply dialog will be created as a
+      // resolved/unresolved patchset level comment
+      _isResolvedPatchsetLevelComment: {
+        type: Boolean,
+        value: true,
+      },
+
+      /**
+       * A copy of added reviewers, a new copy is created when any change
+       * made to the reviewers.
+       */
+      _allReviewers: {
+        type: Array,
+        computed: '_computeAllReviewers(_reviewers.*)',
+      },
     };
   }
 
@@ -274,6 +323,8 @@
       '_changeUpdated(change.reviewers.*, change.owner)',
       '_ccsChanged(_ccs.splices)',
       '_reviewersChanged(_reviewers.splices)',
+      '_computeNewAttention(' +
+        '_account, _reviewers.*, change, draftCommentThreads)',
     ];
   }
 
@@ -287,17 +338,32 @@
     this.addEventListener('comment-editing-changed', e => {
       this._commentEditing = e.detail;
     });
+
+    // Plugins on reply-reviewers endpoint can take advantage of these
+    // events to add / remove reviewers
+
+    this.addEventListener('add-reviewer', e => {
+      // Only support account type, see more from:
+      // elements/shared/gr-account-list/gr-account-list.js#addAccountItem
+      this.$.reviewers.addAccountItem({account: e.detail.reviewer});
+    });
+
+    this.addEventListener('remove-reviewer', e => {
+      this.$.reviewers.removeAccount(e.detail.reviewer);
+    });
   }
 
   /** @override */
   ready() {
     super.ready();
+    this._isPatchsetCommentsExperimentEnabled = this.flagsService
+        .isEnabled(KnownExperimentId.PATCHSET_COMMENTS);
     this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this);
   }
 
   open(opt_focusTarget) {
     this.knownLatestState = LatestPatchState.CHECKING;
-    this.fetchChangeUpdates(this.change, this.$.restAPI)
+    fetchChangeUpdates(this.change, this.$.restAPI)
         .then(result => {
           this.knownLatestState = result.isLatest ?
             LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
@@ -406,7 +472,7 @@
     for (const splice of indexSplices) {
       for (const account of splice.removed) {
         if (!this._reviewersPendingRemove[type]) {
-          console.err('Invalid type ' + type + ' for reviewer.');
+          console.error('Invalid type ' + type + ' for reviewer.');
           return;
         }
         this._reviewersPendingRemove[type].push(account);
@@ -477,24 +543,59 @@
   }
 
   send(includeComments, startReview) {
-    this.$.reporting.time(SEND_REPLY_TIMING_LABEL);
+    this.reporting.time(SEND_REPLY_TIMING_LABEL);
     const labels = this.$.labelScores.getLabelValues();
 
-    const obj = {
+    const reviewInput = {
       drafts: includeComments ? 'PUBLISH_ALL_REVISIONS' : 'KEEP',
       labels,
     };
 
     if (startReview) {
-      obj.ready = true;
+      reviewInput.ready = true;
+    }
+
+    if (this._isAttentionSetEnabled(this.serverConfig)) {
+      reviewInput.ignore_automatic_attention_set_rules = true;
+      reviewInput.add_to_attention_set = [];
+      for (const user of this._newAttentionSet) {
+        if (!this._currentAttentionSet.has(user)) {
+          reviewInput.add_to_attention_set.push({
+            user,
+            reason: 'manually added in reply dialog',
+          });
+        }
+      }
+      reviewInput.remove_from_attention_set = [];
+      for (const user of this._currentAttentionSet) {
+        if (!this._newAttentionSet.has(user)) {
+          reviewInput.remove_from_attention_set.push({
+            user,
+            reason: 'manually removed in reply dialog',
+          });
+        }
+      }
+      this.reportAttentionSetChanges(this._attentionModified,
+          reviewInput.add_to_attention_set,
+          reviewInput.remove_from_attention_set);
     }
 
     if (this.draft != null) {
-      obj.message = this.draft;
+      if (this._isPatchsetCommentsExperimentEnabled) {
+        const comment = {
+          message: this.draft,
+          unresolved: !this._isResolvedPatchsetLevelComment,
+        };
+        reviewInput.comments = {
+          [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment],
+        };
+      } else {
+        reviewInput.message = this.draft;
+      }
     }
 
     const accountAdditions = {};
-    obj.reviewers = this.$.reviewers.additions().map(reviewer => {
+    reviewInput.reviewers = this.$.reviewers.additions().map(reviewer => {
       if (reviewer.account) {
         accountAdditions[reviewer.account._account_id] = true;
       }
@@ -508,14 +609,14 @@
         }
         reviewer = this._mapReviewer(reviewer);
         reviewer.state = 'CC';
-        obj.reviewers.push(reviewer);
+        reviewInput.reviewers.push(reviewer);
       }
     }
 
     this.disabled = true;
 
     const errFn = this._handle400Error.bind(this);
-    return this._saveReview(obj, errFn)
+    return this._saveReview(reviewInput, errFn)
         .then(response => {
           if (!response) {
             // Null or undefined response indicates that an error handler
@@ -577,6 +678,11 @@
     return FocusTarget.BODY;
   }
 
+  _isOwner(account, change) {
+    if (!account || !change || !change.owner) return false;
+    return account._account_id === change.owner._account_id;
+  }
+
   _handle400Error(response) {
     // A call to _saveReview could fail with a server error if erroneous
     // reviewers were requested. This is signalled with a 400 Bad Request
@@ -623,11 +729,11 @@
   }
 
   _computeHideDraftList(draftCommentThreads) {
-    return draftCommentThreads.length === 0;
+    return !draftCommentThreads || draftCommentThreads.length === 0;
   }
 
   _computeDraftsTitle(draftCommentThreads) {
-    const total = draftCommentThreads.length;
+    const total = draftCommentThreads ? draftCommentThreads.length : 0;
     if (total == 0) { return ''; }
     if (total == 1) { return '1 Draft'; }
     if (total > 1) { return total + ' Drafts'; }
@@ -641,7 +747,7 @@
 
   _changeUpdated(changeRecord, owner) {
     // Polymer 2: check for undefined
-    if ([changeRecord, owner].some(arg => arg === undefined)) {
+    if ([changeRecord, owner].includes(undefined)) {
       return;
     }
 
@@ -680,6 +786,140 @@
     this._reviewers = reviewers;
   }
 
+  _handleAttentionModify() {
+    this._attentionModified = true;
+    // If the attention-detail section is expanded without dispatching this
+    // event, then the dialog may expand beyond the screen's bottom border.
+    this.dispatchEvent(new CustomEvent(
+        'iron-resize', {composed: true, bubbles: true}));
+  }
+
+  _showAttentionSummary(config, attentionModified) {
+    return this._isAttentionSetEnabled(config) && !attentionModified;
+  }
+
+  _showAttentionDetails(config, attentionModified) {
+    return this._isAttentionSetEnabled(config) && attentionModified;
+  }
+
+  _isAttentionSetEnabled(config) {
+    return !!config && !!config.change && config.change.enable_attention_set;
+  }
+
+  _handleAttentionClick(e) {
+    const id = e.target.account._account_id;
+    if (!id) return;
+    if (this._newAttentionSet.has(id)) {
+      this._newAttentionSet.delete(id);
+    } else {
+      this._newAttentionSet.add(id);
+    }
+    // Ensure that Polymer picks up the change.
+    this._newAttentionSet = new Set(this._newAttentionSet);
+  }
+
+  _computeHasNewAttention(account, newAttention) {
+    return newAttention && account && newAttention.has(account._account_id);
+  }
+
+  _computeNewAttention(user, reviewers, change, draftCommentThreads) {
+    if ([user, reviewers, change, draftCommentThreads].includes(undefined)) {
+      return;
+    }
+    this._attentionModified = false;
+    this._currentAttentionSet =
+        new Set(Object.keys(change.attention_set || {})
+            .map(id => parseInt(id)));
+    const newAttention = new Set(this._currentAttentionSet);
+    // Add everyone that the user is replying to in a comment thread.
+    this._computeCommentAccounts(draftCommentThreads).forEach(
+        id => newAttention.add(id)
+    );
+    // Add all new reviewers.
+    reviewers.base.filter(r => r._pendingAdd)
+        .forEach(r => newAttention.add(r._account_id));
+    // Remove the current user.
+    if (user) newAttention.delete(user._account_id);
+    // Add the uploader, if someone else replies.
+    if (this._uploader && user &&
+        this._uploader._account_id !== user._account_id) {
+      newAttention.add(this._uploader._account_id);
+    }
+    // Add the owner, if someone else replies. Also add the owner, if the
+    // attention set would otherwise be empty.
+    if (change.owner) {
+      if (!this._isOwner(user, change) || newAttention.size === 0) {
+        newAttention.add(change.owner._account_id);
+      }
+    }
+    // Finally make sure that everyone in the attention set is still active as
+    // owner, reviewer or cc.
+    const allAccountIds = this._allAccounts()
+        .map(a => a._account_id)
+        .filter(id => !!id);
+    this._newAttentionSet = new Set(
+        [...newAttention].filter(id => allAccountIds.includes(id)));
+  }
+
+  _computeCommentAccounts(threads) {
+    const accountIds = new Set();
+    threads.forEach(thread => {
+      thread.comments.forEach(comment => {
+        if (comment.author) {
+          accountIds.add(comment.author._account_id);
+        }
+      });
+    });
+    return accountIds;
+  }
+
+  _isNewAttentionEmpty(config, currentAttentionSet, newAttentionSet) {
+    return this._computeNewAttentionNames(
+        config, currentAttentionSet, newAttentionSet).length === 0;
+  }
+
+  _computeNewAttentionNames(config, currentAttentionSet, newAttentionSet) {
+    if ([currentAttentionSet, newAttentionSet].includes(undefined)) return '';
+    const addedNames = [...newAttentionSet]
+        .filter(id => !currentAttentionSet.has(id))
+        .map(id => this._findAccountById(id))
+        .filter(account => !!account)
+        .map(account => getDisplayName(config, account))
+        .sort();
+    return addedNames.join(', ');
+  }
+
+  _findAccountById(accountId) {
+    return this._allAccounts().find(r => r._account_id === accountId);
+  }
+
+  _allAccounts() {
+    let allAccounts = [];
+    if (this.change && this.change.owner) allAccounts.push(this.change.owner);
+    if (this._uploader) allAccounts.push(this._uploader);
+    if (this._reviewers) allAccounts = [...allAccounts, ...this._reviewers];
+    if (this._ccs) allAccounts = [...allAccounts, ...this._ccs];
+    return allAccounts;
+  }
+
+  _computeShowAttentionCcs(ccs) {
+    return !!ccs && ccs.length > 0;
+  }
+
+  _computeUploader(change) {
+    if (!change || !change.current_revision ||
+        !change.revisions[change.current_revision]) {
+      return undefined;
+    }
+    const rev = change.revisions[change.current_revision];
+
+    if (!rev.uploader ||
+        change.owner._account_id === rev.uploader._account_id) {
+      return undefined;
+    }
+    return rev.uploader;
+  }
+
   _accountOrGroupKey(entry) {
     return entry.id || entry._account_id;
   }
@@ -865,8 +1105,8 @@
   }
 
   _reload() {
-    // Load the current change without any patch range.
-    GerritNav.navigateToChange(this.change);
+    this.dispatchEvent(new CustomEvent('reload',
+        {detail: {clearPatchset: true}, bubbles: false, composed: true}));
     this.cancel();
   }
 
@@ -885,7 +1125,8 @@
 
   _computeSendButtonDisabled(
       canBeStarted, draftCommentThreads, text, reviewersMutated,
-      labelsChanged, includeComments, disabled, commentEditing) {
+      labelsChanged, includeComments, disabled, commentEditing,
+      attentionModified) {
     // Polymer 2: check for undefined
     if ([
       canBeStarted,
@@ -896,20 +1137,21 @@
       includeComments,
       disabled,
       commentEditing,
-    ].some(arg => arg === undefined)) {
+      attentionModified,
+    ].includes(undefined)) {
       return undefined;
     }
-
     if (commentEditing || disabled) { return true; }
     if (canBeStarted === true) { return false; }
     const hasDrafts = includeComments && draftCommentThreads.length;
-    return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
+    return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged &&
+      !attentionModified;
   }
 
   _computePatchSetWarning(patchNum, labelsChanged) {
     let str = `Patch ${patchNum} is not latest.`;
     if (labelsChanged) {
-      str += ' Voting on a non-latest patch will have no effect.';
+      str += ' Voting will have no effect.';
     }
     return str;
   }
@@ -946,6 +1188,30 @@
       composed: true, bubbles: true,
     }));
   }
+
+  reportAttentionSetChanges(modified, addedSet, removedSet) {
+    const actions = modified ? ['MODIFIED'] : ['NOT_MODIFIED'];
+    const ownerId = (this.change && this.change.owner
+        && this.change.owner._account_id) || -1;
+    const selfId = (this._account && this._account._account_id) || -1;
+    for (const added of (addedSet || [])) {
+      const addedId = added.user;
+      const self = addedId === selfId ? '_SELF' : '';
+      const role = addedId === ownerId ? '_OWNER' : '_REVIEWER';
+      actions.push('ADD' + self + role);
+    }
+    for (const removed of (removedSet || [])) {
+      const removedId = removed.user;
+      const self = removedId === selfId ? '_SELF' : '';
+      const role = removedId === ownerId ? '_OWNER' : '_REVIEWER';
+      actions.push('REMOVE' + self + role);
+    }
+    this.reporting.reportInteraction('attention-set-actions', {actions});
+  }
+
+  _computeAllReviewers() {
+    return [...this._reviewers];
+  }
 }
 
 customElements.define(GrReplyDialog.is, GrReplyDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js
deleted file mode 100644
index 54fd47a..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.js
+++ /dev/null
@@ -1,322 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      background-color: var(--dialog-background-color);
-      display: block;
-      max-height: 90vh;
-    }
-    :host([disabled]) {
-      pointer-events: none;
-    }
-    :host([disabled]) .container {
-      opacity: 0.5;
-    }
-    .container {
-      display: flex;
-      flex-direction: column;
-      max-height: 100%;
-    }
-    section {
-      border-top: 1px solid var(--border-color);
-      flex-shrink: 0;
-      padding: var(--spacing-m) var(--spacing-xl);
-      width: 100%;
-    }
-    section.labelsContainer {
-      /* We want the :hover highlight to extend to the border of the dialog. */
-      padding: var(--spacing-m) 0;
-    }
-    .actions {
-      background-color: var(--dialog-background-color);
-      bottom: 0;
-      display: flex;
-      justify-content: space-between;
-      position: sticky;
-      /* @see Issue 8602 */
-      z-index: 1;
-    }
-    .actions .right gr-button {
-      margin-left: var(--spacing-l);
-    }
-    .peopleContainer,
-    .labelsContainer {
-      flex-shrink: 0;
-    }
-    .peopleContainer {
-      border-top: none;
-      display: table;
-    }
-    .peopleList {
-      display: flex;
-    }
-    .peopleListLabel {
-      color: var(--deemphasized-text-color);
-      margin-top: var(--spacing-xs);
-      min-width: 6em;
-      padding-right: var(--spacing-m);
-    }
-    gr-account-list {
-      display: flex;
-      flex-wrap: wrap;
-      flex: 1;
-    }
-    #reviewerConfirmationOverlay {
-      padding: var(--spacing-l);
-      text-align: center;
-    }
-    .reviewerConfirmationButtons {
-      margin-top: var(--spacing-l);
-    }
-    .groupName {
-      font-weight: var(--font-weight-bold);
-    }
-    .groupSize {
-      font-style: italic;
-    }
-    .textareaContainer {
-      min-height: 12em;
-      position: relative;
-    }
-    .textareaContainer,
-    #textarea,
-    gr-endpoint-decorator {
-      display: flex;
-      width: 100%;
-    }
-    gr-endpoint-decorator[name='reply-label-scores'] {
-      display: block;
-    }
-    .previewContainer gr-formatted-text {
-      background: var(--table-header-background-color);
-      padding: var(--spacing-l);
-    }
-    .draftsContainer h3 {
-      margin-top: var(--spacing-xs);
-    }
-    #checkingStatusLabel,
-    #notLatestLabel {
-      margin-left: var(--spacing-l);
-    }
-    #checkingStatusLabel {
-      color: var(--deemphasized-text-color);
-      font-style: italic;
-    }
-    #notLatestLabel,
-    #savingLabel {
-      color: var(--error-text-color);
-    }
-    #savingLabel {
-      display: none;
-    }
-    #savingLabel.saving {
-      display: inline;
-    }
-    #pluginMessage {
-      color: var(--deemphasized-text-color);
-      margin-left: var(--spacing-l);
-      margin-bottom: var(--spacing-m);
-    }
-    #pluginMessage:empty {
-      display: none;
-    }
-  </style>
-  <div class="container" tabindex="-1">
-    <section class="peopleContainer">
-      <div class="peopleList">
-        <div class="peopleListLabel">Reviewers</div>
-        <gr-account-list
-          id="reviewers"
-          accounts="{{_reviewers}}"
-          removable-values="[[change.removable_reviewers]]"
-          filter="[[filterReviewerSuggestion]]"
-          pending-confirmation="{{_reviewerPendingConfirmation}}"
-          placeholder="Add reviewer..."
-          on-account-text-changed="_handleAccountTextEntry"
-          suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
-        >
-        </gr-account-list>
-      </div>
-      <div class="peopleList">
-        <div class="peopleListLabel">CC</div>
-        <gr-account-list
-          id="ccs"
-          accounts="{{_ccs}}"
-          filter="[[filterCCSuggestion]]"
-          pending-confirmation="{{_ccPendingConfirmation}}"
-          allow-any-input=""
-          placeholder="Add CC..."
-          on-account-text-changed="_handleAccountTextEntry"
-          suggestions-provider="[[_getCcSuggestionsProvider(change)]]"
-        >
-        </gr-account-list>
-      </div>
-      <gr-overlay
-        id="reviewerConfirmationOverlay"
-        on-iron-overlay-canceled="_cancelPendingReviewer"
-      >
-        <div class="reviewerConfirmation">
-          Group
-          <span class="groupName">
-            [[_pendingConfirmationDetails.group.name]]
-          </span>
-          has
-          <span class="groupSize">
-            [[_pendingConfirmationDetails.count]]
-          </span>
-          members.
-          <br />
-          Are you sure you want to add them all?
-        </div>
-        <div class="reviewerConfirmationButtons">
-          <gr-button on-click="_confirmPendingReviewer">Yes</gr-button>
-          <gr-button on-click="_cancelPendingReviewer">No</gr-button>
-        </div>
-      </gr-overlay>
-    </section>
-    <section class="textareaContainer">
-      <gr-endpoint-decorator name="reply-text">
-        <gr-textarea
-          id="textarea"
-          class="message"
-          autocomplete="on"
-          placeholder="[[_messagePlaceholder]]"
-          fixed-position-dropdown=""
-          hide-border="true"
-          monospace="true"
-          disabled="{{disabled}}"
-          rows="4"
-          text="{{draft}}"
-          on-bind-value-changed="_handleHeightChanged"
-        >
-        </gr-textarea>
-      </gr-endpoint-decorator>
-    </section>
-    <section class="previewContainer">
-      <label>
-        <input type="checkbox" checked="{{_previewFormatting::change}}" />
-        Preview formatting
-      </label>
-      <gr-formatted-text
-        content="[[draft]]"
-        hidden$="[[!_previewFormatting]]"
-        config="[[projectConfig.commentlinks]]"
-      ></gr-formatted-text>
-    </section>
-    <section class="labelsContainer">
-      <gr-endpoint-decorator name="reply-label-scores">
-        <gr-label-scores
-          id="labelScores"
-          account="[[_account]]"
-          change="[[change]]"
-          on-labels-changed="_handleLabelsChanged"
-          permitted-labels="[[permittedLabels]]"
-        ></gr-label-scores>
-      </gr-endpoint-decorator>
-      <div id="pluginMessage">[[_pluginMessage]]</div>
-    </section>
-    <section
-      class="draftsContainer"
-      hidden$="[[_computeHideDraftList(draftCommentThreads)]]"
-    >
-      <div class="includeComments">
-        <input
-          type="checkbox"
-          id="includeComments"
-          checked="{{_includeComments::change}}"
-        />
-        <label for="includeComments"
-          >Publish [[_computeDraftsTitle(draftCommentThreads)]]</label
-        >
-      </div>
-      <gr-thread-list
-        id="commentList"
-        hidden$="[[!_includeComments]]"
-        threads="[[draftCommentThreads]]"
-        change="[[change]]"
-        change-num="[[change._number]]"
-        logged-in="true"
-        hide-toggle-buttons=""
-        on-thread-list-modified="_onThreadListModified"
-      >
-      </gr-thread-list>
-      <span
-        id="savingLabel"
-        class$="[[_computeSavingLabelClass(_savingComments)]]"
-      >
-        Saving comments...
-      </span>
-    </section>
-    <section class="actions">
-      <div class="left">
-        <span
-          id="checkingStatusLabel"
-          hidden$="[[!_isState(knownLatestState, 'checking')]]"
-        >
-          Checking whether patch [[patchNum]] is latest...
-        </span>
-        <span
-          id="notLatestLabel"
-          hidden$="[[!_isState(knownLatestState, 'not-latest')]]"
-        >
-          [[_computePatchSetWarning(patchNum, _labelsChanged)]]
-          <gr-button link="" on-click="_reload">Reload</gr-button>
-        </span>
-      </div>
-      <div class="right">
-        <gr-button
-          link=""
-          id="cancelButton"
-          class="action cancel"
-          on-click="_cancelTapHandler"
-          >Cancel</gr-button
-        >
-        <template is="dom-if" if="[[canBeStarted]]">
-          <!-- Use 'Send' here as the change may only about reviewers / ccs
-              and when this button is visible, the next button will always
-              be 'Start review' -->
-          <gr-button
-            link=""
-            disabled="[[_isState(knownLatestState, 'not-latest')]]"
-            class="action save"
-            has-tooltip=""
-            title="[[_saveTooltip]]"
-            on-click="_saveClickHandler"
-            >Save</gr-button
-          >
-        </template>
-        <gr-button
-          id="sendButton"
-          primary=""
-          disabled="[[_sendDisabled]]"
-          class="action send"
-          has-tooltip=""
-          title$="[[_computeSendButtonTooltip(canBeStarted)]]"
-          on-click="_sendTapHandler"
-          >[[_sendButtonLabel]]</gr-button
-        >
-      </div>
-    </section>
-  </div>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
-  <gr-reporting id="reporting"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
new file mode 100644
index 0000000..761e7ee
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -0,0 +1,554 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--dialog-background-color);
+      display: block;
+      max-height: 90vh;
+    }
+    :host([disabled]) {
+      pointer-events: none;
+    }
+    :host([disabled]) .container {
+      opacity: 0.5;
+    }
+    .container {
+      display: flex;
+      flex-direction: column;
+      max-height: 100%;
+    }
+    section {
+      border-top: 1px solid var(--border-color);
+      flex-shrink: 0;
+      padding: var(--spacing-m) var(--spacing-xl);
+      width: 100%;
+    }
+    section.labelsContainer {
+      /* We want the :hover highlight to extend to the border of the dialog. */
+      padding: var(--spacing-m) 0;
+    }
+    .actions {
+      background-color: var(--dialog-background-color);
+      bottom: 0;
+      display: flex;
+      justify-content: space-between;
+      position: sticky;
+      /* @see Issue 8602 */
+      z-index: 1;
+    }
+    .actions .right gr-button {
+      margin-left: var(--spacing-l);
+    }
+    .peopleContainer,
+    .labelsContainer {
+      flex-shrink: 0;
+    }
+    .peopleContainer {
+      border-top: none;
+      display: table;
+    }
+    .peopleList {
+      display: flex;
+    }
+    .peopleListLabel {
+      color: var(--deemphasized-text-color);
+      margin-top: var(--spacing-xs);
+      min-width: 6em;
+      padding-right: var(--spacing-m);
+    }
+    gr-account-list {
+      display: flex;
+      flex-wrap: wrap;
+      flex: 1;
+    }
+    #reviewerConfirmationOverlay {
+      padding: var(--spacing-l);
+      text-align: center;
+    }
+    .reviewerConfirmationButtons {
+      margin-top: var(--spacing-l);
+    }
+    .groupName {
+      font-weight: var(--font-weight-bold);
+    }
+    .groupSize {
+      font-style: italic;
+    }
+    .textareaContainer {
+      min-height: 12em;
+      position: relative;
+    }
+    .textareaContainer,
+    #textarea,
+    gr-endpoint-decorator[name='reply-text'] {
+      display: flex;
+      width: 100%;
+    }
+    .previewContainer gr-formatted-text {
+      background: var(--table-header-background-color);
+      padding: var(--spacing-l);
+    }
+    #checkingStatusLabel,
+    #notLatestLabel {
+      margin-left: var(--spacing-l);
+    }
+    #checkingStatusLabel {
+      color: var(--deemphasized-text-color);
+      font-style: italic;
+    }
+    #notLatestLabel,
+    #savingLabel {
+      color: var(--error-text-color);
+    }
+    #savingLabel {
+      display: none;
+    }
+    #savingLabel.saving {
+      display: inline;
+    }
+    #pluginMessage {
+      color: var(--deemphasized-text-color);
+      margin-left: var(--spacing-l);
+      margin-bottom: var(--spacing-m);
+    }
+    #pluginMessage:empty {
+      display: none;
+    }
+    .preview-formatting {
+      margin-left: var(--spacing-m);
+    }
+    .attention-icon {
+      width: 14px;
+      height: 14px;
+      vertical-align: top;
+      position: relative;
+      top: 3px;
+      --iron-icon-height: 24px;
+      --iron-icon-width: 24px;
+    }
+    .attention .edit-attention-button {
+      vertical-align: top;
+      --padding: 0px 4px;
+    }
+    .attention .edit-attention-button iron-icon {
+      color: inherit;
+    }
+    .attention a,
+    .attention-detail a {
+      text-decoration: none;
+    }
+    .attentionSummary {
+      display: flex;
+      justify-content: space-between;
+    }
+    .attention-detail .peopleList {
+      margin-top: var(--spacing-s);
+    }
+    .attention-detail .peopleList .accountList {
+      display: flex;
+      flex-wrap: wrap;
+    }
+    .attention-detail gr-account-label {
+      display: inline-block;
+      padding: var(--spacing-xs) var(--spacing-m);
+      user-select: none;
+      --label-border-radius: 8px;
+    }
+    .attention-detail gr-account-label[selected] {
+      padding-left: 2px;
+    }
+    .attention-detail gr-account-label:focus {
+      outline: none;
+    }
+    .attention-detail gr-account-label:hover {
+      box-shadow: var(--elevation-level-1);
+      cursor: pointer;
+    }
+    .attention-detail .attentionDetailsTitle {
+      display: flex;
+      justify-content: space-between;
+      margin-bottom: var(--spacing-m);
+    }
+    .attention-detail .attentionDetailsFooter {
+      display: flex;
+      justify-content: space-between;
+      margin-top: var(--spacing-s);
+    }
+    .attention-detail .selectUsers {
+      color: var(--deemphasized-text-color);
+    }
+  </style>
+  <div class="container" tabindex="-1">
+    <section class="peopleContainer">
+      <gr-endpoint-decorator name="reply-reviewers">
+        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+        <gr-endpoint-param name="reviewers" value="[[_allReviewers]]">
+        </gr-endpoint-param>
+        <div class="peopleList">
+          <div class="peopleListLabel">Reviewers</div>
+          <gr-account-list
+            id="reviewers"
+            accounts="{{_reviewers}}"
+            removable-values="[[change.removable_reviewers]]"
+            filter="[[filterReviewerSuggestion]]"
+            pending-confirmation="{{_reviewerPendingConfirmation}}"
+            placeholder="Add reviewer..."
+            on-account-text-changed="_handleAccountTextEntry"
+            suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
+          >
+          </gr-account-list>
+          <gr-endpoint-slot name="right"></gr-endpoint-slot>
+        </div>
+        <gr-endpoint-slot name="below"></gr-endpoint-slot>
+      </gr-endpoint-decorator>
+      <div class="peopleList">
+        <div class="peopleListLabel">CC</div>
+        <gr-account-list
+          id="ccs"
+          accounts="{{_ccs}}"
+          filter="[[filterCCSuggestion]]"
+          pending-confirmation="{{_ccPendingConfirmation}}"
+          allow-any-input=""
+          placeholder="Add CC..."
+          on-account-text-changed="_handleAccountTextEntry"
+          suggestions-provider="[[_getCcSuggestionsProvider(change)]]"
+        >
+        </gr-account-list>
+      </div>
+      <gr-overlay
+        id="reviewerConfirmationOverlay"
+        on-iron-overlay-canceled="_cancelPendingReviewer"
+      >
+        <div class="reviewerConfirmation">
+          Group
+          <span class="groupName">
+            [[_pendingConfirmationDetails.group.name]]
+          </span>
+          has
+          <span class="groupSize">
+            [[_pendingConfirmationDetails.count]]
+          </span>
+          members.
+          <br />
+          Are you sure you want to add them all?
+        </div>
+        <div class="reviewerConfirmationButtons">
+          <gr-button on-click="_confirmPendingReviewer">Yes</gr-button>
+          <gr-button on-click="_cancelPendingReviewer">No</gr-button>
+        </div>
+      </gr-overlay>
+    </section>
+    <section class="textareaContainer">
+      <gr-endpoint-decorator name="reply-text">
+        <gr-textarea
+          id="textarea"
+          class="message"
+          autocomplete="on"
+          placeholder="[[_messagePlaceholder]]"
+          fixed-position-dropdown=""
+          hide-border="true"
+          monospace="true"
+          disabled="{{disabled}}"
+          rows="4"
+          text="{{draft}}"
+          on-bind-value-changed="_handleHeightChanged"
+        >
+        </gr-textarea>
+      </gr-endpoint-decorator>
+    </section>
+    <section class="previewContainer">
+      <template is="dom-if" if="[[_isPatchsetCommentsExperimentEnabled]]">
+        <label>
+          <input
+            id="resolvedPatchsetLevelCommentCheckbox"
+            type="checkbox"
+            checked="{{_isResolvedPatchsetLevelComment::change}}"
+          />
+          Resolved
+        </label>
+      </template>
+      <label class="preview-formatting">
+        <input type="checkbox" checked="{{_previewFormatting::change}}" />
+        Preview formatting
+      </label>
+      <gr-formatted-text
+        content="[[draft]]"
+        hidden$="[[!_previewFormatting]]"
+        config="[[projectConfig.commentlinks]]"
+      ></gr-formatted-text>
+    </section>
+    <section class="labelsContainer">
+      <gr-endpoint-decorator name="reply-label-scores">
+        <gr-label-scores
+          id="labelScores"
+          account="[[_account]]"
+          change="[[change]]"
+          on-labels-changed="_handleLabelsChanged"
+          permitted-labels="[[permittedLabels]]"
+        ></gr-label-scores>
+      </gr-endpoint-decorator>
+      <div id="pluginMessage">[[_pluginMessage]]</div>
+    </section>
+    <section
+      hidden$="[[!_showAttentionSummary(serverConfig, _attentionModified)]]"
+      class="attention"
+    >
+      <div class="attentionSummary">
+        <div>
+          <iron-icon
+            class="attention-icon"
+            icon="gr-icons:attention"
+          ></iron-icon>
+          <template
+            is="dom-if"
+            if="[[_isNewAttentionEmpty(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
+          >
+            <span>Do not add anyone to the attention set.</span>
+          </template>
+          <template
+            is="dom-if"
+            if="[[!_isNewAttentionEmpty(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
+          >
+            <span
+              >Bring to attention of [[_computeNewAttentionNames(serverConfig,
+              _currentAttentionSet, _newAttentionSet)]].</span
+            >
+          </template>
+          <gr-button
+            class="edit-attention-button"
+            on-click="_handleAttentionModify"
+            link=""
+            position-below=""
+            data-label="Edit"
+            data-action-type="change"
+            data-action-key="edit"
+            title="Edit attention set changes"
+            role="button"
+            tabindex="0"
+          >
+            <iron-icon icon="gr-icons:edit"></iron-icon>
+            Modify
+          </gr-button>
+        </div>
+        <div>
+          <a
+            href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
+            target="_blank"
+          >
+            <iron-icon icon="gr-icons:bug" title="report a problem"></iron-icon>
+          </a>
+          <a
+            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+            target="_blank"
+          >
+            <iron-icon
+              icon="gr-icons:help-outline"
+              title="read documentation"
+            ></iron-icon>
+          </a>
+        </div>
+      </div>
+    </section>
+    <section
+      hidden$="[[!_showAttentionDetails(serverConfig, _attentionModified)]]"
+      class="attention-detail"
+    >
+      <div class="attentionDetailsTitle">
+        <div>
+          <span>Change attention set to:</span>
+        </div>
+        <div></div>
+        <div>
+          <a
+            href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
+            target="_blank"
+          >
+            <iron-icon icon="gr-icons:bug" title="report a problem"></iron-icon>
+          </a>
+          <a
+            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+            target="_blank"
+          >
+            <iron-icon
+              icon="gr-icons:info"
+              title="read documentation"
+            ></iron-icon>
+          </a>
+        </div>
+      </div>
+      <div class="peopleList">
+        <div class="peopleListLabel">Owner</div>
+        <div>
+          <gr-account-label
+            account="[[_owner]]"
+            force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
+            selected$="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
+            deselected$="[[!_computeHasNewAttention(_owner, _newAttentionSet)]]"
+            hide-hovercard=""
+            on-click="_handleAttentionClick"
+          >
+          </gr-account-label>
+        </div>
+      </div>
+      <template is="dom-if" if="[[_uploader]]">
+        <div class="peopleList">
+          <div class="peopleListLabel">Uploader</div>
+          <div>
+            <gr-account-label
+              account="[[_uploader]]"
+              force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
+              selected$="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
+              deselected$="[[!_computeHasNewAttention(_uploader, _newAttentionSet)]]"
+              hide-hovercard=""
+              on-click="_handleAttentionClick"
+            >
+            </gr-account-label>
+          </div>
+        </div>
+      </template>
+      <div class="peopleList">
+        <div class="peopleListLabel">Reviewers</div>
+        <div>
+          <template is="dom-repeat" items="[[_reviewers]]" as="account">
+            <gr-account-label
+              account="[[account]]"
+              force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+              selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+              deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+              hide-hovercard=""
+              on-click="_handleAttentionClick"
+            >
+            </gr-account-label>
+          </template>
+        </div>
+      </div>
+      <template is="dom-if" if="[[_computeShowAttentionCcs(_ccs)]]">
+        <div class="peopleList">
+          <div class="peopleListLabel">CC</div>
+          <div>
+            <template is="dom-repeat" items="[[_ccs]]" as="account">
+              <gr-account-label
+                account="[[account]]"
+                force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+                deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+                hide-hovercard=""
+                on-click="_handleAttentionClick"
+              >
+              </gr-account-label>
+            </template>
+          </div>
+        </div>
+      </template>
+      <div class="attentionDetailsFooter">
+        <div></div>
+        <div>
+          <span class="selectUsers">(click chips to add and remove users)</span>
+        </div>
+        <div></div>
+      </div>
+    </section>
+    <section
+      class="draftsContainer"
+      hidden$="[[_computeHideDraftList(draftCommentThreads)]]"
+    >
+      <div class="includeComments">
+        <input
+          type="checkbox"
+          id="includeComments"
+          checked="{{_includeComments::change}}"
+        />
+        <label for="includeComments"
+          >Publish [[_computeDraftsTitle(draftCommentThreads)]]</label
+        >
+      </div>
+      <gr-thread-list
+        id="commentList"
+        hidden$="[[!_includeComments]]"
+        threads="[[draftCommentThreads]]"
+        change="[[change]]"
+        change-num="[[change._number]]"
+        logged-in="true"
+        hide-toggle-buttons=""
+        on-thread-list-modified="_onThreadListModified"
+      >
+      </gr-thread-list>
+      <span
+        id="savingLabel"
+        class$="[[_computeSavingLabelClass(_savingComments)]]"
+      >
+        Saving comments...
+      </span>
+    </section>
+    <section class="actions">
+      <div class="left">
+        <span
+          id="checkingStatusLabel"
+          hidden$="[[!_isState(knownLatestState, 'checking')]]"
+        >
+          Checking whether patch [[patchNum]] is latest...
+        </span>
+        <span
+          id="notLatestLabel"
+          hidden$="[[!_isState(knownLatestState, 'not-latest')]]"
+        >
+          [[_computePatchSetWarning(patchNum, _labelsChanged)]]
+          <gr-button link="" on-click="_reload">Reload</gr-button>
+        </span>
+      </div>
+      <div class="right">
+        <gr-button
+          link=""
+          id="cancelButton"
+          class="action cancel"
+          on-click="_cancelTapHandler"
+          >Cancel</gr-button
+        >
+        <template is="dom-if" if="[[canBeStarted]]">
+          <!-- Use 'Send' here as the change may only about reviewers / ccs
+              and when this button is visible, the next button will always
+              be 'Start review' -->
+          <gr-button
+            link=""
+            disabled="[[_isState(knownLatestState, 'not-latest')]]"
+            class="action save"
+            has-tooltip=""
+            title="[[_saveTooltip]]"
+            on-click="_saveClickHandler"
+            >Save</gr-button
+          >
+        </template>
+        <gr-button
+          id="sendButton"
+          primary=""
+          disabled="[[_sendDisabled]]"
+          class="action send"
+          has-tooltip=""
+          title$="[[_computeSendButtonTooltip(canBeStarted)]]"
+          on-click="_sendTapHandler"
+          >[[_sendButtonLabel]]</gr-button
+        >
+      </div>
+    </section>
+  </div>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-storage id="storage"></gr-storage>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
deleted file mode 100644
index 5a61864..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ /dev/null
@@ -1,1305 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-reply-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-reply-dialog></gr-reply-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import {IronOverlayManager} from '@polymer/iron-overlay-behavior/iron-overlay-manager.js';
-import '../../../test/common-test-setup.js';
-import './gr-reply-dialog.js';
-import {mockPromise} from '../../../test/test-utils.js';
-function cloneableResponse(status, text) {
-  return {
-    ok: false,
-    status,
-    text() {
-      return Promise.resolve(text);
-    },
-    clone() {
-      return {
-        ok: false,
-        status,
-        text() {
-          return Promise.resolve(text);
-        },
-      };
-    },
-  };
-}
-
-suite('gr-reply-dialog tests', () => {
-  let element;
-  let changeNum;
-  let patchNum;
-
-  let sandbox;
-  let getDraftCommentStub;
-  let setDraftCommentStub;
-  let eraseDraftCommentStub;
-
-  let lastId = 0;
-  const makeAccount = function() { return {_account_id: lastId++}; };
-  const makeGroup = function() { return {id: lastId++}; };
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-
-    changeNum = 42;
-    patchNum = 1;
-
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getAccount() { return Promise.resolve({}); },
-      getChange() { return Promise.resolve({}); },
-      getChangeSuggestedReviewers() { return Promise.resolve([]); },
-    });
-
-    element = fixture('basic');
-    element.change = {
-      _number: changeNum,
-      labels: {
-        'Verified': {
-          values: {
-            '-1': 'Fails',
-            ' 0': 'No score',
-            '+1': 'Verified',
-          },
-          default_value: 0,
-        },
-        'Code-Review': {
-          values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didn\'t submit this',
-            ' 0': 'No score',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          default_value: 0,
-        },
-      },
-    };
-    element.patchNum = patchNum;
-    element.permittedLabels = {
-      'Code-Review': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-
-    getDraftCommentStub = sandbox.stub(element.$.storage, 'getDraftComment');
-    setDraftCommentStub = sandbox.stub(element.$.storage, 'setDraftComment');
-    eraseDraftCommentStub = sandbox.stub(element.$.storage,
-        'eraseDraftComment');
-
-    sandbox.stub(element, 'fetchChangeUpdates')
-        .returns(Promise.resolve({isLatest: true}));
-
-    // Allow the elements created by dom-repeat to be stamped.
-    flushAsynchronousOperations();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  function stubSaveReview(jsonResponseProducer) {
-    return sandbox.stub(
-        element,
-        '_saveReview',
-        review => new Promise((resolve, reject) => {
-          try {
-            const result = jsonResponseProducer(review) || {};
-            const resultStr =
-            element.$.restAPI.JSON_PREFIX + JSON.stringify(result);
-            resolve({
-              ok: true,
-              text() {
-                return Promise.resolve(resultStr);
-              },
-            });
-          } catch (err) {
-            reject(err);
-          }
-        }));
-  }
-
-  test('default to publishing draft comments with reply', done => {
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-    flush(() => {
-      flush(() => {
-        element.draft = 'I wholeheartedly disapprove';
-
-        stubSaveReview(review => {
-          assert.deepEqual(review, {
-            drafts: 'PUBLISH_ALL_REVISIONS',
-            labels: {
-              'Code-Review': 0,
-              'Verified': 0,
-            },
-            message: 'I wholeheartedly disapprove',
-            reviewers: [],
-          });
-          assert.isFalse(element.$.commentList.hidden);
-          done();
-        });
-
-        // This is needed on non-Blink engines most likely due to the ways in
-        // which the dom-repeat elements are stamped.
-        flush(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.send'));
-        });
-      });
-    });
-  });
-
-  test('keep draft comments with reply', done => {
-    MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
-    assert.equal(element._includeComments, false);
-
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-    flush(() => {
-      flush(() => {
-        element.draft = 'I wholeheartedly disapprove';
-
-        stubSaveReview(review => {
-          assert.deepEqual(review, {
-            drafts: 'KEEP',
-            labels: {
-              'Code-Review': 0,
-              'Verified': 0,
-            },
-            message: 'I wholeheartedly disapprove',
-            reviewers: [],
-          });
-          assert.isTrue(element.$.commentList.hidden);
-          done();
-        });
-
-        // This is needed on non-Blink engines most likely due to the ways in
-        // which the dom-repeat elements are stamped.
-        flush(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.send'));
-        });
-      });
-    });
-  });
-
-  test('label picker', done => {
-    element.draft = 'I wholeheartedly disapprove';
-    stubSaveReview(review => {
-      assert.deepEqual(review, {
-        drafts: 'PUBLISH_ALL_REVISIONS',
-        labels: {
-          'Code-Review': -1,
-          'Verified': -1,
-        },
-        message: 'I wholeheartedly disapprove',
-        reviewers: [],
-      });
-    });
-
-    sandbox.stub(element.$.labelScores, 'getLabelValues', () => {
-      return {
-        'Code-Review': -1,
-        'Verified': -1,
-      };
-    });
-
-    element.addEventListener('send', () => {
-      // Flush to ensure properties are updated.
-      flush(() => {
-        assert.isFalse(element.disabled,
-            'Element should be enabled when done sending reply.');
-        assert.equal(element.draft.length, 0);
-        done();
-      });
-    });
-
-    // This is needed on non-Blink engines most likely due to the ways in
-    // which the dom-repeat elements are stamped.
-    flush(() => {
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.send'));
-      assert.isTrue(element.disabled);
-    });
-  });
-
-  test('getlabelValue returns value', done => {
-    flush(() => {
-      element.shadowRoot
-          .querySelector('gr-label-scores')
-          .shadowRoot
-          .querySelector(`gr-label-score-row[name="Verified"]`)
-          .setSelectedValue(-1);
-      assert.equal('-1', element.getLabelValue('Verified'));
-      done();
-    });
-  });
-
-  test('getlabelValue when no score is selected', done => {
-    flush(() => {
-      element.shadowRoot
-          .querySelector('gr-label-scores')
-          .shadowRoot
-          .querySelector(`gr-label-score-row[name="Code-Review"]`)
-          .setSelectedValue(-1);
-      assert.strictEqual(element.getLabelValue('Verified'), ' 0');
-      done();
-    });
-  });
-
-  test('setlabelValue', done => {
-    element._account = {_account_id: 1};
-    flush(() => {
-      const label = 'Verified';
-      const value = '+1';
-      element.setLabelValue(label, value);
-
-      const labels = element.$.labelScores.getLabelValues();
-      assert.deepEqual(labels, {
-        'Code-Review': 0,
-        'Verified': 1,
-      });
-      done();
-    });
-  });
-
-  function getActiveElement() {
-    return IronOverlayManager.deepActiveElement;
-  }
-
-  function isVisible(el) {
-    assert.ok(el);
-    return getComputedStyle(el).getPropertyValue('display') != 'none';
-  }
-
-  function overlayObserver(mode) {
-    return new Promise(resolve => {
-      function listener() {
-        element.removeEventListener('iron-overlay-' + mode, listener);
-        resolve();
-      }
-      element.addEventListener('iron-overlay-' + mode, listener);
-    });
-  }
-
-  function isFocusInsideElement(element) {
-    // In Polymer 2 focused element either <paper-input> or nested
-    // native input <input> element depending on the current focus
-    // in browser window.
-    // For example, the focus is changed if the developer console
-    // get a focus.
-    let activeElement = getActiveElement();
-    while (activeElement) {
-      if (activeElement === element) {
-        return true;
-      }
-      if (activeElement.parentElement) {
-        activeElement = activeElement.parentElement;
-      } else {
-        activeElement = activeElement.getRootNode().host;
-      }
-    }
-    return false;
-  }
-
-  function testConfirmationDialog(done, cc) {
-    const yesButton = element
-        .shadowRoot
-        .querySelector('.reviewerConfirmationButtons gr-button:first-child');
-    const noButton = element
-        .shadowRoot
-        .querySelector('.reviewerConfirmationButtons gr-button:last-child');
-
-    element._ccPendingConfirmation = null;
-    element._reviewerPendingConfirmation = null;
-    flushAsynchronousOperations();
-    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-
-    // Cause the confirmation dialog to display.
-    let observer = overlayObserver('opened');
-    const group = {
-      id: 'id',
-      name: 'name',
-    };
-    if (cc) {
-      element._ccPendingConfirmation = {
-        group,
-        count: 10,
-      };
-    } else {
-      element._reviewerPendingConfirmation = {
-        group,
-        count: 10,
-      };
-    }
-    flushAsynchronousOperations();
-
-    if (cc) {
-      assert.deepEqual(
-          element._ccPendingConfirmation,
-          element._pendingConfirmationDetails);
-    } else {
-      assert.deepEqual(
-          element._reviewerPendingConfirmation,
-          element._pendingConfirmationDetails);
-    }
-
-    observer
-        .then(() => {
-          assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
-          observer = overlayObserver('closed');
-          const expected = 'Group name has 10 members';
-          assert.notEqual(
-              element.$.reviewerConfirmationOverlay.innerText
-                  .indexOf(expected),
-              -1);
-          MockInteractions.tap(noButton); // close the overlay
-          return observer;
-        }).then(() => {
-          assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-
-          // We should be focused on account entry input.
-          assert.isTrue(
-              isFocusInsideElement(
-                  element.$.reviewers.$.entry.$.input.$.input
-              )
-          );
-
-          // No reviewer/CC should have been added.
-          assert.equal(element.$.ccs.additions().length, 0);
-          assert.equal(element.$.reviewers.additions().length, 0);
-
-          // Reopen confirmation dialog.
-          observer = overlayObserver('opened');
-          if (cc) {
-            element._ccPendingConfirmation = {
-              group,
-              count: 10,
-            };
-          } else {
-            element._reviewerPendingConfirmation = {
-              group,
-              count: 10,
-            };
-          }
-          return observer;
-        })
-        .then(() => {
-          assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
-          observer = overlayObserver('closed');
-          MockInteractions.tap(yesButton); // Confirm the group.
-          return observer;
-        })
-        .then(() => {
-          assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-          const additions = cc ?
-            element.$.ccs.additions() :
-            element.$.reviewers.additions();
-          assert.deepEqual(
-              additions,
-              [
-                {
-                  group: {
-                    id: 'id',
-                    name: 'name',
-                    confirmed: true,
-                    _group: true,
-                    _pendingAdd: true,
-                  },
-                },
-              ]);
-
-          // We should be focused on account entry input.
-          if (cc) {
-            assert.isTrue(
-                isFocusInsideElement(
-                    element.$.ccs.$.entry.$.input.$.input
-                )
-            );
-          } else {
-            assert.isTrue(
-                isFocusInsideElement(
-                    element.$.reviewers.$.entry.$.input.$.input
-                )
-            );
-          }
-        })
-        .then(done);
-  }
-
-  test('cc confirmation', done => {
-    testConfirmationDialog(done, true);
-  });
-
-  test('reviewer confirmation', done => {
-    testConfirmationDialog(done, false);
-  });
-
-  test('_getStorageLocation', () => {
-    const actual = element._getStorageLocation();
-    assert.equal(actual.changeNum, changeNum);
-    assert.equal(actual.patchNum, '@change');
-    assert.equal(actual.path, '@change');
-  });
-
-  test('_reviewersMutated when account-text-change is fired from ccs', () => {
-    flushAsynchronousOperations();
-    assert.isFalse(element._reviewersMutated);
-    assert.isTrue(element.$.ccs.allowAnyInput);
-    assert.isFalse(element.shadowRoot
-        .querySelector('#reviewers').allowAnyInput);
-    element.$.ccs.dispatchEvent(new CustomEvent('account-text-changed',
-        {bubbles: true, composed: true}));
-    assert.isTrue(element._reviewersMutated);
-  });
-
-  test('gets draft from storage on open', () => {
-    const storedDraft = 'hello world';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, storedDraft);
-  });
-
-  test('gets draft from storage even when text is already present', () => {
-    const storedDraft = 'hello world';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.draft = 'foo bar';
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, storedDraft);
-  });
-
-  test('blank if no stored draft', () => {
-    getDraftCommentStub.returns(null);
-    element.draft = 'foo bar';
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, '');
-  });
-
-  test('does not check stored draft when quote is present', () => {
-    const storedDraft = 'hello world';
-    const quote = '> foo bar';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.quote = quote;
-    element.open();
-    assert.isFalse(getDraftCommentStub.called);
-    assert.equal(element.draft, quote);
-    assert.isNotOk(element.quote);
-  });
-
-  test('updates stored draft on edits', () => {
-    const firstEdit = 'hello';
-    const location = element._getStorageLocation();
-
-    element.draft = firstEdit;
-    element.flushDebouncer('store');
-
-    assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
-
-    element.draft = '';
-    element.flushDebouncer('store');
-
-    assert.isTrue(eraseDraftCommentStub.calledWith(location));
-  });
-
-  test('400 converts to human-readable server-error', done => {
-    sandbox.stub(window, 'fetch', () => {
-      const text = '....{"reviewers":{"id1":{"error":"first error"}},' +
-        '"ccs":{"id2":{"error":"second error"}}}';
-      return Promise.resolve(cloneableResponse(400, text));
-    });
-
-    element.addEventListener('server-error', event => {
-      if (event.target !== element) {
-        return;
-      }
-      event.detail.response.text().then(body => {
-        assert.equal(body, 'first error, second error');
-        done();
-      });
-    });
-
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    flush(() => { element.send(); });
-  });
-
-  test('non-json 400 is treated as a normal server-error', done => {
-    sandbox.stub(window, 'fetch', () => {
-      const text = 'Comment validation error!';
-      return Promise.resolve(cloneableResponse(400, text));
-    });
-
-    element.addEventListener('server-error', event => {
-      if (event.target !== element) {
-        return;
-      }
-      event.detail.response.text().then(body => {
-        assert.equal(body, 'Comment validation error!');
-        done();
-      });
-    });
-
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    flush(() => { element.send(); });
-  });
-
-  test('filterReviewerSuggestion', () => {
-    const owner = makeAccount();
-    const reviewer1 = makeAccount();
-    const reviewer2 = makeGroup();
-    const cc1 = makeAccount();
-    const cc2 = makeGroup();
-    let filter = element._filterReviewerSuggestionGenerator(false);
-
-    element._owner = owner;
-    element._reviewers = [reviewer1, reviewer2];
-    element._ccs = [cc1, cc2];
-
-    assert.isTrue(filter({account: makeAccount()}));
-    assert.isTrue(filter({group: makeGroup()}));
-
-    // Owner should be excluded.
-    assert.isFalse(filter({account: owner}));
-
-    // Existing and pending reviewers should be excluded when isCC = false.
-    assert.isFalse(filter({account: reviewer1}));
-    assert.isFalse(filter({group: reviewer2}));
-
-    filter = element._filterReviewerSuggestionGenerator(true);
-
-    // Existing and pending CCs should be excluded when isCC = true;.
-    assert.isFalse(filter({account: cc1}));
-    assert.isFalse(filter({group: cc2}));
-  });
-
-  test('_focusOn', () => {
-    sandbox.spy(element, '_chooseFocusTarget');
-    flushAsynchronousOperations();
-    const textareaStub = sandbox.stub(element.$.textarea, 'async');
-    const reviewerEntryStub = sandbox.stub(element.$.reviewers.focusStart,
-        'async');
-    const ccStub = sandbox.stub(element.$.ccs.focusStart, 'async');
-    element._focusOn();
-    assert.equal(element._chooseFocusTarget.callCount, 1);
-    assert.deepEqual(textareaStub.callCount, 1);
-    assert.deepEqual(reviewerEntryStub.callCount, 0);
-    assert.deepEqual(ccStub.callCount, 0);
-
-    element._focusOn(element.FocusTarget.ANY);
-    assert.equal(element._chooseFocusTarget.callCount, 2);
-    assert.deepEqual(textareaStub.callCount, 2);
-    assert.deepEqual(reviewerEntryStub.callCount, 0);
-    assert.deepEqual(ccStub.callCount, 0);
-
-    element._focusOn(element.FocusTarget.BODY);
-    assert.equal(element._chooseFocusTarget.callCount, 2);
-    assert.deepEqual(textareaStub.callCount, 3);
-    assert.deepEqual(reviewerEntryStub.callCount, 0);
-    assert.deepEqual(ccStub.callCount, 0);
-
-    element._focusOn(element.FocusTarget.REVIEWERS);
-    assert.equal(element._chooseFocusTarget.callCount, 2);
-    assert.deepEqual(textareaStub.callCount, 3);
-    assert.deepEqual(reviewerEntryStub.callCount, 1);
-    assert.deepEqual(ccStub.callCount, 0);
-
-    element._focusOn(element.FocusTarget.CCS);
-    assert.equal(element._chooseFocusTarget.callCount, 2);
-    assert.deepEqual(textareaStub.callCount, 3);
-    assert.deepEqual(reviewerEntryStub.callCount, 1);
-    assert.deepEqual(ccStub.callCount, 1);
-  });
-
-  test('_chooseFocusTarget', () => {
-    element._account = null;
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-    element._account = {_account_id: 1};
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-    element.change.owner = {_account_id: 2};
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-    element.change.owner._account_id = 1;
-    element.change._reviewers = null;
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
-
-    element._reviewers = [];
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
-
-    element._reviewers.push({});
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.BODY);
-  });
-
-  test('only send labels that have changed', done => {
-    flush(() => {
-      stubSaveReview(review => {
-        assert.deepEqual(review.labels, {
-          'Code-Review': 0,
-          'Verified': -1,
-        });
-      });
-
-      element.addEventListener('send', () => {
-        done();
-      });
-      // Without wrapping this test in flush(), the below two calls to
-      // MockInteractions.tap() cause a race in some situations in shadow DOM.
-      // The send button can be tapped before the others, causing the test to
-      // fail.
-
-      element.shadowRoot
-          .querySelector('gr-label-scores').shadowRoot
-          .querySelector(
-              'gr-label-score-row[name="Verified"]')
-          .setSelectedValue(-1);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.send'));
-    });
-  });
-
-  test('_processReviewerChange', () => {
-    const mockIndexSplices = function(toRemove) {
-      return [{
-        removed: [toRemove],
-      }];
-    };
-
-    element._processReviewerChange(
-        mockIndexSplices(makeAccount()), 'REVIEWER');
-    assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
-  });
-
-  test('_purgeReviewersPendingRemove', () => {
-    const removeStub = sandbox.stub(element, '_removeAccount');
-    const mock = function() {
-      element._reviewersPendingRemove = {
-        test: [makeAccount()],
-        test2: [makeAccount(), makeAccount()],
-      };
-    };
-    const checkObjEmpty = function(obj) {
-      for (const prop in obj) {
-        if (obj.hasOwnProperty(prop) && obj[prop].length) { return false; }
-      }
-      return true;
-    };
-    mock();
-    element._purgeReviewersPendingRemove(true); // Cancel
-    assert.isFalse(removeStub.called);
-    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
-
-    mock();
-    element._purgeReviewersPendingRemove(false); // Submit
-    assert.isTrue(removeStub.called);
-    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
-  });
-
-  test('_removeAccount', done => {
-    sandbox.stub(element.$.restAPI, 'removeChangeReviewer')
-        .returns(Promise.resolve({ok: true}));
-    const arr = [makeAccount(), makeAccount()];
-    element.change.reviewers = {
-      REVIEWER: arr.slice(),
-    };
-
-    element._removeAccount(arr[1], 'REVIEWER').then(() => {
-      assert.equal(element.change.reviewers.REVIEWER.length, 1);
-      assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
-      done();
-    });
-  });
-
-  test('moving from cc to reviewer', () => {
-    element._reviewersPendingRemove = {
-      CC: [],
-      REVIEWER: [],
-    };
-    flushAsynchronousOperations();
-
-    const reviewer1 = makeAccount();
-    const reviewer2 = makeAccount();
-    const reviewer3 = makeAccount();
-    const cc1 = makeAccount();
-    const cc2 = makeAccount();
-    const cc3 = makeAccount();
-    const cc4 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2, reviewer3];
-    element._ccs = [cc1, cc2, cc3, cc4];
-    element.push('_reviewers', cc1);
-    flushAsynchronousOperations();
-
-    assert.deepEqual(element._reviewers,
-        [reviewer1, reviewer2, reviewer3, cc1]);
-    assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
-    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);
-
-    element.push('_reviewers', cc4, cc3);
-    flushAsynchronousOperations();
-
-    assert.deepEqual(element._reviewers,
-        [reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
-    assert.deepEqual(element._ccs, [cc2]);
-    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
-  });
-
-  test('moving from reviewer to cc', () => {
-    element._reviewersPendingRemove = {
-      CC: [],
-      REVIEWER: [],
-    };
-    flushAsynchronousOperations();
-
-    const reviewer1 = makeAccount();
-    const reviewer2 = makeAccount();
-    const reviewer3 = makeAccount();
-    const cc1 = makeAccount();
-    const cc2 = makeAccount();
-    const cc3 = makeAccount();
-    const cc4 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2, reviewer3];
-    element._ccs = [cc1, cc2, cc3, cc4];
-    element.push('_ccs', reviewer1);
-    flushAsynchronousOperations();
-
-    assert.deepEqual(element._reviewers,
-        [reviewer2, reviewer3]);
-    assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
-    assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);
-
-    element.push('_ccs', reviewer3, reviewer2);
-    flushAsynchronousOperations();
-
-    assert.deepEqual(element._reviewers, []);
-    assert.deepEqual(element._ccs,
-        [cc1, cc2, cc3, cc4, reviewer1, reviewer3, reviewer2]);
-    assert.deepEqual(element._reviewersPendingRemove.REVIEWER,
-        [reviewer1, reviewer3, reviewer2]);
-  });
-
-  test('migrate reviewers between states', done => {
-    element._reviewersPendingRemove = {
-      CC: [],
-      REVIEWER: [],
-    };
-    flushAsynchronousOperations();
-    const reviewers = element.$.reviewers;
-    const ccs = element.$.ccs;
-    const reviewer1 = makeAccount();
-    const reviewer2 = makeAccount();
-    const cc1 = makeAccount();
-    const cc2 = makeAccount();
-    const cc3 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2];
-    element._ccs = [cc1, cc2, cc3];
-
-    const mutations = [];
-
-    stubSaveReview(review => mutations.push(...review.reviewers));
-
-    sandbox.stub(element, '_removeAccount', (account, type) => {
-      mutations.push({state: 'REMOVED', account});
-      return Promise.resolve();
-    });
-
-    // Remove and add to other field.
-    reviewers.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: reviewer1},
-          composed: true, bubbles: true,
-        }));
-    ccs.$.entry.dispatchEvent(
-        new CustomEvent('add', {
-          detail: {value: {account: reviewer1}},
-          composed: true, bubbles: true,
-        }));
-    ccs.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: cc1},
-          composed: true, bubbles: true,
-        }));
-    ccs.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: cc3},
-          composed: true, bubbles: true,
-        }));
-    reviewers.$.entry.dispatchEvent(
-        new CustomEvent('add', {
-          detail: {value: {account: cc1}},
-          composed: true, bubbles: true,
-        }));
-
-    // Add to other field without removing from former field.
-    // (Currently not possible in UI, but this is a good consistency check).
-    reviewers.$.entry.dispatchEvent(
-        new CustomEvent('add', {
-          detail: {value: {account: cc2}},
-          composed: true, bubbles: true,
-        }));
-    ccs.$.entry.dispatchEvent(
-        new CustomEvent('add', {
-          detail: {value: {account: reviewer2}},
-          composed: true, bubbles: true,
-        }));
-    const mapReviewer = function(reviewer, opt_state) {
-      const result = {reviewer: reviewer._account_id, confirmed: undefined};
-      if (opt_state) {
-        result.state = opt_state;
-      }
-      return result;
-    };
-
-    // Send and purge and verify moves, delete cc3.
-    element.send()
-        .then(keepReviewers =>
-          element._purgeReviewersPendingRemove(false, keepReviewers))
-        .then(() => {
-          assert.deepEqual(
-              mutations, [
-                mapReviewer(cc1),
-                mapReviewer(cc2),
-                mapReviewer(reviewer1, 'CC'),
-                mapReviewer(reviewer2, 'CC'),
-                {account: cc3, state: 'REMOVED'},
-              ]);
-          done();
-        });
-  });
-
-  test('emits cancel on esc key', () => {
-    const cancelHandler = sandbox.spy();
-    element.addEventListener('cancel', cancelHandler);
-    MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
-    flushAsynchronousOperations();
-
-    assert.isTrue(cancelHandler.called);
-  });
-
-  test('should not send on enter key', () => {
-    stubSaveReview(() => undefined);
-    element.addEventListener('send', () => assert.fail('wrongly called'));
-    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-    flushAsynchronousOperations();
-  });
-
-  test('emit send on ctrl+enter key', done => {
-    stubSaveReview(() => undefined);
-    element.addEventListener('send', () => done());
-    MockInteractions.pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
-    flushAsynchronousOperations();
-  });
-
-  test('_computeMessagePlaceholder', () => {
-    assert.equal(
-        element._computeMessagePlaceholder(false),
-        'Say something nice...');
-    assert.equal(
-        element._computeMessagePlaceholder(true),
-        'Add a note for your reviewers...');
-  });
-
-  test('_computeSendButtonLabel', () => {
-    assert.equal(
-        element._computeSendButtonLabel(false),
-        'Send');
-    assert.equal(
-        element._computeSendButtonLabel(true),
-        'Send and Start review');
-  });
-
-  test('_handle400Error reviewrs and CCs', done => {
-    const error1 = 'error 1';
-    const error2 = 'error 2';
-    const error3 = 'error 3';
-    const text = ')]}\'' + JSON.stringify({
-      reviewers: {
-        username1: {
-          input: 'user 1',
-          error: error1,
-        },
-        username2: {
-          input: 'user 2',
-          error: error2,
-        },
-      },
-      ccs: {
-        username3: {
-          input: 'user 3',
-          error: error3,
-        },
-      },
-    });
-    element.addEventListener('server-error', e => {
-      e.detail.response.text().then(text => {
-        assert.equal(text, [error1, error2, error3].join(', '));
-        done();
-      });
-    });
-    element._handle400Error(cloneableResponse(400, text));
-  });
-
-  test('_handle400Error CCs only', done => {
-    const error1 = 'error 1';
-    const text = ')]}\'' + JSON.stringify({
-      ccs: {
-        username1: {
-          input: 'user 1',
-          error: error1,
-        },
-      },
-    });
-    element.addEventListener('server-error', e => {
-      e.detail.response.text().then(text => {
-        assert.equal(text, error1);
-        done();
-      });
-    });
-    element._handle400Error(cloneableResponse(400, text));
-  });
-
-  test('fires height change when the drafts comments load', done => {
-    // Flush DOM operations before binding to the autogrow event so we don't
-    // catch the events fired from the initial layout.
-    flush(() => {
-      const autoGrowHandler = sinon.stub();
-      element.addEventListener('autogrow', autoGrowHandler);
-      element.draftCommentThreads = [];
-      flush(() => {
-        assert.isTrue(autoGrowHandler.called);
-        done();
-      });
-    });
-  });
-
-  suite('post review API', () => {
-    let startReviewStub;
-
-    setup(() => {
-      startReviewStub = sandbox.stub(
-          element.$.restAPI,
-          'startReview',
-          () => Promise.resolve());
-    });
-
-    test('ready property in review input on start review', () => {
-      stubSaveReview(review => {
-        assert.isTrue(review.ready);
-        return {ready: true};
-      });
-      return element.send(true, true).then(() => {
-        assert.isFalse(startReviewStub.called);
-      });
-    });
-
-    test('no ready property in review input on save review', () => {
-      stubSaveReview(review => {
-        assert.isUndefined(review.ready);
-      });
-      return element.send(true, false).then(() => {
-        assert.isFalse(startReviewStub.called);
-      });
-    });
-  });
-
-  suite('start review and save buttons', () => {
-    let sendStub;
-
-    setup(() => {
-      sendStub = sandbox.stub(element, 'send', () => Promise.resolve());
-      element.canBeStarted = true;
-      // Flush to make both Start/Save buttons appear in DOM.
-      flushAsynchronousOperations();
-    });
-
-    test('start review sets ready', () => {
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.send'));
-      flushAsynchronousOperations();
-      assert.isTrue(sendStub.calledWith(true, true));
-    });
-
-    test('save review doesn\'t set ready', () => {
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-      flushAsynchronousOperations();
-      assert.isTrue(sendStub.calledWith(true, false));
-    });
-  });
-
-  test('buttons disabled until all API calls are resolved', () => {
-    stubSaveReview(review => {
-      return {ready: true};
-    });
-    return element.send(true, true).then(() => {
-      assert.isFalse(element.disabled);
-    });
-  });
-
-  suite('error handling', () => {
-    const expectedDraft = 'draft';
-    const expectedError = new Error('test');
-
-    setup(() => {
-      element.draft = expectedDraft;
-    });
-
-    function assertDialogOpenAndEnabled() {
-      assert.strictEqual(expectedDraft, element.draft);
-      assert.isFalse(element.disabled);
-    }
-
-    test('error occurs in _saveReview', () => {
-      stubSaveReview(review => {
-        throw expectedError;
-      });
-      return element.send(true, true).catch(err => {
-        assert.strictEqual(expectedError, err);
-        assertDialogOpenAndEnabled();
-      });
-    });
-
-    suite('pending diff drafts?', () => {
-      test('yes', () => {
-        const promise = mockPromise();
-        const refreshHandler = sandbox.stub();
-
-        element.addEventListener('comment-refresh', refreshHandler);
-        sandbox.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(true);
-        element.$.restAPI._pendingRequests.sendDiffDraft = [promise];
-        element.open();
-
-        assert.isFalse(refreshHandler.called);
-        assert.isTrue(element._savingComments);
-
-        promise.resolve();
-
-        return element.$.restAPI.awaitPendingDiffDrafts().then(() => {
-          assert.isTrue(refreshHandler.called);
-          assert.isFalse(element._savingComments);
-        });
-      });
-
-      test('no', () => {
-        sandbox.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(false);
-        element.open();
-        assert.notOk(element._savingComments);
-      });
-    });
-  });
-
-  test('_computeSendButtonDisabled_canBeStarted', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
-    // Mock canBeStarted
-    assert.isFalse(fn(
-        /* canBeStarted= */ true,
-        /* draftCommentThreads= */ [],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false
-    ));
-  });
-
-  test('_computeSendButtonDisabled_allFalse', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
-    // Mock everything false
-    assert.isTrue(fn(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false
-    ));
-  });
-
-  test('_computeSendButtonDisabled_draftCommentsSend', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
-    // Mock nonempty comment draft array, with sending comments.
-    assert.isFalse(fn(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ true,
-        /* disabled= */ false,
-        /* commentEditing= */ false
-    ));
-  });
-
-  test('_computeSendButtonDisabled_draftCommentsDoNotSend', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
-    // Mock nonempty comment draft array, without sending comments.
-    assert.isTrue(fn(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false
-    ));
-  });
-
-  test('_computeSendButtonDisabled_changeMessage', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
-    // Mock nonempty change message.
-    assert.isFalse(fn(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ {},
-        /* text= */ 'test',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false
-    ));
-  });
-
-  test('_computeSendButtonDisabled_reviewersChanged', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
-    // Mock reviewers mutated.
-    assert.isFalse(fn(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ {},
-        /* text= */ '',
-        /* reviewersMutated= */ true,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false
-    ));
-  });
-
-  test('_computeSendButtonDisabled_labelsChanged', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
-    // Mock labels changed.
-    assert.isFalse(fn(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ {},
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ true,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false
-    ));
-  });
-
-  test('_computeSendButtonDisabled_dialogDisabled', () => {
-    const fn = element._computeSendButtonDisabled.bind(element);
-    // Whole dialog is disabled.
-    assert.isTrue(fn(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ {},
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ true,
-        /* includeComments= */ false,
-        /* disabled= */ true,
-        /* commentEditing= */ false
-    ));
-    assert.isTrue(fn(
-        /* buttonLabel= */ 'Send',
-        /* draftCommentThreads= */ {},
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ true,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ true
-    ));
-  });
-
-  test('_submit blocked when no mutations exist', () => {
-    const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
-    // Stub the below function to avoid side effects from the send promise
-    // resolving.
-    sandbox.stub(element, '_purgeReviewersPendingRemove');
-    element.draftCommentThreads = [];
-    flushAsynchronousOperations();
-
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
-    assert.isFalse(sendStub.called);
-
-    element.draftCommentThreads = [{comments: [{__draft: true}]}];
-    flushAsynchronousOperations();
-
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
-    assert.isTrue(sendStub.called);
-  });
-
-  test('getFocusStops', () => {
-    // Setting draftCommentThreads to an empty object causes _sendDisabled to be
-    // computed to false.
-    element.draftCommentThreads = [];
-    assert.equal(element.getFocusStops().end, element.$.cancelButton);
-    element.draftCommentThreads = [{comments: [{__draft: true}]}];
-    assert.equal(element.getFocusStops().end, element.$.sendButton);
-  });
-
-  test('setPluginMessage', () => {
-    element.setPluginMessage('foo');
-    assert.equal(element.$.pluginMessage.textContent, 'foo');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
new file mode 100644
index 0000000..6f24fb3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -0,0 +1,1461 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {IronOverlayManager} from '@polymer/iron-overlay-behavior/iron-overlay-manager.js';
+import './gr-reply-dialog.js';
+import {mockPromise} from '../../../test/test-utils.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
+import {appContext} from '../../../services/app-context.js';
+
+const basicFixture = fixtureFromElement('gr-reply-dialog');
+
+function cloneableResponse(status, text) {
+  return {
+    ok: false,
+    status,
+    text() {
+      return Promise.resolve(text);
+    },
+    clone() {
+      return {
+        ok: false,
+        status,
+        text() {
+          return Promise.resolve(text);
+        },
+      };
+    },
+  };
+}
+
+suite('gr-reply-dialog tests', () => {
+  let element;
+  let changeNum;
+  let patchNum;
+
+  let getDraftCommentStub;
+  let setDraftCommentStub;
+  let eraseDraftCommentStub;
+
+  let lastId = 0;
+  const makeAccount = function() { return {_account_id: lastId++}; };
+  const makeGroup = function() { return {id: lastId++}; };
+
+  setup(() => {
+    changeNum = 42;
+    patchNum = 1;
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getAccount() { return Promise.resolve({}); },
+      getChange() { return Promise.resolve({}); },
+      getChangeSuggestedReviewers() { return Promise.resolve([]); },
+    });
+
+    sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
+
+    element = basicFixture.instantiate();
+    element.change = {
+      _number: changeNum,
+      owner: {
+        _account_id: 999,
+        display_name: 'Kermit',
+      },
+      labels: {
+        'Verified': {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified',
+          },
+          default_value: 0,
+        },
+        'Code-Review': {
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didn\'t submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      },
+    };
+    element.patchNum = patchNum;
+    element.permittedLabels = {
+      'Code-Review': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+      'Verified': [
+        '-1',
+        ' 0',
+        '+1',
+      ],
+    };
+
+    getDraftCommentStub = sinon.stub(element.$.storage, 'getDraftComment');
+    setDraftCommentStub = sinon.stub(element.$.storage, 'setDraftComment');
+    eraseDraftCommentStub = sinon.stub(element.$.storage,
+        'eraseDraftComment');
+
+    // sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
+    //     .returns(Promise.resolve({isLatest: true}));
+
+    // Allow the elements created by dom-repeat to be stamped.
+    flushAsynchronousOperations();
+  });
+
+  function stubSaveReview(jsonResponseProducer) {
+    return sinon.stub(
+        element,
+        '_saveReview')
+        .callsFake(review => new Promise((resolve, reject) => {
+          try {
+            const result = jsonResponseProducer(review) || {};
+            const resultStr =
+            element.$.restAPI.JSON_PREFIX + JSON.stringify(result);
+            resolve({
+              ok: true,
+              text() {
+                return Promise.resolve(resultStr);
+              },
+            });
+          } catch (err) {
+            reject(err);
+          }
+        }));
+  }
+
+  test('default to publishing draft comments with reply', done => {
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+    flush(() => {
+      flush(() => {
+        element.draft = 'I wholeheartedly disapprove';
+
+        stubSaveReview(review => {
+          assert.deepEqual(review, {
+            drafts: 'PUBLISH_ALL_REVISIONS',
+            labels: {
+              'Code-Review': 0,
+              'Verified': 0,
+            },
+            comments: {
+              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
+                message: 'I wholeheartedly disapprove',
+                unresolved: false,
+              }],
+            },
+            reviewers: [],
+          });
+          assert.isFalse(element.$.commentList.hidden);
+          done();
+        });
+
+        // This is needed on non-Blink engines most likely due to the ways in
+        // which the dom-repeat elements are stamped.
+        flush(() => {
+          MockInteractions.tap(element.shadowRoot
+              .querySelector('.send'));
+        });
+      });
+    });
+  });
+
+  test('modified attention set', done => {
+    element.serverConfig = {
+      change: {enable_attention_set: true},
+    };
+    element._newAttentionSet = new Set([314]);
+    const buttonEl = element.shadowRoot.querySelector('.edit-attention-button');
+    MockInteractions.tap(buttonEl);
+    flushAsynchronousOperations();
+
+    stubSaveReview(review => {
+      assert.isTrue(review.ignore_automatic_attention_set_rules);
+      assert.deepEqual(review.add_to_attention_set, [{
+        user: 314,
+        reason: 'manually added in reply dialog',
+      }]);
+      assert.deepEqual(review.remove_from_attention_set, []);
+      done();
+    });
+    MockInteractions.tap(element.shadowRoot.querySelector('.send'));
+  });
+
+  function checkComputeAttention(userId, reviewerIds, ownerId, attSetIds,
+      replyToIds, expectedIds, uploaderId) {
+    const user = {_account_id: userId};
+    const reviewers = {base: reviewerIds.map(id => {
+      return {_account_id: id};
+    })};
+    const draftThreads = [
+      {comments: []},
+    ];
+    replyToIds.forEach(id => draftThreads[0].comments.push({
+      author: {_account_id: id},
+    }));
+    const change = {
+      owner: {_account_id: ownerId},
+      attention_set: {},
+    };
+    attSetIds.forEach(id => change.attention_set[id] = {});
+    if (uploaderId) {
+      change.current_revision = 1;
+      change.revisions = [{}, {uploader: {_account_id: uploaderId}}];
+    }
+    element.change = change;
+    element._reviewers = reviewers.base;
+    flushAsynchronousOperations();
+    element._computeNewAttention(user, reviewers, change, draftThreads);
+    assert.sameMembers([...element._newAttentionSet], expectedIds);
+  }
+
+  test('computeNewAttention', () => {
+    checkComputeAttention(null, [], 999, [], [], [999]);
+    checkComputeAttention(1, [], 999, [], [], [999]);
+    checkComputeAttention(1, [], 999, [1], [], [999]);
+    checkComputeAttention(1, [22], 999, [], [], [999]);
+    checkComputeAttention(1, [22], 999, [22], [], [22, 999]);
+    checkComputeAttention(1, [22], 999, [], [22], [22, 999]);
+    checkComputeAttention(1, [22, 33], 999, [33], [22], [22, 33, 999]);
+    checkComputeAttention(1, [], 1, [], [], [1]);
+    checkComputeAttention(1, [], 1, [1], [], [1]);
+    checkComputeAttention(1, [22], 1, [], [], [1]);
+    checkComputeAttention(1, [22], 1, [], [22], [22]);
+    checkComputeAttention(1, [22, 33], 1, [33], [22], [22, 33]);
+    checkComputeAttention(1, [22, 33], 1, [], [22], [22]);
+    checkComputeAttention(1, [22, 33], 1, [], [22, 33], [22, 33]);
+    checkComputeAttention(1, [22, 33], 1, [22, 33], [], [22, 33]);
+    // with uploader
+    checkComputeAttention(1, [], 1, [], [2], [2], 2);
+    checkComputeAttention(1, [], 1, [2], [], [2], 2);
+    checkComputeAttention(1, [], 3, [], [], [2, 3], 2);
+  });
+
+  test('computeNewAttentionNames', () => {
+    element._reviewers = [
+      {_account_id: 123, display_name: 'Ernie'},
+      {_account_id: 321, display_name: 'Bert'},
+    ];
+    element._ccs = [
+      {_account_id: 7, display_name: 'Elmo'},
+    ];
+    const compute = (currentAtt, newAtt) => element._computeNewAttentionNames(
+        undefined, new Set(currentAtt), new Set(newAtt));
+
+    assert.equal(compute([], []), '');
+    assert.equal(compute([], [999]), 'Kermit');
+    assert.equal(compute([999], []), '');
+    assert.equal(compute([999], [999]), '');
+    assert.equal(compute([123, 321], [999]), 'Kermit');
+    assert.equal(compute([999], [7, 123, 999]), 'Elmo, Ernie');
+  });
+
+  test('toggle resolved checkbox', done => {
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+    const checkboxEl = element.shadowRoot.querySelector(
+        '#resolvedPatchsetLevelCommentCheckbox');
+    MockInteractions.tap(checkboxEl);
+    flush(() => {
+      flush(() => {
+        element.draft = 'I wholeheartedly disapprove';
+
+        stubSaveReview(review => {
+          assert.deepEqual(review, {
+            drafts: 'PUBLISH_ALL_REVISIONS',
+            labels: {
+              'Code-Review': 0,
+              'Verified': 0,
+            },
+            comments: {
+              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
+                message: 'I wholeheartedly disapprove',
+                unresolved: true,
+              }],
+            },
+            reviewers: [],
+          });
+          done();
+        });
+
+        // This is needed on non-Blink engines most likely due to the ways in
+        // which the dom-repeat elements are stamped.
+        flush(() => {
+          MockInteractions.tap(element.shadowRoot
+              .querySelector('.send'));
+        });
+      });
+    });
+  });
+
+  test('keep draft comments with reply', done => {
+    MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
+    assert.equal(element._includeComments, false);
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+    flush(() => {
+      flush(() => {
+        element.draft = 'I wholeheartedly disapprove';
+
+        stubSaveReview(review => {
+          assert.deepEqual(review, {
+            drafts: 'KEEP',
+            labels: {
+              'Code-Review': 0,
+              'Verified': 0,
+            },
+            comments: {
+              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
+                message: 'I wholeheartedly disapprove',
+                unresolved: false,
+              }],
+            },
+            reviewers: [],
+          });
+          assert.isTrue(element.$.commentList.hidden);
+          done();
+        });
+
+        // This is needed on non-Blink engines most likely due to the ways in
+        // which the dom-repeat elements are stamped.
+        flush(() => {
+          MockInteractions.tap(element.shadowRoot
+              .querySelector('.send'));
+        });
+      });
+    });
+  });
+
+  test('label picker', done => {
+    element.draft = 'I wholeheartedly disapprove';
+    stubSaveReview(review => {
+      assert.deepEqual(review, {
+        drafts: 'PUBLISH_ALL_REVISIONS',
+        labels: {
+          'Code-Review': -1,
+          'Verified': -1,
+        },
+        comments: {
+          [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
+            message: 'I wholeheartedly disapprove',
+            unresolved: false,
+          }],
+        },
+        reviewers: [],
+      });
+    });
+
+    sinon.stub(element.$.labelScores, 'getLabelValues').callsFake( () => {
+      return {
+        'Code-Review': -1,
+        'Verified': -1,
+      };
+    });
+
+    element.addEventListener('send', () => {
+      // Flush to ensure properties are updated.
+      flush(() => {
+        assert.isFalse(element.disabled,
+            'Element should be enabled when done sending reply.');
+        assert.equal(element.draft.length, 0);
+        done();
+      });
+    });
+
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    flush(() => {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.send'));
+      assert.isTrue(element.disabled);
+    });
+  });
+
+  test('getlabelValue returns value', done => {
+    flush(() => {
+      element.shadowRoot
+          .querySelector('gr-label-scores')
+          .shadowRoot
+          .querySelector(`gr-label-score-row[name="Verified"]`)
+          .setSelectedValue(-1);
+      assert.equal('-1', element.getLabelValue('Verified'));
+      done();
+    });
+  });
+
+  test('getlabelValue when no score is selected', done => {
+    flush(() => {
+      element.shadowRoot
+          .querySelector('gr-label-scores')
+          .shadowRoot
+          .querySelector(`gr-label-score-row[name="Code-Review"]`)
+          .setSelectedValue(-1);
+      assert.strictEqual(element.getLabelValue('Verified'), ' 0');
+      done();
+    });
+  });
+
+  test('setlabelValue', done => {
+    element._account = {_account_id: 1};
+    flush(() => {
+      const label = 'Verified';
+      const value = '+1';
+      element.setLabelValue(label, value);
+
+      const labels = element.$.labelScores.getLabelValues();
+      assert.deepEqual(labels, {
+        'Code-Review': 0,
+        'Verified': 1,
+      });
+      done();
+    });
+  });
+
+  function getActiveElement() {
+    return IronOverlayManager.deepActiveElement;
+  }
+
+  function isVisible(el) {
+    assert.ok(el);
+    return getComputedStyle(el).getPropertyValue('display') != 'none';
+  }
+
+  function overlayObserver(mode) {
+    return new Promise(resolve => {
+      function listener() {
+        element.removeEventListener('iron-overlay-' + mode, listener);
+        resolve();
+      }
+      element.addEventListener('iron-overlay-' + mode, listener);
+    });
+  }
+
+  function isFocusInsideElement(element) {
+    // In Polymer 2 focused element either <paper-input> or nested
+    // native input <input> element depending on the current focus
+    // in browser window.
+    // For example, the focus is changed if the developer console
+    // get a focus.
+    let activeElement = getActiveElement();
+    while (activeElement) {
+      if (activeElement === element) {
+        return true;
+      }
+      if (activeElement.parentElement) {
+        activeElement = activeElement.parentElement;
+      } else {
+        activeElement = activeElement.getRootNode().host;
+      }
+    }
+    return false;
+  }
+
+  function testConfirmationDialog(done, cc) {
+    const yesButton = element
+        .shadowRoot
+        .querySelector('.reviewerConfirmationButtons gr-button:first-child');
+    const noButton = element
+        .shadowRoot
+        .querySelector('.reviewerConfirmationButtons gr-button:last-child');
+
+    element._ccPendingConfirmation = null;
+    element._reviewerPendingConfirmation = null;
+    flushAsynchronousOperations();
+    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+
+    // Cause the confirmation dialog to display.
+    let observer = overlayObserver('opened');
+    const group = {
+      id: 'id',
+      name: 'name',
+    };
+    if (cc) {
+      element._ccPendingConfirmation = {
+        group,
+        count: 10,
+      };
+    } else {
+      element._reviewerPendingConfirmation = {
+        group,
+        count: 10,
+      };
+    }
+    flushAsynchronousOperations();
+
+    if (cc) {
+      assert.deepEqual(
+          element._ccPendingConfirmation,
+          element._pendingConfirmationDetails);
+    } else {
+      assert.deepEqual(
+          element._reviewerPendingConfirmation,
+          element._pendingConfirmationDetails);
+    }
+
+    observer
+        .then(() => {
+          assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
+          observer = overlayObserver('closed');
+          const expected = 'Group name has 10 members';
+          assert.notEqual(
+              element.$.reviewerConfirmationOverlay.innerText
+                  .indexOf(expected),
+              -1);
+          MockInteractions.tap(noButton); // close the overlay
+          return observer;
+        }).then(() => {
+          assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+
+          // We should be focused on account entry input.
+          assert.isTrue(
+              isFocusInsideElement(
+                  element.$.reviewers.$.entry.$.input.$.input
+              )
+          );
+
+          // No reviewer/CC should have been added.
+          assert.equal(element.$.ccs.additions().length, 0);
+          assert.equal(element.$.reviewers.additions().length, 0);
+
+          // Reopen confirmation dialog.
+          observer = overlayObserver('opened');
+          if (cc) {
+            element._ccPendingConfirmation = {
+              group,
+              count: 10,
+            };
+          } else {
+            element._reviewerPendingConfirmation = {
+              group,
+              count: 10,
+            };
+          }
+          return observer;
+        })
+        .then(() => {
+          assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
+          observer = overlayObserver('closed');
+          MockInteractions.tap(yesButton); // Confirm the group.
+          return observer;
+        })
+        .then(() => {
+          assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
+          const additions = cc ?
+            element.$.ccs.additions() :
+            element.$.reviewers.additions();
+          assert.deepEqual(
+              additions,
+              [
+                {
+                  group: {
+                    id: 'id',
+                    name: 'name',
+                    confirmed: true,
+                    _group: true,
+                    _pendingAdd: true,
+                  },
+                },
+              ]);
+
+          // We should be focused on account entry input.
+          if (cc) {
+            assert.isTrue(
+                isFocusInsideElement(
+                    element.$.ccs.$.entry.$.input.$.input
+                )
+            );
+          } else {
+            assert.isTrue(
+                isFocusInsideElement(
+                    element.$.reviewers.$.entry.$.input.$.input
+                )
+            );
+          }
+        })
+        .then(done);
+  }
+
+  test('cc confirmation', done => {
+    testConfirmationDialog(done, true);
+  });
+
+  test('reviewer confirmation', done => {
+    testConfirmationDialog(done, false);
+  });
+
+  test('_getStorageLocation', () => {
+    const actual = element._getStorageLocation();
+    assert.equal(actual.changeNum, changeNum);
+    assert.equal(actual.patchNum, '@change');
+    assert.equal(actual.path, '@change');
+  });
+
+  test('_reviewersMutated when account-text-change is fired from ccs', () => {
+    flushAsynchronousOperations();
+    assert.isFalse(element._reviewersMutated);
+    assert.isTrue(element.$.ccs.allowAnyInput);
+    assert.isFalse(element.shadowRoot
+        .querySelector('#reviewers').allowAnyInput);
+    element.$.ccs.dispatchEvent(new CustomEvent('account-text-changed',
+        {bubbles: true, composed: true}));
+    assert.isTrue(element._reviewersMutated);
+  });
+
+  test('gets draft from storage on open', () => {
+    const storedDraft = 'hello world';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, storedDraft);
+  });
+
+  test('gets draft from storage even when text is already present', () => {
+    const storedDraft = 'hello world';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.draft = 'foo bar';
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, storedDraft);
+  });
+
+  test('blank if no stored draft', () => {
+    getDraftCommentStub.returns(null);
+    element.draft = 'foo bar';
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, '');
+  });
+
+  test('does not check stored draft when quote is present', () => {
+    const storedDraft = 'hello world';
+    const quote = '> foo bar';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.quote = quote;
+    element.open();
+    assert.isFalse(getDraftCommentStub.called);
+    assert.equal(element.draft, quote);
+    assert.isNotOk(element.quote);
+  });
+
+  test('updates stored draft on edits', () => {
+    const firstEdit = 'hello';
+    const location = element._getStorageLocation();
+
+    element.draft = firstEdit;
+    element.flushDebouncer('store');
+
+    assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
+
+    element.draft = '';
+    element.flushDebouncer('store');
+
+    assert.isTrue(eraseDraftCommentStub.calledWith(location));
+  });
+
+  test('400 converts to human-readable server-error', done => {
+    sinon.stub(window, 'fetch').callsFake(() => {
+      const text = '....{"reviewers":{"id1":{"error":"first error"}},' +
+        '"ccs":{"id2":{"error":"second error"}}}';
+      return Promise.resolve(cloneableResponse(400, text));
+    });
+
+    element.addEventListener('server-error', event => {
+      if (event.target !== element) {
+        return;
+      }
+      event.detail.response.text().then(body => {
+        assert.equal(body, 'first error, second error');
+        done();
+      });
+    });
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    flush(() => { element.send(); });
+  });
+
+  test('non-json 400 is treated as a normal server-error', done => {
+    sinon.stub(window, 'fetch').callsFake(() => {
+      const text = 'Comment validation error!';
+      return Promise.resolve(cloneableResponse(400, text));
+    });
+
+    element.addEventListener('server-error', event => {
+      if (event.target !== element) {
+        return;
+      }
+      event.detail.response.text().then(body => {
+        assert.equal(body, 'Comment validation error!');
+        done();
+      });
+    });
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    flush(() => { element.send(); });
+  });
+
+  test('filterReviewerSuggestion', () => {
+    const owner = makeAccount();
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeGroup();
+    const cc1 = makeAccount();
+    const cc2 = makeGroup();
+    let filter = element._filterReviewerSuggestionGenerator(false);
+
+    element._owner = owner;
+    element._reviewers = [reviewer1, reviewer2];
+    element._ccs = [cc1, cc2];
+
+    assert.isTrue(filter({account: makeAccount()}));
+    assert.isTrue(filter({group: makeGroup()}));
+
+    // Owner should be excluded.
+    assert.isFalse(filter({account: owner}));
+
+    // Existing and pending reviewers should be excluded when isCC = false.
+    assert.isFalse(filter({account: reviewer1}));
+    assert.isFalse(filter({group: reviewer2}));
+
+    filter = element._filterReviewerSuggestionGenerator(true);
+
+    // Existing and pending CCs should be excluded when isCC = true;.
+    assert.isFalse(filter({account: cc1}));
+    assert.isFalse(filter({group: cc2}));
+  });
+
+  test('_focusOn', () => {
+    sinon.spy(element, '_chooseFocusTarget');
+    flushAsynchronousOperations();
+    const textareaStub = sinon.stub(element.$.textarea, 'async');
+    const reviewerEntryStub = sinon.stub(element.$.reviewers.focusStart,
+        'async');
+    const ccStub = sinon.stub(element.$.ccs.focusStart, 'async');
+    element._focusOn();
+    assert.equal(element._chooseFocusTarget.callCount, 1);
+    assert.deepEqual(textareaStub.callCount, 1);
+    assert.deepEqual(reviewerEntryStub.callCount, 0);
+    assert.deepEqual(ccStub.callCount, 0);
+
+    element._focusOn(element.FocusTarget.ANY);
+    assert.equal(element._chooseFocusTarget.callCount, 2);
+    assert.deepEqual(textareaStub.callCount, 2);
+    assert.deepEqual(reviewerEntryStub.callCount, 0);
+    assert.deepEqual(ccStub.callCount, 0);
+
+    element._focusOn(element.FocusTarget.BODY);
+    assert.equal(element._chooseFocusTarget.callCount, 2);
+    assert.deepEqual(textareaStub.callCount, 3);
+    assert.deepEqual(reviewerEntryStub.callCount, 0);
+    assert.deepEqual(ccStub.callCount, 0);
+
+    element._focusOn(element.FocusTarget.REVIEWERS);
+    assert.equal(element._chooseFocusTarget.callCount, 2);
+    assert.deepEqual(textareaStub.callCount, 3);
+    assert.deepEqual(reviewerEntryStub.callCount, 1);
+    assert.deepEqual(ccStub.callCount, 0);
+
+    element._focusOn(element.FocusTarget.CCS);
+    assert.equal(element._chooseFocusTarget.callCount, 2);
+    assert.deepEqual(textareaStub.callCount, 3);
+    assert.deepEqual(reviewerEntryStub.callCount, 1);
+    assert.deepEqual(ccStub.callCount, 1);
+  });
+
+  test('_chooseFocusTarget', () => {
+    element._account = null;
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element._account = {_account_id: 1};
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element.change.owner = {_account_id: 2};
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element.change.owner._account_id = 1;
+    element.change._reviewers = null;
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
+
+    element._reviewers = [];
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
+
+    element._reviewers.push({});
+    assert.strictEqual(
+        element._chooseFocusTarget(), element.FocusTarget.BODY);
+  });
+
+  test('only send labels that have changed', done => {
+    flush(() => {
+      stubSaveReview(review => {
+        assert.deepEqual(review.labels, {
+          'Code-Review': 0,
+          'Verified': -1,
+        });
+      });
+
+      element.addEventListener('send', () => {
+        done();
+      });
+      // Without wrapping this test in flush(), the below two calls to
+      // MockInteractions.tap() cause a race in some situations in shadow DOM.
+      // The send button can be tapped before the others, causing the test to
+      // fail.
+
+      element.shadowRoot
+          .querySelector('gr-label-scores').shadowRoot
+          .querySelector(
+              'gr-label-score-row[name="Verified"]')
+          .setSelectedValue(-1);
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.send'));
+    });
+  });
+
+  test('_processReviewerChange', () => {
+    const mockIndexSplices = function(toRemove) {
+      return [{
+        removed: [toRemove],
+      }];
+    };
+
+    element._processReviewerChange(
+        mockIndexSplices(makeAccount()), 'REVIEWER');
+    assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
+  });
+
+  test('_purgeReviewersPendingRemove', () => {
+    const removeStub = sinon.stub(element, '_removeAccount');
+    const mock = function() {
+      element._reviewersPendingRemove = {
+        test: [makeAccount()],
+        test2: [makeAccount(), makeAccount()],
+      };
+    };
+    const checkObjEmpty = function(obj) {
+      for (const prop in obj) {
+        if (obj.hasOwnProperty(prop) && obj[prop].length) { return false; }
+      }
+      return true;
+    };
+    mock();
+    element._purgeReviewersPendingRemove(true); // Cancel
+    assert.isFalse(removeStub.called);
+    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
+
+    mock();
+    element._purgeReviewersPendingRemove(false); // Submit
+    assert.isTrue(removeStub.called);
+    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
+  });
+
+  test('_removeAccount', done => {
+    sinon.stub(element.$.restAPI, 'removeChangeReviewer')
+        .returns(Promise.resolve({ok: true}));
+    const arr = [makeAccount(), makeAccount()];
+    element.change.reviewers = {
+      REVIEWER: arr.slice(),
+    };
+
+    element._removeAccount(arr[1], 'REVIEWER').then(() => {
+      assert.equal(element.change.reviewers.REVIEWER.length, 1);
+      assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
+      done();
+    });
+  });
+
+  test('moving from cc to reviewer', () => {
+    element._reviewersPendingRemove = {
+      CC: [],
+      REVIEWER: [],
+    };
+    flushAsynchronousOperations();
+
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const reviewer3 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    const cc4 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2, reviewer3];
+    element._ccs = [cc1, cc2, cc3, cc4];
+    element.push('_reviewers', cc1);
+    flushAsynchronousOperations();
+
+    assert.deepEqual(element._reviewers,
+        [reviewer1, reviewer2, reviewer3, cc1]);
+    assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
+    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);
+
+    element.push('_reviewers', cc4, cc3);
+    flushAsynchronousOperations();
+
+    assert.deepEqual(element._reviewers,
+        [reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
+    assert.deepEqual(element._ccs, [cc2]);
+    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
+  });
+
+  test('moving from reviewer to cc', () => {
+    element._reviewersPendingRemove = {
+      CC: [],
+      REVIEWER: [],
+    };
+    flushAsynchronousOperations();
+
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const reviewer3 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    const cc4 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2, reviewer3];
+    element._ccs = [cc1, cc2, cc3, cc4];
+    element.push('_ccs', reviewer1);
+    flushAsynchronousOperations();
+
+    assert.deepEqual(element._reviewers,
+        [reviewer2, reviewer3]);
+    assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
+    assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);
+
+    element.push('_ccs', reviewer3, reviewer2);
+    flushAsynchronousOperations();
+
+    assert.deepEqual(element._reviewers, []);
+    assert.deepEqual(element._ccs,
+        [cc1, cc2, cc3, cc4, reviewer1, reviewer3, reviewer2]);
+    assert.deepEqual(element._reviewersPendingRemove.REVIEWER,
+        [reviewer1, reviewer3, reviewer2]);
+  });
+
+  test('migrate reviewers between states', done => {
+    element._reviewersPendingRemove = {
+      CC: [],
+      REVIEWER: [],
+    };
+    flushAsynchronousOperations();
+    const reviewers = element.$.reviewers;
+    const ccs = element.$.ccs;
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2];
+    element._ccs = [cc1, cc2, cc3];
+
+    const mutations = [];
+
+    stubSaveReview(review => mutations.push(...review.reviewers));
+
+    sinon.stub(element, '_removeAccount').callsFake((account, type) => {
+      mutations.push({state: 'REMOVED', account});
+      return Promise.resolve();
+    });
+
+    // Remove and add to other field.
+    reviewers.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: reviewer1},
+          composed: true, bubbles: true,
+        }));
+    ccs.$.entry.dispatchEvent(
+        new CustomEvent('add', {
+          detail: {value: {account: reviewer1}},
+          composed: true, bubbles: true,
+        }));
+    ccs.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: cc1},
+          composed: true, bubbles: true,
+        }));
+    ccs.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: cc3},
+          composed: true, bubbles: true,
+        }));
+    reviewers.$.entry.dispatchEvent(
+        new CustomEvent('add', {
+          detail: {value: {account: cc1}},
+          composed: true, bubbles: true,
+        }));
+
+    // Add to other field without removing from former field.
+    // (Currently not possible in UI, but this is a good consistency check).
+    reviewers.$.entry.dispatchEvent(
+        new CustomEvent('add', {
+          detail: {value: {account: cc2}},
+          composed: true, bubbles: true,
+        }));
+    ccs.$.entry.dispatchEvent(
+        new CustomEvent('add', {
+          detail: {value: {account: reviewer2}},
+          composed: true, bubbles: true,
+        }));
+    const mapReviewer = function(reviewer, opt_state) {
+      const result = {reviewer: reviewer._account_id, confirmed: undefined};
+      if (opt_state) {
+        result.state = opt_state;
+      }
+      return result;
+    };
+
+    // Send and purge and verify moves, delete cc3.
+    element.send()
+        .then(keepReviewers =>
+          element._purgeReviewersPendingRemove(false, keepReviewers))
+        .then(() => {
+          assert.deepEqual(
+              mutations, [
+                mapReviewer(cc1),
+                mapReviewer(cc2),
+                mapReviewer(reviewer1, 'CC'),
+                mapReviewer(reviewer2, 'CC'),
+                {account: cc3, state: 'REMOVED'},
+              ]);
+          done();
+        });
+  });
+
+  test('emits cancel on esc key', () => {
+    const cancelHandler = sinon.spy();
+    element.addEventListener('cancel', cancelHandler);
+    MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+    flushAsynchronousOperations();
+
+    assert.isTrue(cancelHandler.called);
+  });
+
+  test('should not send on enter key', () => {
+    stubSaveReview(() => undefined);
+    element.addEventListener('send', () => assert.fail('wrongly called'));
+    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+    flushAsynchronousOperations();
+  });
+
+  test('emit send on ctrl+enter key', done => {
+    stubSaveReview(() => undefined);
+    element.addEventListener('send', () => done());
+    MockInteractions.pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
+    flushAsynchronousOperations();
+  });
+
+  test('_computeMessagePlaceholder', () => {
+    assert.equal(
+        element._computeMessagePlaceholder(false),
+        'Say something nice...');
+    assert.equal(
+        element._computeMessagePlaceholder(true),
+        'Add a note for your reviewers...');
+  });
+
+  test('_computeSendButtonLabel', () => {
+    assert.equal(
+        element._computeSendButtonLabel(false),
+        'Send');
+    assert.equal(
+        element._computeSendButtonLabel(true),
+        'Send and Start review');
+  });
+
+  test('_handle400Error reviewrs and CCs', done => {
+    const error1 = 'error 1';
+    const error2 = 'error 2';
+    const error3 = 'error 3';
+    const text = ')]}\'' + JSON.stringify({
+      reviewers: {
+        username1: {
+          input: 'user 1',
+          error: error1,
+        },
+        username2: {
+          input: 'user 2',
+          error: error2,
+        },
+      },
+      ccs: {
+        username3: {
+          input: 'user 3',
+          error: error3,
+        },
+      },
+    });
+    element.addEventListener('server-error', e => {
+      e.detail.response.text().then(text => {
+        assert.equal(text, [error1, error2, error3].join(', '));
+        done();
+      });
+    });
+    element._handle400Error(cloneableResponse(400, text));
+  });
+
+  test('_handle400Error CCs only', done => {
+    const error1 = 'error 1';
+    const text = ')]}\'' + JSON.stringify({
+      ccs: {
+        username1: {
+          input: 'user 1',
+          error: error1,
+        },
+      },
+    });
+    element.addEventListener('server-error', e => {
+      e.detail.response.text().then(text => {
+        assert.equal(text, error1);
+        done();
+      });
+    });
+    element._handle400Error(cloneableResponse(400, text));
+  });
+
+  test('fires height change when the drafts comments load', done => {
+    // Flush DOM operations before binding to the autogrow event so we don't
+    // catch the events fired from the initial layout.
+    flush(() => {
+      const autoGrowHandler = sinon.stub();
+      element.addEventListener('autogrow', autoGrowHandler);
+      element.draftCommentThreads = [];
+      flush(() => {
+        assert.isTrue(autoGrowHandler.called);
+        done();
+      });
+    });
+  });
+
+  suite('post review API', () => {
+    let startReviewStub;
+
+    setup(() => {
+      startReviewStub = sinon.stub(
+          element.$.restAPI,
+          'startReview')
+          .callsFake(() => Promise.resolve());
+    });
+
+    test('ready property in review input on start review', () => {
+      stubSaveReview(review => {
+        assert.isTrue(review.ready);
+        return {ready: true};
+      });
+      return element.send(true, true).then(() => {
+        assert.isFalse(startReviewStub.called);
+      });
+    });
+
+    test('no ready property in review input on save review', () => {
+      stubSaveReview(review => {
+        assert.isUndefined(review.ready);
+      });
+      return element.send(true, false).then(() => {
+        assert.isFalse(startReviewStub.called);
+      });
+    });
+  });
+
+  suite('start review and save buttons', () => {
+    let sendStub;
+
+    setup(() => {
+      sendStub = sinon.stub(element, 'send').callsFake(() => Promise.resolve());
+      element.canBeStarted = true;
+      // Flush to make both Start/Save buttons appear in DOM.
+      flushAsynchronousOperations();
+    });
+
+    test('start review sets ready', () => {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.send'));
+      flushAsynchronousOperations();
+      assert.isTrue(sendStub.calledWith(true, true));
+    });
+
+    test('save review doesn\'t set ready', () => {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
+      flushAsynchronousOperations();
+      assert.isTrue(sendStub.calledWith(true, false));
+    });
+  });
+
+  test('buttons disabled until all API calls are resolved', () => {
+    stubSaveReview(review => {
+      return {ready: true};
+    });
+    return element.send(true, true).then(() => {
+      assert.isFalse(element.disabled);
+    });
+  });
+
+  suite('error handling', () => {
+    const expectedDraft = 'draft';
+    const expectedError = new Error('test');
+
+    setup(() => {
+      element.draft = expectedDraft;
+    });
+
+    function assertDialogOpenAndEnabled() {
+      assert.strictEqual(expectedDraft, element.draft);
+      assert.isFalse(element.disabled);
+    }
+
+    test('error occurs in _saveReview', () => {
+      stubSaveReview(review => {
+        throw expectedError;
+      });
+      return element.send(true, true).catch(err => {
+        assert.strictEqual(expectedError, err);
+        assertDialogOpenAndEnabled();
+      });
+    });
+
+    suite('pending diff drafts?', () => {
+      test('yes', () => {
+        const promise = mockPromise();
+        const refreshHandler = sinon.stub();
+
+        element.addEventListener('comment-refresh', refreshHandler);
+        sinon.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(true);
+        element.$.restAPI._pendingRequests.sendDiffDraft = [promise];
+        element.open();
+
+        assert.isFalse(refreshHandler.called);
+        assert.isTrue(element._savingComments);
+
+        promise.resolve();
+
+        return element.$.restAPI.awaitPendingDiffDrafts().then(() => {
+          assert.isTrue(refreshHandler.called);
+          assert.isFalse(element._savingComments);
+        });
+      });
+
+      test('no', () => {
+        sinon.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(false);
+        element.open();
+        assert.notOk(element._savingComments);
+      });
+    });
+  });
+
+  test('_computeSendButtonDisabled_canBeStarted', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock canBeStarted
+    assert.isFalse(fn(
+        /* canBeStarted= */ true,
+        /* draftCommentThreads= */ [],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* attentionModified= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_allFalse', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock everything false
+    assert.isTrue(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* attentionModified= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_attentionModified true', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock everything false
+    assert.isFalse(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* attentionModified= */ true
+    ));
+  });
+
+  test('_computeSendButtonDisabled_draftCommentsSend', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock nonempty comment draft array, with sending comments.
+    assert.isFalse(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ true,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* attentionModified= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_draftCommentsDoNotSend', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock nonempty comment draft array, without sending comments.
+    assert.isTrue(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* attentionModified= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_changeMessage', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock nonempty change message.
+    assert.isFalse(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ {},
+        /* text= */ 'test',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* attentionModified= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_reviewersChanged', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock reviewers mutated.
+    assert.isFalse(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ {},
+        /* text= */ '',
+        /* reviewersMutated= */ true,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* attentionModified= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_labelsChanged', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Mock labels changed.
+    assert.isFalse(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ {},
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ true,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* attentionModified= */ false
+    ));
+  });
+
+  test('_computeSendButtonDisabled_dialogDisabled', () => {
+    const fn = element._computeSendButtonDisabled.bind(element);
+    // Whole dialog is disabled.
+    assert.isTrue(fn(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ {},
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ true,
+        /* includeComments= */ false,
+        /* disabled= */ true,
+        /* commentEditing= */ false,
+        /* attentionModified= */ false
+    ));
+    assert.isTrue(fn(
+        /* buttonLabel= */ 'Send',
+        /* draftCommentThreads= */ {},
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ true,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ true,
+        /* attentionModified= */ false
+    ));
+  });
+
+  test('_submit blocked when no mutations exist', () => {
+    const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
+    // Stub the below function to avoid side effects from the send promise
+    // resolving.
+    sinon.stub(element, '_purgeReviewersPendingRemove');
+    element.draftCommentThreads = [];
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isFalse(sendStub.called);
+
+    element.draftCommentThreads = [{comments: [{__draft: true}]}];
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button.send'));
+    assert.isTrue(sendStub.called);
+  });
+
+  test('getFocusStops', () => {
+    // Setting draftCommentThreads to an empty object causes _sendDisabled to be
+    // computed to false.
+    element.draftCommentThreads = [];
+    assert.equal(element.getFocusStops().end, element.$.cancelButton);
+    element.draftCommentThreads = [{comments: [{__draft: true}]}];
+    assert.equal(element.getFocusStops().end, element.$.sendButton);
+  });
+
+  test('setPluginMessage', () => {
+    element.setPluginMessage('foo');
+    assert.equal(element.$.pluginMessage.textContent, 'foo');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html
deleted file mode 100644
index 94787e6..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html
+++ /dev/null
@@ -1,33 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      const replyApi = plugin.changeReply();
-      replyApi.addReplyTextChangedCallback(text => {
-        const label = 'Code-Review';
-        const labelValue = replyApi.getLabelValue(label);
-        if (labelValue &&
-            labelValue === ' 0' &&
-            text.indexOf('LGTM') === 0) {
-          replyApi.setLabelValue(label, '+1');
-        }
-      });
-    });
-  </script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index c933c7c..0c48aca 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-account-chip/gr-account-chip.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
@@ -27,7 +25,7 @@
 import {htmlTemplate} from './gr-reviewer-list_html.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrReviewerList extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -180,7 +178,7 @@
 
   _reviewersChanged(changeRecord, owner, serverConfig) {
     // Polymer 2: check for undefined
-    if ([changeRecord, owner, serverConfig].some(arg => arg === undefined)) {
+    if ([changeRecord, owner, serverConfig].includes(undefined)) {
       return;
     }
 
@@ -214,7 +212,7 @@
 
   _computeHiddenCount(reviewers, displayedReviewers) {
     // Polymer 2: check for undefined
-    if ([reviewers, displayedReviewers].some(arg => arg === undefined)) {
+    if ([reviewers, displayedReviewers].includes(undefined)) {
       return undefined;
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js
deleted file mode 100644
index 93926cf..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.8;
-      pointer-events: none;
-    }
-    .container {
-      display: block;
-    }
-    gr-button {
-      --gr-button: {
-        padding: 0px 0px;
-      }
-    }
-    gr-account-chip {
-      display: inline-block;
-    }
-  </style>
-  <div class="container">
-    <div>
-      <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
-        <gr-account-chip
-          class="reviewer"
-          account="[[reviewer]]"
-          on-remove="_handleRemove"
-          voteable-text="[[_computeVoteableText(reviewer, change)]]"
-          removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]"
-        >
-        </gr-account-chip>
-      </template>
-    </div>
-    <gr-button
-      class="hiddenReviewers"
-      link=""
-      hidden$="[[!_hiddenReviewerCount]]"
-      on-click="_handleViewAll"
-      >and [[_hiddenReviewerCount]] more</gr-button
-    >
-    <div class="controlsContainer" hidden$="[[!mutable]]">
-      <gr-button
-        link=""
-        id="addReviewer"
-        class="addReviewer"
-        on-click="_handleAddTap"
-        >[[_addLabel]]</gr-button
-      >
-    </div>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
new file mode 100644
index 0000000..616a7db
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
@@ -0,0 +1,73 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.8;
+      pointer-events: none;
+    }
+    .container {
+      display: block;
+    }
+    gr-button {
+      --gr-button: {
+        padding: 0px 0px;
+      }
+    }
+    gr-account-chip {
+      display: inline-block;
+    }
+  </style>
+  <div class="container">
+    <div>
+      <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
+        <gr-account-chip
+          class="reviewer"
+          account="[[reviewer]]"
+          change="[[change]]"
+          on-remove="_handleRemove"
+          highlight-attention
+          voteable-text="[[_computeVoteableText(reviewer, change)]]"
+          removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]"
+        >
+        </gr-account-chip>
+      </template>
+    </div>
+    <gr-button
+      class="hiddenReviewers"
+      link=""
+      hidden$="[[!_hiddenReviewerCount]]"
+      on-click="_handleViewAll"
+      >and [[_hiddenReviewerCount]] more</gr-button
+    >
+    <div class="controlsContainer" hidden$="[[!mutable]]">
+      <gr-button
+        link=""
+        id="addReviewer"
+        class="addReviewer"
+        on-click="_handleAddTap"
+        >[[_addLabel]]</gr-button
+      >
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
deleted file mode 100644
index 6949afc..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ /dev/null
@@ -1,323 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-reviewer-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-reviewer-list></gr-reviewer-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-reviewer-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-reviewer-list tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    element = fixture('basic');
-    element.serverConfig = {};
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      removeChangeReviewer() {
-        return Promise.resolve({ok: true});
-      },
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('controls hidden on immutable element', () => {
-    element.mutable = false;
-    assert.isTrue(element.shadowRoot
-        .querySelector('.controlsContainer').hasAttribute('hidden'));
-    element.mutable = true;
-    assert.isFalse(element.shadowRoot
-        .querySelector('.controlsContainer').hasAttribute('hidden'));
-  });
-
-  test('add reviewer button opens reply dialog', done => {
-    element.addEventListener('show-reply-dialog', () => {
-      done();
-    });
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.addReviewer'));
-  });
-
-  test('only show remove for removable reviewers', () => {
-    element.mutable = true;
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        REVIEWER: [
-          {
-            _account_id: 2,
-            name: 'Bojack Horseman',
-            email: 'SecretariatRulez96@hotmail.com',
-          },
-          {
-            _account_id: 3,
-            name: 'Pinky Penguin',
-          },
-        ],
-        CC: [
-          {
-            _account_id: 4,
-            name: 'Diane Nguyen',
-            email: 'macarthurfellow2B@juno.com',
-          },
-          {
-            email: 'test@e.mail',
-          },
-        ],
-      },
-      removable_reviewers: [
-        {
-          _account_id: 3,
-          name: 'Pinky Penguin',
-        },
-        {
-          _account_id: 4,
-          name: 'Diane Nguyen',
-          email: 'macarthurfellow2B@juno.com',
-        },
-        {
-          email: 'test@e.mail',
-        },
-      ],
-    };
-    flushAsynchronousOperations();
-    const chips =
-        dom(element.root).querySelectorAll('gr-account-chip');
-    assert.equal(chips.length, 4);
-
-    for (const el of Array.from(chips)) {
-      const accountID = el.account._account_id || el.account.email;
-      assert.ok(accountID);
-
-      const buttonEl = el.shadowRoot
-          .querySelector('gr-button');
-      assert.isNotNull(buttonEl);
-      if (accountID == 2) {
-        assert.isTrue(buttonEl.hasAttribute('hidden'));
-      } else {
-        assert.isFalse(buttonEl.hasAttribute('hidden'));
-      }
-    }
-  });
-
-  test('tracking reviewers and ccs', () => {
-    let counter = 0;
-    function makeAccount() {
-      return {_account_id: counter++};
-    }
-
-    const owner = makeAccount();
-    const reviewer = makeAccount();
-    const cc = makeAccount();
-    const reviewers = {
-      REMOVED: [makeAccount()],
-      REVIEWER: [owner, reviewer],
-      CC: [owner, cc],
-    };
-
-    element.ccsOnly = false;
-    element.reviewersOnly = false;
-    element.change = {
-      owner,
-      reviewers,
-    };
-    assert.deepEqual(element._reviewers, [reviewer, cc]);
-
-    element.reviewersOnly = true;
-    element.change = {
-      owner,
-      reviewers,
-    };
-    assert.deepEqual(element._reviewers, [reviewer]);
-
-    element.ccsOnly = true;
-    element.reviewersOnly = false;
-    element.change = {
-      owner,
-      reviewers,
-    };
-    assert.deepEqual(element._reviewers, [cc]);
-  });
-
-  test('_handleAddTap passes mode with event', () => {
-    const fireStub = sandbox.stub(element, 'dispatchEvent');
-    const e = {preventDefault() {}};
-
-    element.ccsOnly = false;
-    element.reviewersOnly = false;
-    element._handleAddTap(e);
-    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(fireStub.lastCall.args[0].detail, {value: {}});
-
-    element.reviewersOnly = true;
-    element._handleAddTap(e);
-    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(
-        fireStub.lastCall.args[0].detail,
-        {value: {reviewersOnly: true}});
-
-    element.ccsOnly = true;
-    element.reviewersOnly = false;
-    element._handleAddTap(e);
-    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(fireStub.lastCall.args[0].detail,
-        {value: {ccsOnly: true}});
-  });
-
-  test('dont show all reviewers button with 4 reviewers', () => {
-    const reviewers = [];
-    element.maxReviewersDisplayed = 3;
-    for (let i = 0; i < 4; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-    }
-    element.ccsOnly = true;
-
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        CC: reviewers,
-      },
-    };
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 4);
-    assert.equal(element._reviewers.length, 4);
-    assert.isTrue(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-  });
-
-  test('show all reviewers button with 6 reviewers', () => {
-    const reviewers = [];
-    for (let i = 0; i < 6; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-    }
-    element.ccsOnly = true;
-
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        CC: reviewers,
-      },
-    };
-    assert.equal(element._hiddenReviewerCount, 3);
-    assert.equal(element._displayedReviewers.length, 3);
-    assert.equal(element._reviewers.length, 6);
-    assert.isFalse(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-  });
-
-  test('show all reviewers button', () => {
-    const reviewers = [];
-    for (let i = 0; i < 100; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-    }
-    element.ccsOnly = true;
-
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        CC: reviewers,
-      },
-    };
-    assert.equal(element._hiddenReviewerCount, 97);
-    assert.equal(element._displayedReviewers.length, 3);
-    assert.equal(element._reviewers.length, 100);
-    assert.isFalse(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.hiddenReviewers'));
-
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 100);
-    assert.equal(element._reviewers.length, 100);
-    assert.isTrue(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-  });
-
-  test('votable labels', () => {
-    const change = {
-      labels: {
-        Foo: {
-          all: [{_account_id: 7, permitted_voting_range: {max: 2}}],
-        },
-        Bar: {
-          all: [{_account_id: 1, permitted_voting_range: {max: 1}},
-            {_account_id: 7, permitted_voting_range: {max: 1}}],
-        },
-        FooBar: {
-          all: [{_account_id: 7, value: 0}],
-        },
-      },
-      permitted_labels: {
-        Foo: ['-1', ' 0', '+1', '+2'],
-        FooBar: ['-1', ' 0'],
-      },
-    };
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 1}, change),
-        'Bar');
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 7}, change),
-        'Foo: +2, Bar, FooBar');
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 2}, change),
-        '');
-  });
-
-  test('fails gracefully when all is not included', () => {
-    const change = {
-      labels: {Foo: {}},
-      permitted_labels: {
-        Foo: ['-1', ' 0', '+1', '+2'],
-      },
-    };
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 1}, change), '');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
new file mode 100644
index 0000000..809a768
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
@@ -0,0 +1,304 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-reviewer-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-reviewer-list');
+
+suite('gr-reviewer-list tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.serverConfig = {};
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      removeChangeReviewer() {
+        return Promise.resolve({ok: true});
+      },
+    });
+  });
+
+  test('controls hidden on immutable element', () => {
+    element.mutable = false;
+    assert.isTrue(element.shadowRoot
+        .querySelector('.controlsContainer').hasAttribute('hidden'));
+    element.mutable = true;
+    assert.isFalse(element.shadowRoot
+        .querySelector('.controlsContainer').hasAttribute('hidden'));
+  });
+
+  test('add reviewer button opens reply dialog', done => {
+    element.addEventListener('show-reply-dialog', () => {
+      done();
+    });
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.addReviewer'));
+  });
+
+  test('only show remove for removable reviewers', () => {
+    element.mutable = true;
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        REVIEWER: [
+          {
+            _account_id: 2,
+            name: 'Bojack Horseman',
+            email: 'SecretariatRulez96@hotmail.com',
+          },
+          {
+            _account_id: 3,
+            name: 'Pinky Penguin',
+          },
+        ],
+        CC: [
+          {
+            _account_id: 4,
+            name: 'Diane Nguyen',
+            email: 'macarthurfellow2B@juno.com',
+          },
+          {
+            email: 'test@e.mail',
+          },
+        ],
+      },
+      removable_reviewers: [
+        {
+          _account_id: 3,
+          name: 'Pinky Penguin',
+        },
+        {
+          _account_id: 4,
+          name: 'Diane Nguyen',
+          email: 'macarthurfellow2B@juno.com',
+        },
+        {
+          email: 'test@e.mail',
+        },
+      ],
+    };
+    flushAsynchronousOperations();
+    const chips =
+        dom(element.root).querySelectorAll('gr-account-chip');
+    assert.equal(chips.length, 4);
+
+    for (const el of Array.from(chips)) {
+      const accountID = el.account._account_id || el.account.email;
+      assert.ok(accountID);
+
+      const buttonEl = el.shadowRoot
+          .querySelector('gr-button');
+      assert.isNotNull(buttonEl);
+      if (accountID == 2) {
+        assert.isTrue(buttonEl.hasAttribute('hidden'));
+      } else {
+        assert.isFalse(buttonEl.hasAttribute('hidden'));
+      }
+    }
+  });
+
+  test('tracking reviewers and ccs', () => {
+    let counter = 0;
+    function makeAccount() {
+      return {_account_id: counter++};
+    }
+
+    const owner = makeAccount();
+    const reviewer = makeAccount();
+    const cc = makeAccount();
+    const reviewers = {
+      REMOVED: [makeAccount()],
+      REVIEWER: [owner, reviewer],
+      CC: [owner, cc],
+    };
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element.change = {
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer, cc]);
+
+    element.reviewersOnly = true;
+    element.change = {
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer]);
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element.change = {
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [cc]);
+  });
+
+  test('_handleAddTap passes mode with event', () => {
+    const fireStub = sinon.stub(element, 'dispatchEvent');
+    const e = {preventDefault() {}};
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual(fireStub.lastCall.args[0].detail, {value: {}});
+
+    element.reviewersOnly = true;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual(
+        fireStub.lastCall.args[0].detail,
+        {value: {reviewersOnly: true}});
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual(fireStub.lastCall.args[0].detail,
+        {value: {ccsOnly: true}});
+  });
+
+  test('dont show all reviewers button with 4 reviewers', () => {
+    const reviewers = [];
+    element.maxReviewersDisplayed = 3;
+    for (let i = 0; i < 4; i++) {
+      reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 4);
+    assert.equal(element._reviewers.length, 4);
+    assert.isTrue(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+  });
+
+  test('show all reviewers button with 6 reviewers', () => {
+    const reviewers = [];
+    for (let i = 0; i < 6; i++) {
+      reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 3);
+    assert.equal(element._displayedReviewers.length, 3);
+    assert.equal(element._reviewers.length, 6);
+    assert.isFalse(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+  });
+
+  test('show all reviewers button', () => {
+    const reviewers = [];
+    for (let i = 0; i < 100; i++) {
+      reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      owner: {
+        _account_id: 1,
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 97);
+    assert.equal(element._displayedReviewers.length, 3);
+    assert.equal(element._reviewers.length, 100);
+    assert.isFalse(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.hiddenReviewers'));
+
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 100);
+    assert.equal(element._reviewers.length, 100);
+    assert.isTrue(element.shadowRoot
+        .querySelector('.hiddenReviewers').hidden);
+  });
+
+  test('votable labels', () => {
+    const change = {
+      labels: {
+        Foo: {
+          all: [{_account_id: 7, permitted_voting_range: {max: 2}}],
+        },
+        Bar: {
+          all: [{_account_id: 1, permitted_voting_range: {max: 1}},
+            {_account_id: 7, permitted_voting_range: {max: 1}}],
+        },
+        FooBar: {
+          all: [{_account_id: 7, value: 0}],
+        },
+      },
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+        FooBar: ['-1', ' 0'],
+      },
+    };
+    assert.strictEqual(
+        element._computeVoteableText({_account_id: 1}, change),
+        'Bar');
+    assert.strictEqual(
+        element._computeVoteableText({_account_id: 7}, change),
+        'Foo: +2, Bar, FooBar');
+    assert.strictEqual(
+        element._computeVoteableText({_account_id: 2}, change),
+        '');
+  });
+
+  test('fails gracefully when all is not included', () => {
+    const change = {
+      labels: {Foo: {}},
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+      },
+    };
+    assert.strictEqual(
+        element._computeVoteableText({_account_id: 1}, change), '');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
index 44ec9b0..28a8d9a 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/paper-toggle-button/paper-toggle-button.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-comment-thread/gr-comment-thread.js';
@@ -24,15 +22,16 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-thread-list_html.js';
-import {util} from '../../../scripts/util.js';
+import {parseDate} from '../../../utils/date-util.js';
 
 import {NO_THREADS_MSG} from '../../../constants/messages.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
 
 /**
  * Fired when a comment is saved or deleted
  *
  * @event thread-list-modified
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrThreadList extends GestureEventListeners(
     LegacyElementMixin(
@@ -51,12 +50,6 @@
       _sortedThreads: {
         type: Array,
       },
-      _filteredThreads: {
-        type: Array,
-        computed: '_computeFilteredThreads(_sortedThreads, ' +
-          '_unresolvedOnly, _draftsOnly,' +
-          'onlyShowRobotCommentsWithHumanReply)',
-      },
       _unresolvedOnly: {
         type: Boolean,
         value: false,
@@ -82,124 +75,199 @@
     };
   }
 
-  static get observers() { return ['_computeSortedThreads(threads.*)']; }
+  static get observers() {
+    return ['_updateSortedThreads(threads, threads.splices)'];
+  }
 
   _computeShowDraftToggle(loggedIn) {
     return loggedIn ? 'show' : '';
   }
 
+  _compareThreads(c1, c2) {
+    if (c1.thread.path !== c2.thread.path) {
+      // '/PATCHSET' will not come before '/COMMIT' when sorting
+      // alphabetically so move it to the front explicitly
+      if (c1.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+        return -1;
+      }
+      if (c2.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+        return 1;
+      }
+      return c1.thread.path.localeCompare(c2.thread.path);
+    }
+
+    // Patchset comments have no line/range associated with them
+    if (c1.thread.line !== c2.thread.line) {
+      if (!c1.thread.line || !c2.thread.line) {
+        // one of them is a file level comment, show first
+        return c1.thread.line ? 1 : -1;
+      }
+      return c1.thread.line < c2.thread.line ? -1 : 1;
+    }
+
+    if (c1.thread.patchNum !== c2.thread.patchNum) {
+      return c1.thread.patchNum > c2.thread.patchNum ? -1 : 1;
+    }
+
+    if (c2.unresolved !== c1.unresolved) {
+      if (!c1.unresolved) { return 1; }
+      if (!c2.unresolved) { return -1; }
+    }
+
+    if (c2.hasDraft !== c1.hasDraft) {
+      if (!c1.hasDraft) { return 1; }
+      if (!c2.hasDraft) { return -1; }
+    }
+
+    const c1Date = c1.__date || parseDate(c1.updated);
+    const c2Date = c2.__date || parseDate(c2.updated);
+    const dateCompare = c2Date - c1Date;
+    if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) {
+      return 0;
+    }
+    return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
+  }
+
   /**
+   * Observer on threads and update _sortedThreads when needed.
    * Order as follows:
-   *  - Unresolved threads with drafts (reverse chronological)
-   *  - Unresolved threads without drafts (reverse chronological)
-   *  - Resolved threads with drafts (reverse chronological)
-   *  - Resolved threads without drafts (reverse chronological)
+   *  - Patchset level threads (descending based on patchset number)
+   *    - unresolved
+          - comments with drafts
+          - comments without drafts
+   *    - resolved
+          - comments with drafts
+          - comments without drafts
+   *  - File name
+   *    - Line number
+   *      - Unresolved (descending based on patchset number)
+   *        - comments with drafts
+   *        - comments without drafts
+   *      - Resolved (descending based on patchset number)
+   *        - comments with drafts
+   *        - comments without drafts
    *
-   * @param {!Object} changeRecord
+   * @param {Array<Object>} threads
+   * @param {!Object} spliceRecord
    */
-  _computeSortedThreads(changeRecord) {
-    const threads = changeRecord.base;
-    if (!threads) { return []; }
-    this._updateSortedThreads(threads);
+  _updateSortedThreads(threads, spliceRecord) {
+    if (!threads) {
+      this._sortedThreads = [];
+      return;
+    }
+    // We only want to sort on thread additions / removals to avoid
+    // re-rendering on modifications (add new reply / edit draft etc)
+    //  https://polymer-library.polymer-project.org/3.0/docs/devguide/observers#array-observation
+    const isArrayMutation = spliceRecord &&
+      (spliceRecord.indexSplices.addedCount !== 0
+        || spliceRecord.indexSplices.removed.length);
+
+    if (this._sortedThreads
+        && this._sortedThreads.length === threads.length
+        && !isArrayMutation) {
+      // Instead of replacing the _sortedThreads which will trigger a re-render,
+      // we override all threads inside of it
+
+      for (const thread of threads) {
+        const idxInSortedThreads = this._sortedThreads
+            .findIndex(t => t.rootId === thread.rootId);
+        this.set(`_sortedThreads.${idxInSortedThreads}`, {...thread});
+      }
+      return;
+    }
+
+    const threadsWithInfo = threads
+        .map(thread => this._getThreadWithStatusInfo(thread));
+    this._sortedThreads = threadsWithInfo.sort((t1, t2) =>
+      this._compareThreads(t1, t2)).map(threadInfo => threadInfo.thread);
   }
 
-  // TODO(taoalpha): should allow only sort once during initialization
-  // to avoid flickering
-  _updateSortedThreads(threads) {
-    this._sortedThreads =
-        threads.map(this._getThreadWithSortInfo).sort((c1, c2) => {
-          // threads will be sorted by:
-          // - unresolved first
-          // - with drafts
-          // - file path
-          // - line
-          // - updated time
-          if (c2.unresolved || c1.unresolved) {
-            if (!c1.unresolved) { return 1; }
-            if (!c2.unresolved) { return -1; }
-          }
-
-          if (c2.hasDraft || c1.hasDraft) {
-            if (!c1.hasDraft) { return 1; }
-            if (!c2.hasDraft) { return -1; }
-          }
-
-          // TODO: Update here once we introduce patchset level comments
-          // they may not have or have a special line or path attribute
-
-          if (c1.thread.path !== c2.thread.path) {
-            return c1.thread.path.localeCompare(c2.thread.path);
-          }
-
-          // File level comments (no `line` property)
-          // should always show before any lines
-          if ([c1, c2].some(c => c.thread.line === undefined)) {
-            if (!c1.thread.line) { return -1; }
-            if (!c2.thread.line) { return 1; }
-          } else if (c1.thread.line !== c2.thread.line) {
-            return c1.thread.line - c2.thread.line;
-          }
-
-          const c1Date = c1.__date || util.parseDate(c1.updated);
-          const c2Date = c2.__date || util.parseDate(c2.updated);
-          const dateCompare = c2Date - c1Date;
-          if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) {
-            return 0;
-          }
-          return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
-        });
-  }
-
-  _computeFilteredThreads(sortedThreads, unresolvedOnly, draftsOnly,
+  _isFirstThreadWithFileName(sortedThreads, thread, unresolvedOnly, draftsOnly,
       onlyShowRobotCommentsWithHumanReply) {
-    // Polymer 2: check for undefined
+    const threads = sortedThreads.filter(t => this._shouldShowThread(
+        t, unresolvedOnly, draftsOnly,
+        onlyShowRobotCommentsWithHumanReply));
+    const index = threads.findIndex(t => t.rootId === thread.rootId);
+    if (index === -1) {
+      return false;
+    }
+    return index === 0 || (threads[index - 1].path !== threads[index].path);
+  }
+
+  _shouldRenderSeparator(sortedThreads, thread, unresolvedOnly, draftsOnly,
+      onlyShowRobotCommentsWithHumanReply) {
+    const threads = sortedThreads.filter(t => this._shouldShowThread(
+        t, unresolvedOnly, draftsOnly,
+        onlyShowRobotCommentsWithHumanReply));
+    const index = threads.findIndex(t => t.rootId === thread.rootId);
+    if (index === -1) {
+      return false;
+    }
+    return index > 0 && this._isFirstThreadWithFileName(sortedThreads,
+        thread, unresolvedOnly, draftsOnly,
+        onlyShowRobotCommentsWithHumanReply);
+  }
+
+  _shouldShowThread(thread, unresolvedOnly, draftsOnly,
+      onlyShowRobotCommentsWithHumanReply) {
     if ([
-      sortedThreads,
+      thread,
       unresolvedOnly,
       draftsOnly,
       onlyShowRobotCommentsWithHumanReply,
-    ].some(arg => arg === undefined)) {
-      return undefined;
+    ].includes(undefined)) {
+      return false;
     }
 
-    return sortedThreads.filter(c => {
-      if (draftsOnly) {
-        return c.hasDraft;
-      } else if (unresolvedOnly) {
-        return c.unresolved;
-      } else {
-        const comments = c && c.thread && c.thread.comments;
-        let robotComment = false;
-        let humanReplyToRobotComment = false;
-        comments.forEach(comment => {
-          if (comment.robot_id) {
-            robotComment = true;
-          } else if (robotComment) {
-            // Robot comment exists and human comment exists after it
-            humanReplyToRobotComment = true;
-          }
-        });
-        if (robotComment && onlyShowRobotCommentsWithHumanReply) {
-          return humanReplyToRobotComment;
-        }
-        return c;
-      }
-    }).map(threadInfo => threadInfo.thread);
+    if (!draftsOnly
+        && !unresolvedOnly
+        && !onlyShowRobotCommentsWithHumanReply) {
+      return true;
+    }
+
+    const threadInfo = this._getThreadWithStatusInfo(thread);
+
+    if (threadInfo.isEditing) {
+      return true;
+    }
+
+    if (threadInfo.hasRobotComment
+       && onlyShowRobotCommentsWithHumanReply
+       && !threadInfo.hasHumanReplyToRobotComment) {
+      return false;
+    }
+
+    let filtersCheck = true;
+    if (draftsOnly && unresolvedOnly) {
+      filtersCheck = threadInfo.hasDraft && threadInfo.unresolved;
+    } else if (draftsOnly) {
+      filtersCheck = threadInfo.hasDraft;
+    } else if (unresolvedOnly) {
+      filtersCheck = threadInfo.unresolved;
+    }
+
+    return filtersCheck;
   }
 
-  _getThreadWithSortInfo(thread) {
-    const lastComment = thread.comments[thread.comments.length - 1] || {};
-
-    const lastNonDraftComment =
-        (lastComment.__draft && thread.comments.length > 1) ?
-          thread.comments[thread.comments.length - 2] :
-          lastComment;
+  _getThreadWithStatusInfo(thread) {
+    const comments = thread.comments;
+    const lastComment = comments[comments.length - 1] || {};
+    let hasRobotComment = false;
+    let hasHumanReplyToRobotComment = false;
+    comments.forEach(comment => {
+      if (comment.robot_id) {
+        hasRobotComment = true;
+      } else if (hasRobotComment) {
+        hasHumanReplyToRobotComment = true;
+      }
+    });
 
     return {
       thread,
-      // Use the unresolved bit for the last non draft comment. This is what
-      // anybody other than the current user would see.
-      unresolved: !!lastNonDraftComment.unresolved,
+      hasRobotComment,
+      hasHumanReplyToRobotComment,
+      unresolved: !!lastComment.unresolved,
+      isEditing: !!lastComment.__editing,
       hasDraft: !!lastComment.__draft,
       updated: lastComment.updated || lastComment.__date,
     };
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
deleted file mode 100644
index e48fe97..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.js
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    #threads {
-      display: block;
-      padding: var(--spacing-l);
-    }
-    gr-comment-thread {
-      display: block;
-      margin-bottom: var(--spacing-m);
-      max-width: 80ch;
-    }
-    .header {
-      align-items: center;
-      background-color: var(--table-header-background-color);
-      border-bottom: 1px solid var(--border-color);
-      border-top: 1px solid var(--border-color);
-      display: flex;
-      justify-content: left;
-      min-height: 3.2em;
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    .toggleItem.draftToggle {
-      display: none;
-    }
-    .toggleItem.draftToggle.show {
-      display: flex;
-    }
-    .toggleItem {
-      align-items: center;
-      display: flex;
-      margin-right: var(--spacing-l);
-    }
-    .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
-    .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
-    .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
-      display: block;
-    }
-  </style>
-  <template is="dom-if" if="[[!hideToggleButtons]]">
-    <div class="header">
-      <div class="toggleItem">
-        <paper-toggle-button
-          id="unresolvedToggle"
-          checked="{{_unresolvedOnly}}"
-          on-tap="_onTapUnresolvedToggle"
-        ></paper-toggle-button>
-        Only unresolved threads
-      </div>
-      <div
-        class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]"
-      >
-        <paper-toggle-button
-          id="draftToggle"
-          checked="{{_draftsOnly}}"
-          on-tap="_onTapUnresolvedToggle"
-        ></paper-toggle-button>
-        Only threads with drafts
-      </div>
-    </div>
-  </template>
-  <div id="threads">
-    <template is="dom-if" if="[[!threads.length]]">
-      [[emptyThreadMsg]]
-    </template>
-    <template
-      is="dom-repeat"
-      items="[[_filteredThreads]]"
-      as="thread"
-      initial-count="5"
-      target-framerate="60"
-    >
-      <gr-comment-thread
-        show-file-path=""
-        change-num="[[changeNum]]"
-        comments="[[thread.comments]]"
-        comment-side="[[thread.commentSide]]"
-        project-name="[[change.project]]"
-        is-on-parent="[[_isOnParent(thread.commentSide)]]"
-        line-num="[[thread.line]]"
-        patch-num="[[thread.patchNum]]"
-        path="[[thread.path]]"
-        root-id="{{thread.rootId}}"
-        on-thread-changed="_handleCommentsChanged"
-        on-thread-discard="_handleThreadDiscard"
-      ></gr-comment-thread>
-    </template>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
new file mode 100644
index 0000000..d74c985
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -0,0 +1,122 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    #threads {
+      display: block;
+      padding: var(--spacing-l);
+    }
+    gr-comment-thread {
+      display: block;
+      margin-bottom: var(--spacing-m);
+      max-width: 80ch;
+    }
+    .header {
+      align-items: center;
+      background-color: var(--table-header-background-color);
+      border-bottom: 1px solid var(--border-color);
+      border-top: 1px solid var(--border-color);
+      display: flex;
+      justify-content: left;
+      min-height: 3.2em;
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    .toggleItem.draftToggle {
+      display: none;
+    }
+    .toggleItem.draftToggle.show {
+      display: flex;
+    }
+    .toggleItem {
+      align-items: center;
+      display: flex;
+      margin-right: var(--spacing-l);
+    }
+    .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
+    .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
+    .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] {
+      display: block;
+    }
+    .thread-separator {
+      border-top: 1px solid var(--border-color);
+      margin-top: var(--spacing-xl);
+    }
+  </style>
+  <template is="dom-if" if="[[!hideToggleButtons]]">
+    <div class="header">
+      <div class="toggleItem">
+        <paper-toggle-button
+          id="unresolvedToggle"
+          checked="{{_unresolvedOnly}}"
+          on-tap="_onTapUnresolvedToggle"
+          >Only unresolved threads</paper-toggle-button
+        >
+      </div>
+      <div
+        class$="toggleItem draftToggle [[_computeShowDraftToggle(loggedIn)]]"
+      >
+        <paper-toggle-button
+          id="draftToggle"
+          checked="{{_draftsOnly}}"
+          on-tap="_onTapUnresolvedToggle"
+          >Only threads with drafts</paper-toggle-button
+        >
+      </div>
+    </div>
+  </template>
+  <div id="threads">
+    <template is="dom-if" if="[[!threads.length]]">
+      [[emptyThreadMsg]]
+    </template>
+    <template
+      is="dom-repeat"
+      items="[[_sortedThreads]]"
+      as="thread"
+      initial-count="10"
+      target-framerate="60"
+    >
+      <template
+        is="dom-if"
+        if="[[_shouldShowThread(thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+      >
+        <template
+          is="dom-if"
+          if="[[_shouldRenderSeparator(_sortedThreads, thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+        >
+          <div class="thread-separator"></div>
+        </template>
+        <gr-comment-thread
+          show-file-path=""
+          change-num="[[changeNum]]"
+          comments="[[thread.comments]]"
+          comment-side="[[thread.commentSide]]"
+          show-file-name="[[_isFirstThreadWithFileName(_sortedThreads, thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+          project-name="[[change.project]]"
+          is-on-parent="[[_isOnParent(thread.commentSide)]]"
+          line-num="[[thread.line]]"
+          patch-num="[[thread.patchNum]]"
+          path="[[thread.path]]"
+          root-id="{{thread.rootId}}"
+          on-thread-changed="_handleCommentsChanged"
+          on-thread-discard="_handleThreadDiscard"
+        ></gr-comment-thread>
+      </template>
+    </template>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
deleted file mode 100644
index 4b00d5a..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.html
+++ /dev/null
@@ -1,404 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-thread-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-thread-list></gr-thread-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-thread-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {NO_THREADS_MSG} from '../../../constants/messages.js';
-suite('gr-thread-list tests', () => {
-  let element;
-  let sandbox;
-  let threadElements;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    element.onlyShowRobotCommentsWithHumanReply = true;
-    element.threads = [
-      {
-        comments: [
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'ecf0b9fa_fe1a5f62',
-            line: 5,
-            updated: '2018-02-08 18:49:18.000000000',
-            message: 'test',
-            unresolved: true,
-          },
-          {
-            id: '503008e2_0ab203ee',
-            path: '/COMMIT_MSG',
-            line: 5,
-            in_reply_to: 'ecf0b9fa_fe1a5f62',
-            updated: '2018-02-13 22:48:48.018000000',
-            message: 'draft',
-            unresolved: false,
-            __draft: true,
-            __draftID: '0.m683trwff68',
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 5,
-        rootId: 'ecf0b9fa_fe1a5f62',
-        start_datetime: '2018-02-08 18:49:18.000000000',
-      },
-      {
-        comments: [
-          {
-            __path: 'test.txt',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 3,
-            id: '09a9fb0a_1484e6cf',
-            side: 'PARENT',
-            updated: '2018-02-13 22:47:19.000000000',
-            message: 'Some comment on another patchset.',
-            unresolved: false,
-          },
-        ],
-        patchNum: 3,
-        path: 'test.txt',
-        rootId: '09a9fb0a_1484e6cf',
-        start_datetime: '2018-02-13 22:47:19.000000000',
-        commentSide: 'PARENT',
-      },
-      {
-        comments: [
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 2,
-            id: '8caddf38_44770ec1',
-            updated: '2018-02-13 22:48:40.000000000',
-            message: 'Another unresolved comment',
-            unresolved: true,
-          },
-        ],
-        patchNum: 2,
-        path: '/COMMIT_MSG',
-        rootId: '8caddf38_44770ec1',
-        start_datetime: '2018-02-13 22:48:40.000000000',
-      },
-      {
-        comments: [
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 2,
-            id: 'scaddf38_44770ec1',
-            line: 4,
-            updated: '2018-02-14 22:48:40.000000000',
-            message: 'Yet another unresolved comment',
-            unresolved: true,
-          },
-        ],
-        patchNum: 2,
-        path: '/COMMIT_MSG',
-        line: 4,
-        rootId: 'scaddf38_44770ec1',
-        start_datetime: '2018-02-14 22:48:40.000000000',
-      },
-      {
-        comments: [
-          {
-            id: 'zcf0b9fa_fe1a5f62',
-            path: '/COMMIT_MSG',
-            line: 6,
-            updated: '2018-02-15 22:48:48.018000000',
-            message: 'resolved draft',
-            unresolved: false,
-            __draft: true,
-            __draftID: '0.m683trwff68',
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 6,
-        rootId: 'zcf0b9fa_fe1a5f62',
-        start_datetime: '2018-02-09 18:49:18.000000000',
-      },
-      {
-        comments: [
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'rc1',
-            line: 5,
-            updated: '2019-02-08 18:49:18.000000000',
-            message: 'test',
-            unresolved: true,
-            robot_id: 'rc1',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 5,
-        rootId: 'rc1',
-        start_datetime: '2019-02-08 18:49:18.000000000',
-      },
-      {
-        comments: [
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'rc2',
-            line: 7,
-            updated: '2019-03-08 18:49:18.000000000',
-            message: 'test',
-            unresolved: true,
-            robot_id: 'rc2',
-          },
-          {
-            __path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'c2_1',
-            line: 5,
-            updated: '2019-03-08 18:49:18.000000000',
-            message: 'test',
-            unresolved: true,
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 7,
-        rootId: 'rc2',
-        start_datetime: '2019-03-08 18:49:18.000000000',
-      },
-    ];
-    flushAsynchronousOperations();
-    threadElements = dom(element.root)
-        .querySelectorAll('gr-comment-thread');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('draft toggle only appears when logged in', () => {
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.draftToggle')).display,
-    'none');
-    element.loggedIn = true;
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.draftToggle')).display,
-    'none');
-  });
-
-  test('there are five threads by default', () => {
-    assert.equal(dom(element.root)
-        .querySelectorAll('gr-comment-thread').length, 5);
-  });
-
-  test('_computeSortedThreads', () => {
-    assert.equal(element._sortedThreads.length, 7);
-    // Draft and unresolved for commit-msg at line 5
-    assert.equal(element._sortedThreads[0].thread.rootId,
-        'ecf0b9fa_fe1a5f62');
-    // /COMMIT_MSG
-    // unresolved no draft and file level
-    assert.equal(element._sortedThreads[1].thread.rootId,
-        '8caddf38_44770ec1');
-    // unresolved no draft at line 4
-    assert.equal(element._sortedThreads[2].thread.rootId,
-        'scaddf38_44770ec1');
-    // unresolved no draft at line 5
-    assert.equal(element._sortedThreads[3].thread.rootId,
-        'rc1');
-    // Unresolved no draft at line 7
-    assert.equal(element._sortedThreads[4].thread.rootId,
-        'rc2');
-    // resolved and draft on COMMIT_MSG
-    assert.equal(element._sortedThreads[5].thread.rootId,
-        'zcf0b9fa_fe1a5f62');
-    // resolved and on file test.txt
-    assert.equal(element._sortedThreads[6].thread.rootId,
-        '09a9fb0a_1484e6cf');
-  });
-
-  test('filtered threads do not contain robot comments without reply', () => {
-    const thread = element.threads.find(thread => thread.rootId === 'rc1');
-    assert.equal(element._filteredThreads.includes(thread), false);
-  });
-
-  test('filtered threads contains robot comments with reply', () => {
-    const thread = element.threads.find(thread => thread.rootId === 'rc2');
-    assert.equal(element._filteredThreads.includes(thread), true);
-  });
-
-  test('thread removal', () => {
-    threadElements[1].dispatchEvent(
-        new CustomEvent('thread-discard', {
-          detail: {rootId: 'rc2'},
-          composed: true, bubbles: true,
-        }));
-    flushAsynchronousOperations();
-    assert.equal(element._sortedThreads.length, 6);
-    assert.equal(element._sortedThreads[0].thread.rootId,
-        'ecf0b9fa_fe1a5f62');
-    // /COMMIT_MSG
-    // unresolved no draft and file level
-    assert.equal(element._sortedThreads[1].thread.rootId,
-        '8caddf38_44770ec1');
-    // unresolved no draft at line 4
-    assert.equal(element._sortedThreads[2].thread.rootId,
-        'scaddf38_44770ec1');
-    // unresolved no draft at line 5
-    assert.equal(element._sortedThreads[3].thread.rootId,
-        'rc1');
-    // resolved and draft
-    assert.equal(element._sortedThreads[4].thread.rootId,
-        'zcf0b9fa_fe1a5f62');
-    // resolved and on file test.txt
-    assert.equal(element._sortedThreads[5].thread.rootId,
-        '09a9fb0a_1484e6cf');
-  });
-
-  test('toggle unresolved only shows unresolved comments', () => {
-    MockInteractions.tap(element.shadowRoot.querySelector(
-        '#unresolvedToggle'));
-    flushAsynchronousOperations();
-    assert.equal(dom(element.root)
-        .querySelectorAll('gr-comment-thread').length, 5);
-  });
-
-  test('toggle drafts only shows threads with draft comments', () => {
-    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
-    flushAsynchronousOperations();
-    assert.equal(dom(element.root)
-        .querySelectorAll('gr-comment-thread').length, 2);
-  });
-
-  test('toggle drafts and unresolved only shows threads with drafts and ' +
-      'publicly unresolved ', () => {
-    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
-    MockInteractions.tap(element.shadowRoot.querySelector(
-        '#unresolvedToggle'));
-    flushAsynchronousOperations();
-    assert.equal(dom(element.root)
-        .querySelectorAll('gr-comment-thread').length, 2);
-  });
-
-  test('modification events are consumed and displatched', () => {
-    sandbox.spy(element, '_handleCommentsChanged');
-    const dispatchSpy = sandbox.stub();
-    element.addEventListener('thread-list-modified', dispatchSpy);
-    threadElements[0].dispatchEvent(
-        new CustomEvent('thread-changed', {
-          detail: {
-            rootId: 'ecf0b9fa_fe1a5f62', path: '/COMMIT_MSG'},
-          composed: true, bubbles: true,
-        }));
-    assert.isTrue(element._handleCommentsChanged.called);
-    assert.isTrue(dispatchSpy.called);
-    assert.equal(dispatchSpy.lastCall.args[0].detail.rootId,
-        'ecf0b9fa_fe1a5f62');
-    assert.equal(dispatchSpy.lastCall.args[0].detail.path, '/COMMIT_MSG');
-  });
-
-  suite('hideToggleButtons', () => {
-    setup(done => {
-      element.hideToggleButtons = true;
-      flush(() => {
-        done();
-      });
-    });
-
-    test('toggle buttons are hidden', () => {
-      assert.equal(element.shadowRoot.querySelector('.header').style.display,
-          'none');
-    });
-  });
-
-  suite('empty thread', () => {
-    setup(done => {
-      element.threads = [];
-      flush(() => {
-        done();
-      });
-    });
-
-    test('default empty message should show', () => {
-      assert.equal(
-          element.shadowRoot.querySelector('#threads').textContent.trim(),
-          NO_THREADS_MSG
-      );
-    });
-
-    test('can override empty message', () => {
-      element.emptyThreadMsg = 'test';
-      assert.equal(
-          element.shadowRoot.querySelector('#threads').textContent.trim(),
-          'test'
-      );
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
new file mode 100644
index 0000000..df4850c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -0,0 +1,631 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-thread-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {NO_THREADS_MSG} from '../../../constants/messages.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
+
+const basicFixture = fixtureFromElement('gr-thread-list');
+
+suite('gr-thread-list tests', () => {
+  let element;
+
+  let threadElements;
+
+  function getVisibleThreads() {
+    return [...dom(element.root)
+        .querySelectorAll('gr-comment-thread')]
+        .filter(e => e.style.display !== 'none');
+  }
+
+  setup(done => {
+    element = basicFixture.instantiate();
+    element.threads = [
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4,
+            id: 'ecf0b9fa_fe1a5f62',
+            line: 5,
+            updated: '2018-02-08 18:49:18.000000000',
+            message: 'test',
+            unresolved: true,
+          },
+          {
+            id: '503008e2_0ab203ee',
+            path: '/COMMIT_MSG',
+            line: 5,
+            in_reply_to: 'ecf0b9fa_fe1a5f62',
+            updated: '2018-02-13 22:48:48.018000000',
+            message: 'draft',
+            unresolved: true,
+            __draft: true,
+            __draftID: '0.m683trwff68',
+            __editing: false,
+            patch_set: '2',
+          },
+        ],
+        patchNum: 4,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'ecf0b9fa_fe1a5f62',
+        start_datetime: '2018-02-08 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
+            __path: 'test.txt',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 3,
+            id: '09a9fb0a_1484e6cf',
+            side: 'PARENT',
+            updated: '2018-02-13 22:47:19.000000000',
+            message: 'Some comment on another patchset.',
+            unresolved: false,
+          },
+        ],
+        patchNum: 3,
+        path: 'test.txt',
+        rootId: '09a9fb0a_1484e6cf',
+        start_datetime: '2018-02-13 22:47:19.000000000',
+        commentSide: 'PARENT',
+      },
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 2,
+            id: '8caddf38_44770ec1',
+            updated: '2018-02-13 22:48:40.000000000',
+            message: 'Another unresolved comment',
+            unresolved: false,
+          },
+        ],
+        patchNum: 2,
+        path: '/COMMIT_MSG',
+        rootId: '8caddf38_44770ec1',
+        start_datetime: '2018-02-13 22:48:40.000000000',
+      },
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 2,
+            id: 'scaddf38_44770ec1',
+            line: 4,
+            updated: '2018-02-14 22:48:40.000000000',
+            message: 'Yet another unresolved comment',
+            unresolved: true,
+          },
+        ],
+        patchNum: 2,
+        path: '/COMMIT_MSG',
+        line: 4,
+        rootId: 'scaddf38_44770ec1',
+        start_datetime: '2018-02-14 22:48:40.000000000',
+      },
+      {
+        comments: [
+          {
+            id: 'zcf0b9fa_fe1a5f62',
+            path: '/COMMIT_MSG',
+            line: 6,
+            updated: '2018-02-15 22:48:48.018000000',
+            message: 'resolved draft',
+            unresolved: false,
+            __draft: true,
+            __draftID: '0.m683trwff68',
+            __editing: false,
+            patch_set: '2',
+          },
+        ],
+        patchNum: 4,
+        path: '/COMMIT_MSG',
+        line: 6,
+        rootId: 'zcf0b9fa_fe1a5f62',
+        start_datetime: '2018-02-09 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
+            id: 'patchset_level_1',
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2018-02-15 22:48:48.018000000',
+            message: 'patchset comment 1',
+            unresolved: false,
+            __editing: false,
+            patch_set: '2',
+          },
+        ],
+        patchNum: 2,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_1',
+        start_datetime: '2018-02-09 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
+            id: 'patchset_level_2',
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2018-02-15 22:48:48.018000000',
+            message: 'patchset comment 2',
+            unresolved: false,
+            __editing: false,
+            patch_set: '3',
+          },
+        ],
+        patchNum: 3,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_2',
+        start_datetime: '2018-02-09 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4,
+            id: 'rc1',
+            line: 5,
+            updated: '2019-02-08 18:49:18.000000000',
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc1',
+          },
+        ],
+        patchNum: 4,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'rc1',
+        start_datetime: '2019-02-08 18:49:18.000000000',
+      },
+      {
+        comments: [
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4,
+            id: 'rc2',
+            line: 7,
+            updated: '2019-03-08 18:49:18.000000000',
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc2',
+          },
+          {
+            __path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000,
+              name: 'user',
+              username: 'user',
+            },
+            patch_set: 4,
+            id: 'c2_1',
+            line: 5,
+            updated: '2019-03-08 18:49:18.000000000',
+            message: 'test',
+            unresolved: true,
+          },
+        ],
+        patchNum: 4,
+        path: '/COMMIT_MSG',
+        line: 7,
+        rootId: 'rc2',
+        start_datetime: '2019-03-08 18:49:18.000000000',
+      },
+    ];
+
+    // use flush to render all (bypass initial-count set on dom-repeat)
+    flush(() => {
+      threadElements = dom(element.root)
+          .querySelectorAll('gr-comment-thread');
+      done();
+    });
+  });
+
+  test('draft toggle only appears when logged in', () => {
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.draftToggle')).display,
+    'none');
+    element.loggedIn = true;
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.draftToggle')).display,
+    'none');
+  });
+
+  test('show all threads by default', () => {
+    assert.equal(dom(element.root)
+        .querySelectorAll('gr-comment-thread').length, element.threads.length);
+    assert.equal(getVisibleThreads().length, element.threads.length);
+  });
+
+  test('showing file name takes visible threads into account', () => {
+    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
+        element._sortedThreads[2], element._unresolvedOnly, element._draftsOnly,
+        element.onlyShowRobotCommentsWithHumanReply), true);
+    element._unresolvedOnly = true;
+    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
+        element._sortedThreads[2], element._unresolvedOnly, element._draftsOnly,
+        element.onlyShowRobotCommentsWithHumanReply), false);
+  });
+
+  test('onlyShowRobotCommentsWithHumanReply ', () => {
+    element.onlyShowRobotCommentsWithHumanReply = true;
+    flushAsynchronousOperations();
+    assert.equal(
+        getVisibleThreads().length,
+        element.threads.length - 1);
+    assert.isNotOk(getVisibleThreads().find(th => th.rootId === 'rc1'));
+  });
+
+  suite('_compareThreads', () => {
+    test('patchset comes before any other file', () => {
+      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS}};
+      const t2 = {thread: {path: SpecialFilePath.COMMIT_MESSAGE}};
+
+      t1.patchNum = t2.patchNum = 1;
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+
+      // assigning values to properties such that t2 should come first
+      t1.patchNum = 1;
+      t2.patchNum = 2;
+      t1.unresolved = t1.hasDraft = false;
+      t2.unresolved = t2.unresolved = true;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+    });
+
+    test('file path is compared lexicographically', () => {
+      const t1 = {thread: {path: 'a.txt'}};
+      const t2 = {thread: {path: 'b.txt'}};
+      t1.patchNum = t2.patchNum = 1;
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+
+      t1.patchNum = 1;
+      t2.patchNum = 2;
+      t1.unresolved = t1.hasDraft = false;
+      t2.unresolved = t2.unresolved = true;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+    });
+
+    test('patchset comments sorted by reverse patchset', () => {
+      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        patchNum: 1}};
+      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        patchNum: 2}};
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+
+      t1.unresolved = t1.hasDraft = false;
+      t2.unresolved = t2.unresolved = true;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+    });
+
+    test('patchset comments with same patchset picks unresolved first', () => {
+      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        patchNum: 1}, unresolved: true};
+      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        patchNum: 1}, unresolved: false};
+      t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+    });
+
+    test('file level comment before line', () => {
+      const t1 = {thread: {path: 'a.txt', line: 2}};
+      const t2 = {thread: {path: 'a.txt'}};
+      t1.patchNum = t2.patchNum = 1;
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+
+      // give preference to t1 in unresolved/draft properties
+      t1.unresolved = t1.hasDraft = true;
+      t2.unresolved = t2.unresolved = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+    });
+
+    test('comments sorted by line', () => {
+      const t1 = {thread: {path: 'a.txt', line: 2}};
+      const t2 = {thread: {path: 'a.txt', line: 3}};
+      t1.patchNum = t2.patchNum = 1;
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+
+      t1.unresolved = t1.hasDraft = false;
+      t2.unresolved = t2.unresolved = true;
+      assert.equal(element._compareThreads(t1, t2), -1);
+      assert.equal(element._compareThreads(t2, t1), 1);
+    });
+
+    test('comments on same line sorted by reverse patchset', () => {
+      const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1}};
+      const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 2}};
+      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+
+      // give preference to t1 in unresolved/draft properties
+      t1.unresolved = t1.hasDraft = true;
+      t2.unresolved = t2.unresolved = false;
+      assert.equal(element._compareThreads(t1, t2), 1);
+      assert.equal(element._compareThreads(t2, t1), -1);
+    });
+
+    test('comments on same line & patchset sorted by unresolved first',
+        () => {
+          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
+            unresolved: true};
+          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
+            unresolved: false};
+          t1.patchNum = t2.patchNum = 1;
+          assert.equal(element._compareThreads(t1, t2), -1);
+          assert.equal(element._compareThreads(t2, t1), 1);
+
+          t2.hasDraft = true;
+          t1.hasDraft = false;
+          assert.equal(element._compareThreads(t1, t2), -1);
+          assert.equal(element._compareThreads(t2, t1), 1);
+        });
+
+    test('comments on same line & patchset & unresolved sorted by draft',
+        () => {
+          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
+            unresolved: true, hasDraft: false};
+          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
+            unresolved: true, hasDraft: true};
+          t1.patchNum = t2.patchNum = 1;
+          assert.equal(element._compareThreads(t1, t2), 1);
+          assert.equal(element._compareThreads(t2, t1), -1);
+        });
+  });
+
+  test('_computeSortedThreads', () => {
+    assert.equal(element._sortedThreads.length, 9);
+    const expectedSortedRootIds = [
+      'patchset_level_2', // Posted on Patchset 3
+      'patchset_level_1', // Posted on Patchset 2
+      '8caddf38_44770ec1', // File level on COMMIT_MSG
+      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
+      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
+      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
+      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
+      'rc2', // Line 7 on COMMIT_MSG
+      '09a9fb0a_1484e6cf', // File level on test.txt
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
+  });
+
+  test('thread removal and sort again', () => {
+    threadElements[1].dispatchEvent(
+        new CustomEvent('thread-discard', {
+          detail: {rootId: 'rc2'},
+          composed: true, bubbles: true,
+        }));
+    flushAsynchronousOperations();
+    assert.equal(element._sortedThreads.length, 8);
+    const expectedSortedRootIds = [
+      'patchset_level_2',
+      'patchset_level_1',
+      '8caddf38_44770ec1', // File level on COMMIT_MSG
+      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
+      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
+      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
+      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
+      '09a9fb0a_1484e6cf', // File level on test.txt
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
+  });
+
+  test('modification on thread shold not trigger sort again', () => {
+    const currentSortedThreads = [...element._sortedThreads];
+    for (const thread of currentSortedThreads) {
+      thread.comments = [...thread.comments];
+    }
+    const modifiedThreads = [...element.threads];
+    modifiedThreads[5] = {...modifiedThreads[5]};
+    modifiedThreads[5].comments = [...modifiedThreads[5].comments, {
+      ...modifiedThreads[5].comments[0],
+      unresolved: false,
+    }];
+    element.threads = modifiedThreads;
+    assert.notDeepEqual(currentSortedThreads, element._sortedThreads);
+
+    // exact same order as in _computeSortedThreads
+    const expectedSortedRootIds = [
+      'patchset_level_2',
+      'patchset_level_1',
+      '8caddf38_44770ec1', // File level on COMMIT_MSG
+      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
+      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
+      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
+      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
+      'rc2', // Line 7 on COMMIT_MSG
+      '09a9fb0a_1484e6cf', // File level on test.txt
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
+  });
+
+  test('reset sortedThreads when threads set to undefiend', () => {
+    element.threads = undefined;
+    assert.deepEqual(element._sortedThreads, []);
+  });
+
+  test('non-equal length of sortThreads and threads' +
+    ' should trigger sort again', () => {
+    const modifiedThreads = [...element.threads];
+    const currentSortedThreads = [...element._sortedThreads];
+    element._sortedThreads = [];
+    element.threads = modifiedThreads;
+    assert.deepEqual(currentSortedThreads, element._sortedThreads);
+
+    // exact same order as in _computeSortedThreads
+    const expectedSortedRootIds = [
+      'patchset_level_2',
+      'patchset_level_1',
+      '8caddf38_44770ec1', // File level on COMMIT_MSG
+      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
+      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
+      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
+      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
+      'rc2', // Line 7 on COMMIT_MSG
+      '09a9fb0a_1484e6cf', // File level on test.txt
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
+  });
+
+  test('toggle unresolved only shows unresolved comments', () => {
+    MockInteractions.tap(element.shadowRoot.querySelector(
+        '#unresolvedToggle'));
+    flushAsynchronousOperations();
+    assert.equal(getVisibleThreads().length, 4);
+  });
+
+  test('toggle drafts only shows threads with draft comments', () => {
+    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
+    flushAsynchronousOperations();
+    assert.equal(getVisibleThreads().length, 2);
+  });
+
+  test('toggle drafts and unresolved should ignore comments in editing', () => {
+    const modifiedThreads = [...element.threads];
+    modifiedThreads[5] = {...modifiedThreads[5]};
+    modifiedThreads[5].comments = [...modifiedThreads[5].comments];
+    modifiedThreads[5].comments.push({
+      ...modifiedThreads[5].comments[0],
+      __editing: true,
+    });
+    element.threads = modifiedThreads;
+    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
+    MockInteractions.tap(element.shadowRoot.querySelector(
+        '#unresolvedToggle'));
+    flushAsynchronousOperations();
+    assert.equal(getVisibleThreads().length, 2);
+  });
+
+  test('toggle drafts and unresolved only shows threads with drafts and ' +
+      'publicly unresolved ', () => {
+    MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
+    MockInteractions.tap(element.shadowRoot.querySelector(
+        '#unresolvedToggle'));
+    flushAsynchronousOperations();
+    assert.equal(getVisibleThreads().length, 1);
+  });
+
+  test('modification events are consumed and displatched', () => {
+    sinon.spy(element, '_handleCommentsChanged');
+    const dispatchSpy = sinon.stub();
+    element.addEventListener('thread-list-modified', dispatchSpy);
+    threadElements[0].dispatchEvent(
+        new CustomEvent('thread-changed', {
+          detail: {
+            rootId: 'ecf0b9fa_fe1a5f62', path: '/COMMIT_MSG'},
+          composed: true, bubbles: true,
+        }));
+    assert.isTrue(element._handleCommentsChanged.called);
+    assert.isTrue(dispatchSpy.called);
+    assert.equal(dispatchSpy.lastCall.args[0].detail.rootId,
+        'ecf0b9fa_fe1a5f62');
+    assert.equal(dispatchSpy.lastCall.args[0].detail.path, '/COMMIT_MSG');
+  });
+
+  suite('hideToggleButtons', () => {
+    setup(done => {
+      element.hideToggleButtons = true;
+      flush(() => {
+        done();
+      });
+    });
+
+    test('toggle buttons are hidden', () => {
+      assert.equal(element.shadowRoot.querySelector('.header').style.display,
+          'none');
+    });
+  });
+
+  suite('empty thread', () => {
+    setup(done => {
+      element.threads = [];
+      flush(() => {
+        done();
+      });
+    });
+
+    test('default empty message should show', () => {
+      assert.equal(
+          element.shadowRoot.querySelector('#threads').textContent.trim(),
+          NO_THREADS_MSG
+      );
+    });
+
+    test('can override empty message', () => {
+      element.emptyThreadMsg = 'test';
+      assert.equal(
+          element.shadowRoot.querySelector('#threads').textContent.trim(),
+          'test'
+      );
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
index 9171908..15319e6 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-dialog/gr-dialog.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../shared/gr-shell-command/gr-shell-command.js';
@@ -36,7 +34,7 @@
 ];
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrUploadHelpDialog extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -104,7 +102,7 @@
       revision,
       preferredDownloadCommand,
       preferredDownloadScheme,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return undefined;
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js
deleted file mode 100644
index ec010a1..0000000
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.js
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      background-color: var(--dialog-background-color);
-      display: block;
-    }
-    .main {
-      width: 100%;
-    }
-    ol {
-      margin-left: var(--spacing-xl);
-      list-style: decimal;
-    }
-    p {
-      margin-bottom: var(--spacing-m);
-    }
-  </style>
-  <gr-dialog confirm-label="Done" cancel-label="" on-confirm="_handleCloseTap">
-    <div class="header" slot="header">How to update this change:</div>
-    <div class="main" slot="main">
-      <ol>
-        <li>
-          <p>
-            Checkout this change locally and make your desired modifications to
-            the files.
-          </p>
-          <template is="dom-if" if="[[_fetchCommand]]">
-            <gr-shell-command command="[[_fetchCommand]]"></gr-shell-command>
-          </template>
-        </li>
-        <li>
-          <p>
-            Update the local commit with your modifications using the following
-            command.
-          </p>
-          <gr-shell-command command="[[_commitCommand]]"></gr-shell-command>
-          <p>
-            Leave the "Change-Id:" line of the commit message as is.
-          </p>
-        </li>
-        <li>
-          <p>Push the updated commit to Gerrit.</p>
-          <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
-        </li>
-        <li>
-          <p>Refresh this page to view the the update.</p>
-        </li>
-      </ol>
-    </div>
-  </gr-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts
new file mode 100644
index 0000000..1ee3a3a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_html.ts
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--dialog-background-color);
+      display: block;
+    }
+    .main {
+      width: 100%;
+    }
+    ol {
+      margin-left: var(--spacing-xl);
+      list-style: decimal;
+    }
+    p {
+      margin-bottom: var(--spacing-m);
+    }
+  </style>
+  <gr-dialog confirm-label="Done" cancel-label="" on-confirm="_handleCloseTap">
+    <div class="header" slot="header">How to update this change:</div>
+    <div class="main" slot="main">
+      <ol>
+        <li>
+          <p>
+            Checkout this change locally and make your desired modifications to
+            the files.
+          </p>
+          <template is="dom-if" if="[[_fetchCommand]]">
+            <gr-shell-command command="[[_fetchCommand]]"></gr-shell-command>
+          </template>
+        </li>
+        <li>
+          <p>
+            Update the local commit with your modifications using the following
+            command.
+          </p>
+          <gr-shell-command command="[[_commitCommand]]"></gr-shell-command>
+          <p>
+            Leave the "Change-Id:" line of the commit message as is.
+          </p>
+        </li>
+        <li>
+          <p>Push the updated commit to Gerrit.</p>
+          <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
+        </li>
+        <li>
+          <p>Refresh this page to view the the update.</p>
+        </li>
+      </ol>
+    </div>
+  </gr-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
deleted file mode 100644
index 164c483..0000000
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.html
+++ /dev/null
@@ -1,124 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-upload-help-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-upload-help-dialog></gr-upload-help-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-upload-help-dialog.js';
-suite('gr-upload-help-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('constructs push command from branch', () => {
-    element.targetBranch = 'foo';
-    assert.equal(element._pushCommand, 'git push origin HEAD:refs/for/foo');
-
-    element.targetBranch = 'master';
-    assert.equal(element._pushCommand,
-        'git push origin HEAD:refs/for/master');
-  });
-
-  suite('fetch command', () => {
-    const testRev = {
-      fetch: {
-        http: {
-          commands: {
-            Checkout: 'http checkout',
-            Pull: 'http pull',
-          },
-        },
-        ssh: {
-          commands: {
-            Pull: 'ssh pull',
-          },
-        },
-      },
-    };
-
-    test('null cases', () => {
-      assert.isUndefined(element._computeFetchCommand());
-      assert.isUndefined(element._computeFetchCommand({}));
-      assert.isUndefined(element._computeFetchCommand({fetch: null}));
-      assert.isUndefined(element._computeFetchCommand({fetch: {}}));
-    });
-
-    test('not all defined', () => {
-      assert.isUndefined(
-          element._computeFetchCommand(testRev, undefined, ''));
-      assert.isUndefined(
-          element._computeFetchCommand(testRev, '', undefined));
-      assert.isUndefined(
-          element._computeFetchCommand(undefined, '', ''));
-    });
-
-    test('insufficiently defined scheme', () => {
-      assert.isUndefined(
-          element._computeFetchCommand(testRev, '', 'badscheme'));
-
-      const rev = Object.assign({}, testRev);
-      rev.fetch = Object.assign({}, testRev.fetch, {nocmds: {commands: {}}});
-      assert.isUndefined(
-          element._computeFetchCommand(rev, '', 'nocmds'));
-
-      rev.fetch.nocmds.commands.unsupported = 'unsupported';
-      assert.isUndefined(
-          element._computeFetchCommand(rev, '', 'nocmds'));
-    });
-
-    test('default scheme and command', () => {
-      const cmd = element._computeFetchCommand(testRev, '', '');
-      assert.isTrue(cmd === 'http checkout' || cmd === 'ssh pull');
-    });
-
-    test('default command', () => {
-      assert.strictEqual(
-          element._computeFetchCommand(testRev, '', 'http'),
-          'http checkout');
-      assert.strictEqual(
-          element._computeFetchCommand(testRev, '', 'ssh'),
-          'ssh pull');
-    });
-
-    test('user preferred scheme and command', () => {
-      assert.strictEqual(
-          element._computeFetchCommand(testRev, 'PULL', 'http'),
-          'http pull');
-      assert.strictEqual(
-          element._computeFetchCommand(testRev, 'badcmd', 'http'),
-          'http checkout');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.js
new file mode 100644
index 0000000..2d1da92
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.js
@@ -0,0 +1,110 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-upload-help-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-upload-help-dialog');
+
+suite('gr-upload-help-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('constructs push command from branch', () => {
+    element.targetBranch = 'foo';
+    assert.equal(element._pushCommand, 'git push origin HEAD:refs/for/foo');
+
+    element.targetBranch = 'master';
+    assert.equal(element._pushCommand,
+        'git push origin HEAD:refs/for/master');
+  });
+
+  suite('fetch command', () => {
+    const testRev = {
+      fetch: {
+        http: {
+          commands: {
+            Checkout: 'http checkout',
+            Pull: 'http pull',
+          },
+        },
+        ssh: {
+          commands: {
+            Pull: 'ssh pull',
+          },
+        },
+      },
+    };
+
+    test('null cases', () => {
+      assert.isUndefined(element._computeFetchCommand());
+      assert.isUndefined(element._computeFetchCommand({}));
+      assert.isUndefined(element._computeFetchCommand({fetch: null}));
+      assert.isUndefined(element._computeFetchCommand({fetch: {}}));
+    });
+
+    test('not all defined', () => {
+      assert.isUndefined(
+          element._computeFetchCommand(testRev, undefined, ''));
+      assert.isUndefined(
+          element._computeFetchCommand(testRev, '', undefined));
+      assert.isUndefined(
+          element._computeFetchCommand(undefined, '', ''));
+    });
+
+    test('insufficiently defined scheme', () => {
+      assert.isUndefined(
+          element._computeFetchCommand(testRev, '', 'badscheme'));
+
+      const rev = {...testRev};
+      rev.fetch = {...testRev.fetch, nocmds: {commands: {}}};
+      assert.isUndefined(
+          element._computeFetchCommand(rev, '', 'nocmds'));
+
+      rev.fetch.nocmds.commands.unsupported = 'unsupported';
+      assert.isUndefined(
+          element._computeFetchCommand(rev, '', 'nocmds'));
+    });
+
+    test('default scheme and command', () => {
+      const cmd = element._computeFetchCommand(testRev, '', '');
+      assert.isTrue(cmd === 'http checkout' || cmd === 'ssh pull');
+    });
+
+    test('default command', () => {
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, '', 'http'),
+          'http checkout');
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, '', 'ssh'),
+          'ssh pull');
+    });
+
+    test('user preferred scheme and command', () => {
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, 'PULL', 'http'),
+          'http pull');
+      assert.strictEqual(
+          element._computeFetchCommand(testRev, 'badcmd', 'http'),
+          'http checkout');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index 2645c63..7608137 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -14,29 +14,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-dropdown/gr-dropdown.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-avatar/gr-avatar.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-account-dropdown_html.js';
-import {DisplayNameBehavior} from '../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.js';
+import {getUserName} from '../../../utils/display-name-util.js';
 
-const INTERPOLATE_URL_PATTERN = /\$\{([\w]+)\}/g;
+const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrAccountDropdown extends mixinBehaviors( [
-  DisplayNameBehavior,
-], GestureEventListeners(
+class GrAccountDropdown extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-account-dropdown'; }
@@ -87,11 +83,13 @@
 
   _getLinks(switchAccountUrl, path) {
     // Polymer 2: check for undefined
-    if ([switchAccountUrl, path].some(arg => arg === undefined)) {
+    if ([switchAccountUrl, path].includes(undefined)) {
       return undefined;
     }
 
-    const links = [{name: 'Settings', url: '/settings/'}];
+    const links = [];
+    links.push({name: 'Settings', url: '/settings/'});
+    links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
     if (switchAccountUrl) {
       const replacements = {path};
       const url = this._interpolateUrl(switchAccountUrl, replacements);
@@ -108,6 +106,11 @@
     ];
   }
 
+  _handleShortcutsTap(e) {
+    this.dispatchEvent(new CustomEvent('show-keyboard-shortcuts',
+        {bubbles: true, composed: true}));
+  }
+
   _handleLocationChange() {
     this._path =
         window.location.pathname +
@@ -122,7 +125,7 @@
   }
 
   _accountName(account) {
-    return this.getUserName(this.config, account);
+    return getUserName(this.config, account);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
deleted file mode 100644
index b47894e..0000000
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-dropdown {
-      padding: 0 var(--spacing-m);
-      --gr-button: {
-        color: var(--header-text-color);
-      }
-      --gr-dropdown-item: {
-        color: var(--primary-text-color);
-      }
-    }
-    gr-avatar {
-      height: 2em;
-      width: 2em;
-      vertical-align: middle;
-    }
-  </style>
-  <gr-dropdown
-    link=""
-    items="[[links]]"
-    top-content="[[topContent]]"
-    horizontal-align="right"
-  >
-    <span hidden$="[[_hasAvatars]]" hidden="">[[_accountName(account)]]</span>
-    <gr-avatar
-      account="[[account]]"
-      hidden$="[[!_hasAvatars]]"
-      hidden=""
-      image-size="56"
-      aria-label="Account avatar"
-    ></gr-avatar>
-  </gr-dropdown>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
new file mode 100644
index 0000000..b67e1e8
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    gr-dropdown {
+      padding: 0 var(--spacing-m);
+      --gr-button: {
+        color: var(--header-text-color);
+      }
+      --gr-dropdown-item: {
+        color: var(--primary-text-color);
+      }
+    }
+    gr-avatar {
+      height: 2em;
+      width: 2em;
+      vertical-align: middle;
+    }
+  </style>
+  <gr-dropdown
+    link=""
+    items="[[links]]"
+    top-content="[[topContent]]"
+    on-tap-item-shortcuts="_handleShortcutsTap"
+    horizontal-align="right"
+  >
+    <span hidden$="[[_hasAvatars]]" hidden="">[[_accountName(account)]]</span>
+    <gr-avatar
+      account="[[account]]"
+      hidden$="[[!_hasAvatars]]"
+      hidden=""
+      image-size="56"
+      aria-label="Account avatar"
+    ></gr-avatar>
+  </gr-dropdown>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
deleted file mode 100644
index 6c8ed68..0000000
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ /dev/null
@@ -1,124 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-account-dropdown</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-account-dropdown></gr-account-dropdown>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-account-dropdown.js';
-suite('gr-account-dropdown tests', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
-    element = fixture('basic');
-  });
-
-  test('account information', () => {
-    element.account = {name: 'John Doe', email: 'john@doe.com'};
-    assert.deepEqual(element.topContent,
-        [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
-  });
-
-  test('test for account without a name', () => {
-    element.account = {id: '0001'};
-    assert.deepEqual(element.topContent,
-        [{text: 'Anonymous', bold: true}, {text: ''}]);
-  });
-
-  test('test for account without a name but using config', () => {
-    element.config = {
-      user: {
-        anonymous_coward_name: 'WikiGerrit',
-      },
-    };
-    element.account = {id: '0001'};
-    assert.deepEqual(element.topContent,
-        [{text: 'WikiGerrit', bold: true}, {text: ''}]);
-  });
-
-  test('test for account name as an email', () => {
-    element.config = {
-      user: {
-        anonymous_coward_name: 'WikiGerrit',
-      },
-    };
-    element.account = {email: 'john@doe.com'};
-    assert.deepEqual(element.topContent,
-        [{text: 'john@doe.com', bold: true}, {text: 'john@doe.com'}]);
-  });
-
-  test('switch account', () => {
-    // Missing params.
-    assert.isUndefined(element._getLinks());
-    assert.isUndefined(element._getLinks(null));
-
-    // No switch account link.
-    assert.equal(element._getLinks(null, '').length, 2);
-
-    // Unparameterized switch account link.
-    let links = element._getLinks('/switch-account', '');
-    assert.equal(links.length, 3);
-    assert.deepEqual(links[1], {
-      name: 'Switch account',
-      url: '/switch-account',
-      external: true,
-    });
-
-    // Parameterized switch account link.
-    links = element._getLinks('/switch-account${path}', '/c/123');
-    assert.equal(links.length, 3);
-    assert.deepEqual(links[1], {
-      name: 'Switch account',
-      url: '/switch-account/c/123',
-      external: true,
-    });
-  });
-
-  test('_interpolateUrl', () => {
-    const replacements = {
-      foo: 'bar',
-      test: 'TEST',
-    };
-    const interpolate = function(url) {
-      return element._interpolateUrl(url, replacements);
-    };
-
-    assert.equal(interpolate('test'), 'test');
-    assert.equal(interpolate('${test}'), 'TEST');
-    assert.equal(
-        interpolate('${}, ${test}, ${TEST}, ${foo}'),
-        '${}, TEST, , bar');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
new file mode 100644
index 0000000..a8f206c
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
@@ -0,0 +1,110 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-account-dropdown.js';
+
+const basicFixture = fixtureFromElement('gr-account-dropdown');
+
+suite('gr-account-dropdown tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('account information', () => {
+    element.account = {name: 'John Doe', email: 'john@doe.com'};
+    assert.deepEqual(element.topContent,
+        [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
+  });
+
+  test('test for account without a name', () => {
+    element.account = {id: '0001'};
+    assert.deepEqual(element.topContent,
+        [{text: 'Anonymous', bold: true}, {text: ''}]);
+  });
+
+  test('test for account without a name but using config', () => {
+    element.config = {
+      user: {
+        anonymous_coward_name: 'WikiGerrit',
+      },
+    };
+    element.account = {id: '0001'};
+    assert.deepEqual(element.topContent,
+        [{text: 'WikiGerrit', bold: true}, {text: ''}]);
+  });
+
+  test('test for account name as an email', () => {
+    element.config = {
+      user: {
+        anonymous_coward_name: 'WikiGerrit',
+      },
+    };
+    element.account = {email: 'john@doe.com'};
+    assert.deepEqual(element.topContent,
+        [{text: 'john@doe.com', bold: true}, {text: 'john@doe.com'}]);
+  });
+
+  test('switch account', () => {
+    // Missing params.
+    assert.isUndefined(element._getLinks());
+    assert.isUndefined(element._getLinks(null));
+
+    // No switch account link.
+    assert.equal(element._getLinks(null, '').length, 3);
+
+    // Unparameterized switch account link.
+    let links = element._getLinks('/switch-account', '');
+    assert.equal(links.length, 4);
+    assert.deepEqual(links[2], {
+      name: 'Switch account',
+      url: '/switch-account',
+      external: true,
+    });
+
+    // Parameterized switch account link.
+    links = element._getLinks('/switch-account${path}', '/c/123');
+    assert.equal(links.length, 4);
+    assert.deepEqual(links[2], {
+      name: 'Switch account',
+      url: '/switch-account/c/123',
+      external: true,
+    });
+  });
+
+  test('_interpolateUrl', () => {
+    const replacements = {
+      foo: 'bar',
+      test: 'TEST',
+    };
+    const interpolate = function(url) {
+      return element._interpolateUrl(url, replacements);
+    };
+
+    assert.equal(interpolate('test'), 'test');
+    assert.equal(interpolate('${test}'), 'TEST');
+    assert.equal(
+        interpolate('${}, ${test}, ${TEST}, ${foo}'),
+        '${}, TEST, , bar');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
index 6814d89..99c4cb3 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-dialog/gr-dialog.js';
 import '../../../styles/shared-styles.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
@@ -23,7 +21,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-error-dialog_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrErrorDialog extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.js
deleted file mode 100644
index 39d4f2d..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .main {
-      max-height: 40em;
-      max-width: 60em;
-      overflow-y: auto;
-      white-space: pre-wrap;
-    }
-    @media screen and (max-width: 50em) {
-      .main {
-        max-height: none;
-        max-width: 50em;
-      }
-    }
-    .signInLink {
-      text-decoration: none;
-    }
-  </style>
-  <gr-dialog
-    id="dialog"
-    cancel-label=""
-    on-confirm="_handleConfirm"
-    confirm-label="Dismiss"
-    confirm-on-enter=""
-  >
-    <div class="header" slot="header">An error occurred</div>
-    <div class="main" slot="main">[[text]]</div>
-    <gr-button
-      id="signIn"
-      class$="signInLink"
-      hidden$="[[!showSignInButton]]"
-      link=""
-      slot="footer"
-    >
-      <a href$="[[loginUrl]]" class="signInLink">Sign in</a>
-    </gr-button>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts
new file mode 100644
index 0000000..10476cd
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .main {
+      max-height: 40em;
+      max-width: 60em;
+      overflow-y: auto;
+      white-space: pre-wrap;
+    }
+    @media screen and (max-width: 50em) {
+      .main {
+        max-height: none;
+        max-width: 50em;
+      }
+    }
+    .signInLink {
+      text-decoration: none;
+    }
+  </style>
+  <gr-dialog
+    id="dialog"
+    cancel-label=""
+    on-confirm="_handleConfirm"
+    confirm-label="Dismiss"
+    confirm-on-enter=""
+  >
+    <div class="header" slot="header">An error occurred</div>
+    <div class="main" slot="main">[[text]]</div>
+    <gr-button
+      id="signIn"
+      class$="signInLink"
+      hidden$="[[!showSignInButton]]"
+      link=""
+      slot="footer"
+    >
+      <a href$="[[loginUrl]]" class="signInLink">Sign in</a>
+    </gr-button>
+  </gr-dialog>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
deleted file mode 100644
index bd4991f..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.html
+++ /dev/null
@@ -1,49 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-error-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-error-dialog></gr-error-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-error-dialog.js';
-suite('gr-error-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('dismiss tap fires event', done => {
-    element.addEventListener('dismiss', () => { done(); });
-    MockInteractions.tap(element.$.dialog.$.confirm);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.js
new file mode 100644
index 0000000..ea8f7c5
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.js
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-error-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-error-dialog');
+
+suite('gr-error-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('dismiss tap fires event', done => {
+    element.addEventListener('dismiss', () => { done(); });
+    MockInteractions.tap(element.$.dialog.$.confirm);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 4b5969a..e619eab 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -16,27 +16,17 @@
  */
 /* Import to get Gerrit interface */
 /* TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface */
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
-import '../../../scripts/bundled-polymer.js';
 import '../gr-error-dialog/gr-error-dialog.js';
-import '../gr-reporting/gr-reporting.js';
 import '../../shared/gr-alert/gr-alert.js';
 import '../../shared/gr-overlay/gr-overlay.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-error-manager_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {authService} from '../../shared/gr-rest-api-interface/gr-auth.js';
-import {gerritEventEmitter} from '../../shared/gr-event-emitter/gr-event-emitter.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
+import {appContext} from '../../../services/app-context.js';
 
 const HIDE_ALERT_TIMEOUT_MS = 5000;
 const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
@@ -46,14 +36,27 @@
 const TOO_MANY_FILES = 'too many files to find conflicts';
 const AUTHENTICATION_REQUIRED = 'Authentication required\n';
 
+const ErrorType = {
+  AUTH: 'AUTH',
+  NETWORK: 'NETWORK',
+  GENERIC: 'GENERIC',
+};
+
+// Bigger number has higher priority
+const ErrorTypePriority = {
+  [ErrorType.AUTH]: 3,
+  [ErrorType.NETWORK]: 2,
+  [ErrorType.GENERIC]: 1,
+};
+
+export const __testOnly_ErrorType = ErrorType;
+
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrErrorManager extends mixinBehaviors( [
-  BaseUrlBehavior,
-], GestureEventListeners(
+class GrErrorManager extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-error-manager'; }
@@ -94,10 +97,13 @@
     super();
 
     /** @type {!Auth} */
-    this._authService = authService;
+    this._authService = appContext.authService;
 
     /** @type {?Function} */
     this._authErrorHandlerDeregistrationHook;
+
+    this.reporting = appContext.reportingService;
+    this.eventEmitter = appContext.eventEmitter;
   }
 
   /** @override */
@@ -106,12 +112,13 @@
     this.listen(document, 'server-error', '_handleServerError');
     this.listen(document, 'network-error', '_handleNetworkError');
     this.listen(document, 'show-alert', '_handleShowAlert');
+    this.listen(document, 'hide-alert', '_hideAlert');
     this.listen(document, 'show-error', '_handleShowErrorDialog');
     this.listen(document, 'visibilitychange', '_handleVisibilityChange');
     this.listen(document, 'show-auth-required', '_handleAuthRequired');
 
     this._authErrorHandlerDeregistrationHook =
-      gerritEventEmitter.on('auth-error',
+      this.eventEmitter.on('auth-error',
           event => {
             this._handleAuthError(event.message, event.action);
           });
@@ -123,9 +130,11 @@
     this._clearHideAlertHandle();
     this.unlisten(document, 'server-error', '_handleServerError');
     this.unlisten(document, 'network-error', '_handleNetworkError');
-    this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
-    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+    this.unlisten(document, 'show-alert', '_handleShowAlert');
+    this.unlisten(document, 'hide-alert', '_hideAlert');
     this.unlisten(document, 'show-error', '_handleShowErrorDialog');
+    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+    this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
 
     this._authErrorHandlerDeregistrationHook();
   }
@@ -185,7 +194,7 @@
           }));
         }
       }
-      console.log(`server error: ${errorText}`);
+      console.info(`server error: ${errorText}`);
     });
   }
 
@@ -205,7 +214,6 @@
         showSignInButton: !isLoggedIn,
       });
     });
-    return;
   }
 
   _constructServerErrorMsg({errorText, status, statusText, url, trace, tip}) {
@@ -232,17 +240,24 @@
     console.error(e.detail.error.message);
   }
 
+  // TODO(dhruvsr): allow less priority alerts to override high priority alerts
+  // In some use cases we may want generic alerts to show along/over errors
+  _canOverride(incoming = ErrorType.GENERIC, existing = ErrorType.GENERIC) {
+    return ErrorTypePriority[incoming] >= ErrorTypePriority[existing];
+  }
+
   /**
    * @param {string} text
    * @param {?string=} opt_actionText
    * @param {?Function=} opt_actionCallback
    * @param {?boolean=} opt_dismissOnNavigation
+   * @param {?string=} opt_type
    */
   _showAlert(text, opt_actionText, opt_actionCallback,
-      opt_dismissOnNavigation) {
+      opt_dismissOnNavigation, opt_type) {
     if (this._alertElement) {
-      // do not override auth alerts
-      if (this._alertElement.type === 'AUTH') return;
+      // check priority before hiding
+      if (!this._canOverride(opt_type, this._alertElement.type)) return;
       this._hideAlert();
     }
 
@@ -284,7 +299,7 @@
     }
 
     this._alertElement = this._createToastAlert();
-    this._alertElement.type = 'AUTH';
+    this._alertElement.type = ErrorType.AUTH;
     this._alertElement.show(errorText, actionText,
         this._createLoginPopup.bind(this));
 
@@ -377,7 +392,7 @@
       'left=' + left,
       'top=' + top,
     ];
-    window.open(this.getBaseUrl() +
+    window.open(getBaseUrl() +
         '/login/%3FcloseAfterLogin', '_blank', options.join(','));
     this.listen(window, 'focus', '_handleWindowFocus');
   }
@@ -406,7 +421,7 @@
   }
 
   _showErrorDialog(message, opt_options) {
-    this.$.reporting.reportErrorDialog(message);
+    this.reporting.reportErrorDialog(message);
     this.$.errorDialog.text = message;
     this.$.errorDialog.showSignInButton =
         opt_options && opt_options.showSignInButton;
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.js
deleted file mode 100644
index 4d32f24..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.js
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <gr-overlay with-backdrop="" id="errorOverlay">
-    <gr-error-dialog
-      id="errorDialog"
-      on-dismiss="_handleDismissErrorDialog"
-      confirm-label="Dismiss"
-      confirm-on-enter=""
-      login-url="[[loginUrl]]"
-    ></gr-error-dialog>
-  </gr-overlay>
-  <gr-overlay
-    id="noInteractionOverlay"
-    with-backdrop=""
-    always-on-top=""
-    no-cancel-on-esc-key=""
-    no-cancel-on-outside-click=""
-  >
-  </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-reporting id="reporting"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts
new file mode 100644
index 0000000..1cefb78
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <gr-overlay with-backdrop="" id="errorOverlay">
+    <gr-error-dialog
+      id="errorDialog"
+      on-dismiss="_handleDismissErrorDialog"
+      confirm-label="Dismiss"
+      confirm-on-enter=""
+      login-url="[[loginUrl]]"
+    ></gr-error-dialog>
+  </gr-overlay>
+  <gr-overlay
+    id="noInteractionOverlay"
+    with-backdrop=""
+    always-on-top=""
+    no-cancel-on-esc-key=""
+    no-cancel-on-outside-click=""
+  >
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
deleted file mode 100644
index 8272c6e..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ /dev/null
@@ -1,570 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-error-manager</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-error-manager.js';
-void (0);
-</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-error-manager></gr-error-manager>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-error-manager.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-_testOnly_initGerritPluginApi();
-
-suite('gr-error-manager tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('when authed', () => {
-    setup(() => {
-      sandbox.stub(window, 'fetch')
-          .returns(Promise.resolve({ok: true, status: 204}));
-      element = fixture('basic');
-      element._authService.clearCache();
-    });
-
-    test('does not show auth error on 403 by default', done => {
-      const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
-      const responseText = Promise.resolve('server says no.');
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail:
-          {response: {status: 403, text() { return responseText; }}},
-            composed: true, bubbles: true,
-          }));
-      flush(() => {
-        assert.isFalse(showAuthErrorStub.calledOnce);
-        done();
-      });
-    });
-
-    test('show auth required for 403 with auth error and not authed before',
-        done => {
-          const showAuthErrorStub = sandbox.stub(
-              element, '_showAuthErrorAlert'
-          );
-          const responseText = Promise.resolve('Authentication required\n');
-          sinon.stub(element.$.restAPI, 'getLoggedIn')
-              .returns(Promise.resolve(true));
-          element.dispatchEvent(
-              new CustomEvent('server-error', {
-                detail:
-              {response: {status: 403, text() { return responseText; }}},
-                composed: true, bubbles: true,
-              }));
-          flush(() => {
-            assert.isTrue(showAuthErrorStub.calledOnce);
-            done();
-          });
-        });
-
-    test('recheck auth for 403 with auth error if authed before', done => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
-      const responseText = Promise.resolve('Authentication required\n');
-      sinon.stub(element.$.restAPI, 'getLoggedIn')
-          .returns(Promise.resolve(true));
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail:
-          {response: {status: 403, text() { return responseText; }}},
-            composed: true, bubbles: true,
-          }));
-      flush(() => {
-        assert.isTrue(element.$.restAPI.getLoggedIn.calledOnce);
-        done();
-      });
-    });
-
-    test('show logged in error', () => {
-      sandbox.stub(element, '_showAuthErrorAlert');
-      element.dispatchEvent(
-          new CustomEvent('show-auth-required', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
-          'Log in is required to perform that action.', 'Log in.'));
-    });
-
-    test('show normal Error', done => {
-      const showErrorStub = sandbox.stub(element, '_showErrorDialog');
-      const textSpy = sandbox.spy(() => Promise.resolve('ZOMG'));
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {response: {status: 500, text: textSpy}},
-            composed: true, bubbles: true,
-          }));
-
-      assert.isTrue(textSpy.called);
-      flush(() => {
-        assert.isTrue(showErrorStub.calledOnce);
-        assert.isTrue(showErrorStub.lastCall.calledWithExactly(
-            'Error 500: ZOMG'));
-        done();
-      });
-    });
-
-    test('_constructServerErrorMsg', () => {
-      const errorText = 'change conflicts';
-      const status = 409;
-      const statusText = 'Conflict';
-      const url = '/my/test/url';
-
-      assert.equal(element._constructServerErrorMsg({status}),
-          'Error 409');
-      assert.equal(element._constructServerErrorMsg({status, url}),
-          'Error 409: \nEndpoint: /my/test/url');
-      assert.equal(element.
-          _constructServerErrorMsg({status, statusText, url}),
-      'Error 409 (Conflict): \nEndpoint: /my/test/url');
-      assert.equal(element._constructServerErrorMsg({
-        status,
-        statusText,
-        errorText,
-        url,
-      }), 'Error 409 (Conflict): change conflicts' +
-      '\nEndpoint: /my/test/url');
-      assert.equal(element._constructServerErrorMsg({
-        status,
-        statusText,
-        errorText,
-        url,
-        trace: 'xxxxx',
-      }), 'Error 409 (Conflict): change conflicts' +
-      '\nEndpoint: /my/test/url\nTrace Id: xxxxx');
-    });
-
-    test('extract trace id from headers if exists', done => {
-      const textSpy = sandbox.spy(
-          () => Promise.resolve('500')
-      );
-      const headers = new Headers();
-      headers.set('X-Gerrit-Trace', 'xxxx');
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {
-              response: {
-                headers,
-                status: 500,
-                text: textSpy,
-              },
-            },
-            composed: true, bubbles: true,
-          }));
-      flush(() => {
-        assert.equal(
-            element.$.errorDialog.text,
-            'Error 500: 500\nTrace Id: xxxx'
-        );
-        done();
-      });
-    });
-
-    test('suppress TOO_MANY_FILES error', done => {
-      const showAlertStub = sandbox.stub(element, '_showAlert');
-      const textSpy = sandbox.spy(
-          () => Promise.resolve('too many files to find conflicts')
-      );
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {response: {status: 500, text: textSpy}},
-            composed: true, bubbles: true,
-          }));
-
-      assert.isTrue(textSpy.called);
-      flush(() => {
-        assert.isFalse(showAlertStub.called);
-        done();
-      });
-    });
-
-    test('show network error', done => {
-      const consoleErrorStub = sandbox.stub(console, 'error');
-      const showAlertStub = sandbox.stub(element, '_showAlert');
-      element.dispatchEvent(
-          new CustomEvent('network-error', {
-            detail: {error: new Error('ZOMG')},
-            composed: true, bubbles: true,
-          }));
-      flush(() => {
-        assert.isTrue(showAlertStub.calledOnce);
-        assert.isTrue(showAlertStub.lastCall.calledWithExactly(
-            'Server unavailable'));
-        assert.isTrue(consoleErrorStub.calledOnce);
-        assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
-        done();
-      });
-    });
-
-    test('show auth refresh toast', done => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
-      const refreshStub = sandbox.stub(element.$.restAPI, 'getAccount',
-          () => Promise.resolve({}));
-      const toastSpy = sandbox.spy(element, '_createToastAlert');
-      const windowOpen = sandbox.stub(window, 'open');
-      const responseText = Promise.resolve('Authentication required\n');
-      // fake failed auth
-      window.fetch.returns(Promise.resolve({status: 403}));
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail:
-          {response: {status: 403, text() { return responseText; }}},
-            composed: true, bubbles: true,
-          }));
-      assert.equal(window.fetch.callCount, 1);
-      flush(() => {
-        // here needs two flush as there are two chanined
-        // promises on server-error handler and flush only flushes one
-        assert.equal(window.fetch.callCount, 2);
-        flush(() => {
-          // auth-error fired
-          assert.isTrue(toastSpy.called);
-
-          // toast
-          let toast = toastSpy.lastCall.returnValue;
-          assert.isOk(toast);
-          assert.include(
-              dom(toast.root).textContent, 'Credentials expired.');
-          assert.include(
-              dom(toast.root).textContent, 'Refresh credentials');
-
-          // noInteractionOverlay
-          const noInteractionOverlay = element.$.noInteractionOverlay;
-          assert.isOk(noInteractionOverlay);
-          sinon.spy(noInteractionOverlay, 'close');
-          assert.equal(
-              noInteractionOverlay.backdropElement.getAttribute('opened'),
-              '');
-          assert.isFalse(windowOpen.called);
-          MockInteractions.tap(toast.shadowRoot
-              .querySelector('gr-button.action'));
-          assert.isTrue(windowOpen.called);
-
-          // @see Issue 5822: noopener breaks closeAfterLogin
-          assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
-              -1);
-
-          const hideToastSpy = sandbox.spy(toast, 'hide');
-
-          // now fake authed
-          window.fetch.returns(Promise.resolve({status: 204}));
-          element._handleWindowFocus();
-          element.flushDebouncer('checkLoggedIn');
-          flush(() => {
-            assert.isTrue(refreshStub.called);
-            assert.isTrue(hideToastSpy.called);
-
-            // toast update
-            assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
-            toast = toastSpy.lastCall.returnValue;
-            assert.isOk(toast);
-            assert.include(
-                dom(toast.root).textContent, 'Credentials refreshed');
-
-            // close overlay
-            assert.isTrue(noInteractionOverlay.close.called);
-            done();
-          });
-        });
-      });
-    });
-
-    test('auth toast should dismiss existing toast', done => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
-      const toastSpy = sandbox.spy(element, '_createToastAlert');
-      const responseText = Promise.resolve('Authentication required\n');
-
-      // fake an alert
-      element.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: 'test reload', action: 'reload'},
-            composed: true, bubbles: true,
-          }));
-      const toast = toastSpy.lastCall.returnValue;
-      assert.isOk(toast);
-      assert.include(
-          dom(toast.root).textContent, 'test reload');
-
-      // fake auth
-      window.fetch.returns(Promise.resolve({status: 403}));
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail:
-          {response: {status: 403, text() { return responseText; }}},
-            composed: true, bubbles: true,
-          }));
-      assert.equal(window.fetch.callCount, 1);
-      flush(() => {
-        // here needs two flush as there are two chanined
-        // promises on server-error handler and flush only flushes one
-        assert.equal(window.fetch.callCount, 2);
-        flush(() => {
-          // toast
-          const toast = toastSpy.lastCall.returnValue;
-          assert.include(
-              dom(toast.root).textContent, 'Credentials expired.');
-          assert.include(
-              dom(toast.root).textContent, 'Refresh credentials');
-          done();
-        });
-      });
-    });
-
-    test('regular toast should dismiss regular toast', () => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
-      const toastSpy = sandbox.spy(element, '_createToastAlert');
-
-      // fake an alert
-      element.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: 'test reload', action: 'reload'},
-            composed: true, bubbles: true,
-          }));
-      let toast = toastSpy.lastCall.returnValue;
-      assert.isOk(toast);
-      assert.include(
-          dom(toast.root).textContent, 'test reload');
-
-      // new alert
-      element.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: 'second-test', action: 'reload'},
-            composed: true, bubbles: true,
-          }));
-
-      toast = toastSpy.lastCall.returnValue;
-      assert.include(dom(toast.root).textContent, 'second-test');
-    });
-
-    test('regular toast should not dismiss auth toast', done => {
-      // starts with authed state
-      element.$.restAPI.getLoggedIn();
-      const toastSpy = sandbox.spy(element, '_createToastAlert');
-      const responseText = Promise.resolve('Authentication required\n');
-
-      // fake auth
-      window.fetch.returns(Promise.resolve({status: 403}));
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail:
-          {response: {status: 403, text() { return responseText; }}},
-            composed: true, bubbles: true,
-          }));
-      assert.equal(window.fetch.callCount, 1);
-      flush(() => {
-        // here needs two flush as there are two chanined
-        // promises on server-error handler and flush only flushes one
-        assert.equal(window.fetch.callCount, 2);
-        flush(() => {
-          let toast = toastSpy.lastCall.returnValue;
-          assert.include(
-              dom(toast.root).textContent, 'Credentials expired.');
-          assert.include(
-              dom(toast.root).textContent, 'Refresh credentials');
-
-          // fake an alert
-          element.dispatchEvent(
-              new CustomEvent('show-alert', {
-                detail: {
-                  message: 'test-alert', action: 'reload',
-                },
-                composed: true, bubbles: true,
-              }));
-          flush(() => {
-            toast = toastSpy.lastCall.returnValue;
-            assert.isOk(toast);
-            assert.include(
-                dom(toast.root).textContent, 'Credentials expired.');
-            done();
-          });
-        });
-      });
-    });
-
-    test('show alert', () => {
-      const alertObj = {message: 'foo'};
-      sandbox.stub(element, '_showAlert');
-      element.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: alertObj,
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._showAlert.calledOnce);
-      assert.equal(element._showAlert.lastCall.args[0], 'foo');
-      assert.isNotOk(element._showAlert.lastCall.args[1]);
-      assert.isNotOk(element._showAlert.lastCall.args[2]);
-    });
-
-    test('checks stale credentials on visibility change', () => {
-      const refreshStub = sandbox.stub(element,
-          '_checkSignedIn');
-      sandbox.stub(Date, 'now').returns(999999);
-      element._lastCredentialCheck = 0;
-      element._handleVisibilityChange();
-
-      // Since there is no known account, it should not test credentials.
-      assert.isFalse(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 0);
-
-      element.knownAccountId = 123;
-      element._handleVisibilityChange();
-
-      // Should test credentials, since there is a known account.
-      assert.isTrue(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 999999);
-    });
-
-    test('refreshes with same credentials', done => {
-      const accountPromise = Promise.resolve({_account_id: 1234});
-      sandbox.stub(element.$.restAPI, 'getAccount')
-          .returns(accountPromise);
-      const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
-      const handleRefreshStub = sandbox.stub(element,
-          '_handleCredentialRefreshed');
-      const reloadStub = sandbox.stub(element, '_reloadPage');
-
-      element.knownAccountId = 1234;
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
-
-      flush(() => {
-        assert.isFalse(requestCheckStub.called);
-        assert.isTrue(handleRefreshStub.called);
-        assert.isFalse(reloadStub.called);
-        done();
-      });
-    });
-
-    test('_showAlert hides existing alerts', () => {
-      element._alertElement = element._createToastAlert();
-      const hideStub = sandbox.stub(element, '_hideAlert');
-      element._showAlert();
-      assert.isTrue(hideStub.calledOnce);
-    });
-
-    test('show-error', () => {
-      const openStub = sandbox.stub(element.$.errorOverlay, 'open');
-      const closeStub = sandbox.stub(element.$.errorOverlay, 'close');
-      const reportStub = sandbox.stub(
-          element.$.reporting,
-          'reportErrorDialog'
-      );
-
-      const message = 'test message';
-      element.dispatchEvent(
-          new CustomEvent('show-error', {
-            detail: {message},
-            composed: true, bubbles: true,
-          }));
-      flushAsynchronousOperations();
-
-      assert.isTrue(openStub.called);
-      assert.isTrue(reportStub.called);
-      assert.equal(element.$.errorDialog.text, message);
-
-      element.$.errorDialog.dispatchEvent(
-          new CustomEvent('dismiss', {
-            composed: true, bubbles: true,
-          }));
-      flushAsynchronousOperations();
-
-      assert.isTrue(closeStub.called);
-    });
-
-    test('reloads when refreshed credentials differ', done => {
-      const accountPromise = Promise.resolve({_account_id: 1234});
-      sandbox.stub(element.$.restAPI, 'getAccount')
-          .returns(accountPromise);
-      const requestCheckStub = sandbox.stub(
-          element,
-          '_requestCheckLoggedIn');
-      const handleRefreshStub = sandbox.stub(element,
-          '_handleCredentialRefreshed');
-      const reloadStub = sandbox.stub(element, '_reloadPage');
-
-      element.knownAccountId = 4321; // Different from 1234
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
-
-      flush(() => {
-        assert.isFalse(requestCheckStub.called);
-        assert.isFalse(handleRefreshStub.called);
-        assert.isTrue(reloadStub.called);
-        done();
-      });
-    });
-  });
-
-  suite('when not authed', () => {
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-      element = fixture('basic');
-    });
-
-    test('refresh loop continues on credential fail', done => {
-      const requestCheckStub = sandbox.stub(
-          element,
-          '_requestCheckLoggedIn');
-      const handleRefreshStub = sandbox.stub(element,
-          '_handleCredentialRefreshed');
-      const reloadStub = sandbox.stub(element, '_reloadPage');
-
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
-
-      flush(() => {
-        assert.isTrue(requestCheckStub.called);
-        assert.isFalse(handleRefreshStub.called);
-        assert.isFalse(reloadStub.called);
-        done();
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
new file mode 100644
index 0000000..b527786
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
@@ -0,0 +1,575 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-error-manager.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {__testOnly_ErrorType} from './gr-error-manager.js';
+
+const basicFixture = fixtureFromElement('gr-error-manager');
+
+_testOnly_initGerritPluginApi();
+
+suite('gr-error-manager tests', () => {
+  let element;
+
+  suite('when authed', () => {
+    let toastSpy;
+    let openOverlaySpy;
+
+    setup(() => {
+      sinon.stub(window, 'fetch')
+          .returns(Promise.resolve({ok: true, status: 204}));
+      element = basicFixture.instantiate();
+      element._authService.clearCache();
+      toastSpy = sinon.spy(element, '_createToastAlert');
+      openOverlaySpy = sinon.spy(element.$.noInteractionOverlay, 'open');
+    });
+
+    teardown(() => {
+      toastSpy.getCalls().forEach(call => {
+        call.returnValue.remove();
+      });
+    });
+
+    test('does not show auth error on 403 by default', done => {
+      const showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
+      const responseText = Promise.resolve('server says no.');
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail:
+          {response: {status: 403, text() { return responseText; }}},
+            composed: true, bubbles: true,
+          }));
+      flush(() => {
+        assert.isFalse(showAuthErrorStub.calledOnce);
+        done();
+      });
+    });
+
+    test('show auth required for 403 with auth error and not authed before',
+        done => {
+          const showAuthErrorStub = sinon.stub(
+              element, '_showAuthErrorAlert'
+          );
+          const responseText = Promise.resolve('Authentication required\n');
+          sinon.stub(element.$.restAPI, 'getLoggedIn')
+              .returns(Promise.resolve(true));
+          element.dispatchEvent(
+              new CustomEvent('server-error', {
+                detail:
+              {response: {status: 403, text() { return responseText; }}},
+                composed: true, bubbles: true,
+              }));
+          flush(() => {
+            assert.isTrue(showAuthErrorStub.calledOnce);
+            done();
+          });
+        });
+
+    test('recheck auth for 403 with auth error if authed before', done => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+      const responseText = Promise.resolve('Authentication required\n');
+      sinon.stub(element.$.restAPI, 'getLoggedIn')
+          .returns(Promise.resolve(true));
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail:
+          {response: {status: 403, text() { return responseText; }}},
+            composed: true, bubbles: true,
+          }));
+      flush(() => {
+        assert.isTrue(element.$.restAPI.getLoggedIn.calledOnce);
+        done();
+      });
+    });
+
+    test('show logged in error', () => {
+      sinon.stub(element, '_showAuthErrorAlert');
+      element.dispatchEvent(
+          new CustomEvent('show-auth-required', {
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
+          'Log in is required to perform that action.', 'Log in.'));
+    });
+
+    test('show normal Error', done => {
+      const showErrorStub = sinon.stub(element, '_showErrorDialog');
+      const textSpy = sinon.spy(() => Promise.resolve('ZOMG'));
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail: {response: {status: 500, text: textSpy}},
+            composed: true, bubbles: true,
+          }));
+
+      assert.isTrue(textSpy.called);
+      flush(() => {
+        assert.isTrue(showErrorStub.calledOnce);
+        assert.isTrue(showErrorStub.lastCall.calledWithExactly(
+            'Error 500: ZOMG'));
+        done();
+      });
+    });
+
+    test('_constructServerErrorMsg', () => {
+      const errorText = 'change conflicts';
+      const status = 409;
+      const statusText = 'Conflict';
+      const url = '/my/test/url';
+
+      assert.equal(element._constructServerErrorMsg({status}),
+          'Error 409');
+      assert.equal(element._constructServerErrorMsg({status, url}),
+          'Error 409: \nEndpoint: /my/test/url');
+      assert.equal(element.
+          _constructServerErrorMsg({status, statusText, url}),
+      'Error 409 (Conflict): \nEndpoint: /my/test/url');
+      assert.equal(element._constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        url,
+      }), 'Error 409 (Conflict): change conflicts' +
+      '\nEndpoint: /my/test/url');
+      assert.equal(element._constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        url,
+        trace: 'xxxxx',
+      }), 'Error 409 (Conflict): change conflicts' +
+      '\nEndpoint: /my/test/url\nTrace Id: xxxxx');
+    });
+
+    test('extract trace id from headers if exists', done => {
+      const textSpy = sinon.spy(
+          () => Promise.resolve('500')
+      );
+      const headers = new Headers();
+      headers.set('X-Gerrit-Trace', 'xxxx');
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail: {
+              response: {
+                headers,
+                status: 500,
+                text: textSpy,
+              },
+            },
+            composed: true, bubbles: true,
+          }));
+      flush(() => {
+        assert.equal(
+            element.$.errorDialog.text,
+            'Error 500: 500\nTrace Id: xxxx'
+        );
+        done();
+      });
+    });
+
+    test('suppress TOO_MANY_FILES error', done => {
+      const showAlertStub = sinon.stub(element, '_showAlert');
+      const textSpy = sinon.spy(
+          () => Promise.resolve('too many files to find conflicts')
+      );
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail: {response: {status: 500, text: textSpy}},
+            composed: true, bubbles: true,
+          }));
+
+      assert.isTrue(textSpy.called);
+      flush(() => {
+        assert.isFalse(showAlertStub.called);
+        done();
+      });
+    });
+
+    test('show network error', done => {
+      const consoleErrorStub = sinon.stub(console, 'error');
+      const showAlertStub = sinon.stub(element, '_showAlert');
+      element.dispatchEvent(
+          new CustomEvent('network-error', {
+            detail: {error: new Error('ZOMG')},
+            composed: true, bubbles: true,
+          }));
+      flush(() => {
+        assert.isTrue(showAlertStub.calledOnce);
+        assert.isTrue(showAlertStub.lastCall.calledWithExactly(
+            'Server unavailable'));
+        assert.isTrue(consoleErrorStub.calledOnce);
+        assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
+        done();
+      });
+    });
+
+    test('_canOverride alerts', () => {
+      assert.isFalse(element._canOverride(undefined,
+          __testOnly_ErrorType.AUTH));
+      assert.isFalse(element._canOverride(undefined,
+          __testOnly_ErrorType.NETWORK));
+      assert.isTrue(element._canOverride(undefined,
+          __testOnly_ErrorType.GENERIC));
+      assert.isTrue(element._canOverride(undefined, undefined));
+
+      assert.isTrue(element._canOverride(__testOnly_ErrorType.NETWORK,
+          undefined));
+      assert.isTrue(element._canOverride(__testOnly_ErrorType.AUTH,
+          undefined));
+      assert.isFalse(element._canOverride(__testOnly_ErrorType.NETWORK,
+          __testOnly_ErrorType.AUTH));
+
+      assert.isTrue(element._canOverride(__testOnly_ErrorType.AUTH,
+          __testOnly_ErrorType.NETWORK));
+    });
+
+    test('show auth refresh toast', async () => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+      const refreshStub = sinon.stub(element.$.restAPI, 'getAccount').callsFake(
+          () => Promise.resolve({}));
+      const windowOpen = sinon.stub(window, 'open');
+      const responseText = Promise.resolve('Authentication required\n');
+      // fake failed auth
+      window.fetch.returns(Promise.resolve({status: 403}));
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail:
+          {response: {status: 403, text() { return responseText; }}},
+            composed: true, bubbles: true,
+          }));
+      assert.equal(window.fetch.callCount, 1);
+      await flush();
+
+      // here needs two flush as there are two chanined
+      // promises on server-error handler and flush only flushes one
+      assert.equal(window.fetch.callCount, 2);
+      await flush();
+      // Sometime overlay opens with delay, waiting while open is complete
+      await openOverlaySpy.lastCall.returnValue;
+      // auth-error fired
+      assert.isTrue(toastSpy.called);
+
+      // toast
+      let toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(
+          dom(toast.root).textContent, 'Credentials expired.');
+      assert.include(
+          dom(toast.root).textContent, 'Refresh credentials');
+
+      // noInteractionOverlay
+      const noInteractionOverlay = element.$.noInteractionOverlay;
+      assert.isOk(noInteractionOverlay);
+      sinon.spy(noInteractionOverlay, 'close');
+      assert.equal(
+          noInteractionOverlay.backdropElement.getAttribute('opened'),
+          '');
+      assert.isFalse(windowOpen.called);
+      MockInteractions.tap(toast.shadowRoot
+          .querySelector('gr-button.action'));
+      assert.isTrue(windowOpen.called);
+
+      // @see Issue 5822: noopener breaks closeAfterLogin
+      assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
+          -1);
+
+      const hideToastSpy = sinon.spy(toast, 'hide');
+
+      // now fake authed
+      window.fetch.returns(Promise.resolve({status: 204}));
+      element._handleWindowFocus();
+      element.flushDebouncer('checkLoggedIn');
+      await flush();
+      assert.isTrue(refreshStub.called);
+      assert.isTrue(hideToastSpy.called);
+
+      // toast update
+      assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
+      toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(
+          dom(toast.root).textContent, 'Credentials refreshed');
+
+      // close overlay
+      assert.isTrue(noInteractionOverlay.close.called);
+    });
+
+    test('auth toast should dismiss existing toast', async () => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+      const responseText = Promise.resolve('Authentication required\n');
+
+      // fake an alert
+      element.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message: 'test reload', action: 'reload'},
+            composed: true, bubbles: true,
+          }));
+      let toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(
+          dom(toast.root).textContent, 'test reload');
+
+      // fake auth
+      window.fetch.returns(Promise.resolve({status: 403}));
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail:
+          {response: {status: 403, text() { return responseText; }}},
+            composed: true, bubbles: true,
+          }));
+      assert.equal(window.fetch.callCount, 1);
+      await flush();
+      // here needs two flush as there are two chained
+      // promises on server-error handler and flush only flushes one
+      assert.equal(window.fetch.callCount, 2);
+      await flush();
+      // Sometime overlay opens with delay, waiting while open is complete
+      await openOverlaySpy.lastCall.returnValue;
+      // toast
+      toast = toastSpy.lastCall.returnValue;
+      assert.include(
+          dom(toast.root).textContent, 'Credentials expired.');
+      assert.include(
+          dom(toast.root).textContent, 'Refresh credentials');
+    });
+
+    test('regular toast should dismiss regular toast', () => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+
+      // fake an alert
+      element.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message: 'test reload', action: 'reload'},
+            composed: true, bubbles: true,
+          }));
+      let toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(
+          dom(toast.root).textContent, 'test reload');
+
+      // new alert
+      element.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message: 'second-test', action: 'reload'},
+            composed: true, bubbles: true,
+          }));
+
+      toast = toastSpy.lastCall.returnValue;
+      assert.include(dom(toast.root).textContent, 'second-test');
+    });
+
+    test('regular toast should not dismiss auth toast', done => {
+      // starts with authed state
+      element.$.restAPI.getLoggedIn();
+      const responseText = Promise.resolve('Authentication required\n');
+
+      // fake auth
+      window.fetch.returns(Promise.resolve({status: 403}));
+      element.dispatchEvent(
+          new CustomEvent('server-error', {
+            detail:
+          {response: {status: 403, text() { return responseText; }}},
+            composed: true, bubbles: true,
+          }));
+      assert.equal(window.fetch.callCount, 1);
+      flush(() => {
+        // here needs two flush as there are two chained
+        // promises on server-error handler and flush only flushes one
+        assert.equal(window.fetch.callCount, 2);
+        flush(() => {
+          let toast = toastSpy.lastCall.returnValue;
+          assert.include(
+              dom(toast.root).textContent, 'Credentials expired.');
+          assert.include(
+              dom(toast.root).textContent, 'Refresh credentials');
+
+          // fake an alert
+          element.dispatchEvent(
+              new CustomEvent('show-alert', {
+                detail: {
+                  message: 'test-alert', action: 'reload',
+                },
+                composed: true, bubbles: true,
+              }));
+          flush(() => {
+            toast = toastSpy.lastCall.returnValue;
+            assert.isOk(toast);
+            assert.include(
+                dom(toast.root).textContent, 'Credentials expired.');
+            done();
+          });
+        });
+      });
+    });
+
+    test('show alert', () => {
+      const alertObj = {message: 'foo'};
+      sinon.stub(element, '_showAlert');
+      element.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: alertObj,
+            composed: true, bubbles: true,
+          }));
+      assert.isTrue(element._showAlert.calledOnce);
+      assert.equal(element._showAlert.lastCall.args[0], 'foo');
+      assert.isNotOk(element._showAlert.lastCall.args[1]);
+      assert.isNotOk(element._showAlert.lastCall.args[2]);
+    });
+
+    test('checks stale credentials on visibility change', () => {
+      const refreshStub = sinon.stub(element,
+          '_checkSignedIn');
+      sinon.stub(Date, 'now').returns(999999);
+      element._lastCredentialCheck = 0;
+      element._handleVisibilityChange();
+
+      // Since there is no known account, it should not test credentials.
+      assert.isFalse(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 0);
+
+      element.knownAccountId = 123;
+      element._handleVisibilityChange();
+
+      // Should test credentials, since there is a known account.
+      assert.isTrue(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 999999);
+    });
+
+    test('refreshes with same credentials', done => {
+      const accountPromise = Promise.resolve({_account_id: 1234});
+      sinon.stub(element.$.restAPI, 'getAccount')
+          .returns(accountPromise);
+      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sinon.stub(element,
+          '_handleCredentialRefreshed');
+      const reloadStub = sinon.stub(element, '_reloadPage');
+
+      element.knownAccountId = 1234;
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      flush(() => {
+        assert.isFalse(requestCheckStub.called);
+        assert.isTrue(handleRefreshStub.called);
+        assert.isFalse(reloadStub.called);
+        done();
+      });
+    });
+
+    test('_showAlert hides existing alerts', () => {
+      element._alertElement = element._createToastAlert();
+      const hideStub = sinon.stub(element, '_hideAlert');
+      element._showAlert();
+      assert.isTrue(hideStub.calledOnce);
+    });
+
+    test('show-error', () => {
+      const openStub = sinon.stub(element.$.errorOverlay, 'open');
+      const closeStub = sinon.stub(element.$.errorOverlay, 'close');
+      const reportStub = sinon.stub(
+          element.reporting,
+          'reportErrorDialog'
+      );
+
+      const message = 'test message';
+      element.dispatchEvent(
+          new CustomEvent('show-error', {
+            detail: {message},
+            composed: true, bubbles: true,
+          }));
+      flushAsynchronousOperations();
+
+      assert.isTrue(openStub.called);
+      assert.isTrue(reportStub.called);
+      assert.equal(element.$.errorDialog.text, message);
+
+      element.$.errorDialog.dispatchEvent(
+          new CustomEvent('dismiss', {
+            composed: true, bubbles: true,
+          }));
+      flushAsynchronousOperations();
+
+      assert.isTrue(closeStub.called);
+    });
+
+    test('reloads when refreshed credentials differ', done => {
+      const accountPromise = Promise.resolve({_account_id: 1234});
+      sinon.stub(element.$.restAPI, 'getAccount')
+          .returns(accountPromise);
+      const requestCheckStub = sinon.stub(
+          element,
+          '_requestCheckLoggedIn');
+      const handleRefreshStub = sinon.stub(element,
+          '_handleCredentialRefreshed');
+      const reloadStub = sinon.stub(element, '_reloadPage');
+
+      element.knownAccountId = 4321; // Different from 1234
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      flush(() => {
+        assert.isFalse(requestCheckStub.called);
+        assert.isFalse(handleRefreshStub.called);
+        assert.isTrue(reloadStub.called);
+        done();
+      });
+    });
+  });
+
+  suite('when not authed', () => {
+    let toastSpy;
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+      });
+      element = basicFixture.instantiate();
+      toastSpy = sinon.spy(element, '_createToastAlert');
+    });
+
+    teardown(() => {
+      toastSpy.getCalls().forEach(call => {
+        call.returnValue.remove();
+      });
+    });
+
+    test('refresh loop continues on credential fail', done => {
+      const requestCheckStub = sinon.stub(
+          element,
+          '_requestCheckLoggedIn');
+      const handleRefreshStub = sinon.stub(element,
+          '_handleCredentialRefreshed');
+      const reloadStub = sinon.stub(element, '_reloadPage');
+
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      flush(() => {
+        assert.isTrue(requestCheckStub.called);
+        assert.isFalse(handleRefreshStub.called);
+        assert.isFalse(reloadStub.called);
+        done();
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
deleted file mode 100644
index 5d7ec27..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import '../../../styles/shared-styles.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-key-binding-display_html.js';
-
-/** @extends Polymer.Element */
-class GrKeyBindingDisplay extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-key-binding-display'; }
-
-  static get properties() {
-    return {
-    /** @type {Array<string>} */
-      binding: Array,
-    };
-  }
-
-  _computeModifiers(binding) {
-    return binding.slice(0, binding.length - 1);
-  }
-
-  _computeKey(binding) {
-    return binding[binding.length - 1];
-  }
-}
-
-customElements.define(GrKeyBindingDisplay.is, GrKeyBindingDisplay);
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
new file mode 100644
index 0000000..796a167
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
@@ -0,0 +1,48 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-key-binding-display_html';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-key-binding-display': GrKeyBindingDisplay;
+  }
+}
+
+@customElement('gr-key-binding-display')
+export class GrKeyBindingDisplay extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Array})
+  binding: string[][] = [];
+
+  _computeModifiers(binding: string[][]) {
+    return binding.slice(0, binding.length - 1);
+  }
+
+  _computeKey(binding: string[][]) {
+    return binding[binding.length - 1];
+  }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.js
deleted file mode 100644
index 334a40a..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .key {
-      background-color: var(--chip-background-color);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      display: inline-block;
-      font-weight: var(--font-weight-bold);
-      padding: var(--spacing-xxs) var(--spacing-m);
-      text-align: center;
-    }
-  </style>
-  <template is="dom-repeat" items="[[binding]]">
-    <template is="dom-if" if="[[index]]">
-      or
-    </template>
-    <template is="dom-repeat" items="[[_computeModifiers(item)]]" as="modifier">
-      <span class="key modifier">[[modifier]]</span>
-    </template>
-    <span class="key">[[_computeKey(item)]]</span>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
new file mode 100644
index 0000000..0a75104
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_html.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .key {
+      background-color: var(--chip-background-color);
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      display: inline-block;
+      font-weight: var(--font-weight-bold);
+      padding: var(--spacing-xxs) var(--spacing-m);
+      text-align: center;
+    }
+  </style>
+  <template is="dom-repeat" items="[[binding]]">
+    <template is="dom-if" if="[[index]]">
+      or
+    </template>
+    <template is="dom-repeat" items="[[_computeModifiers(item)]]" as="modifier">
+      <span class="key modifier">[[modifier]]</span>
+    </template>
+    <span class="key">[[_computeKey(item)]]</span>
+  </template>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
deleted file mode 100644
index 8ae0f69..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.html
+++ /dev/null
@@ -1,67 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-key-binding-display</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-key-binding-display></gr-key-binding-display>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-key-binding-display.js';
-suite('gr-key-binding-display tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  suite('_computeKey', () => {
-    test('unmodified key', () => {
-      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');
-    });
-  });
-
-  suite('_computeModifiers', () => {
-    test('single unmodified key', () => {
-      assert.deepEqual(element._computeModifiers(['x']), []);
-    });
-
-    test('key with modifiers', () => {
-      assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
-      assert.deepEqual(
-          element._computeModifiers(['Shift', 'Meta', 'x']),
-          ['Shift', 'Meta']);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js
new file mode 100644
index 0000000..0c25e6e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.js
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-key-binding-display.js';
+
+const basicFixture = fixtureFromElement('gr-key-binding-display');
+
+suite('gr-key-binding-display tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('_computeKey', () => {
+    test('unmodified key', () => {
+      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');
+    });
+  });
+
+  suite('_computeModifiers', () => {
+    test('single unmodified key', () => {
+      assert.deepEqual(element._computeModifiers(['x']), []);
+    });
+
+    test('key with modifiers', () => {
+      assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
+      assert.deepEqual(
+          element._computeModifiers(['Shift', 'Meta', 'x']),
+          ['Shift', 'Meta']);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
index beb0f7e..31fece5 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
@@ -14,28 +14,21 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-button/gr-button.js';
 import '../gr-key-binding-display/gr-key-binding-display.js';
 import '../../../styles/shared-styles.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-keyboard-shortcuts-dialog_html.js';
-import {KeyboardShortcutBehavior, KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-
-const {ShortcutSection} = KeyboardShortcutBinder;
+import {KeyboardShortcutMixin, ShortcutSection} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrKeyboardShortcutsDialog extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrKeyboardShortcutsDialog extends KeyboardShortcutMixin(
+    GestureEventListeners(
+        LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-keyboard-shortcuts-dialog'; }
@@ -49,22 +42,6 @@
     return {
       _left: Array,
       _right: Array,
-
-      _propertyBySection: {
-        type: Object,
-        value() {
-          return {
-            [ShortcutSection.EVERYWHERE]: '_everywhere',
-            [ShortcutSection.NAVIGATION]: '_navigation',
-            [ShortcutSection.DASHBOARD]: '_dashboard',
-            [ShortcutSection.CHANGE_LIST]: '_changeList',
-            [ShortcutSection.ACTIONS]: '_actions',
-            [ShortcutSection.REPLY_DIALOG]: '_replyDialog',
-            [ShortcutSection.FILE_LIST]: '_fileList',
-            [ShortcutSection.DIFFS]: '_diffs',
-          };
-        },
-      },
     };
   }
 
@@ -77,15 +54,17 @@
   /** @override */
   attached() {
     super.attached();
+    this.keyboardShortcutDirectoryListener =
+        this._onDirectoryUpdated.bind(this);
     this.addKeyboardShortcutDirectoryListener(
-        this._onDirectoryUpdated.bind(this));
+        this.keyboardShortcutDirectoryListener);
   }
 
   /** @override */
   detached() {
     super.detached();
     this.removeKeyboardShortcutDirectoryListener(
-        this._onDirectoryUpdated.bind(this));
+        this.keyboardShortcutDirectoryListener);
   }
 
   _handleCloseTap(e) {
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js
deleted file mode 100644
index 78b576e..0000000
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.js
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      max-height: 100vh;
-      overflow-y: auto;
-    }
-    header {
-      padding: var(--spacing-l);
-    }
-    main {
-      display: flex;
-      padding: 0 var(--spacing-xxl) var(--spacing-xxl);
-    }
-    header {
-      align-items: center;
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-      justify-content: space-between;
-    }
-    table:last-of-type {
-      margin-left: var(--spacing-xxl);
-    }
-    td {
-      padding: var(--spacing-xs) 0;
-    }
-    td:first-child {
-      padding-right: var(--spacing-m);
-      text-align: right;
-    }
-    .header {
-      font-weight: var(--font-weight-bold);
-      padding-top: var(--spacing-l);
-    }
-    .modifier {
-      font-weight: var(--font-weight-normal);
-    }
-  </style>
-  <header>
-    <h3>Keyboard shortcuts</h3>
-    <gr-button link="" on-click="_handleCloseTap">Close</gr-button>
-  </header>
-  <main>
-    <table>
-      <tbody>
-        <template is="dom-repeat" items="[[_left]]">
-          <tr>
-            <td></td>
-            <td class="header">[[item.section]]</td>
-          </tr>
-          <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
-            <tr>
-              <td>
-                <gr-key-binding-display binding="[[shortcut.binding]]">
-                </gr-key-binding-display>
-              </td>
-              <td>[[shortcut.text]]</td>
-            </tr>
-          </template>
-        </template>
-      </tbody>
-    </table>
-    <template is="dom-if" if="[[_right]]">
-      <table>
-        <tbody>
-          <template is="dom-repeat" items="[[_right]]">
-            <tr>
-              <td></td>
-              <td class="header">[[item.section]]</td>
-            </tr>
-            <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
-              <tr>
-                <td>
-                  <gr-key-binding-display binding="[[shortcut.binding]]">
-                  </gr-key-binding-display>
-                </td>
-                <td>[[shortcut.text]]</td>
-              </tr>
-            </template>
-          </template>
-        </tbody>
-      </table>
-    </template>
-  </main>
-  <footer></footer>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
new file mode 100644
index 0000000..1860f38
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
@@ -0,0 +1,104 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      max-height: 100vh;
+      overflow-y: auto;
+    }
+    header {
+      padding: var(--spacing-l);
+    }
+    main {
+      display: flex;
+      padding: 0 var(--spacing-xxl) var(--spacing-xxl);
+    }
+    header {
+      align-items: center;
+      border-bottom: 1px solid var(--border-color);
+      display: flex;
+      justify-content: space-between;
+    }
+    table:last-of-type {
+      margin-left: var(--spacing-xxl);
+    }
+    td {
+      padding: var(--spacing-xs) 0;
+    }
+    td:first-child {
+      padding-right: var(--spacing-m);
+      text-align: right;
+    }
+    .header {
+      font-weight: var(--font-weight-bold);
+      padding-top: var(--spacing-l);
+    }
+    .modifier {
+      font-weight: var(--font-weight-normal);
+    }
+  </style>
+  <header>
+    <h3 class="heading-3">Keyboard shortcuts</h3>
+    <gr-button link="" on-click="_handleCloseTap">Close</gr-button>
+  </header>
+  <main>
+    <table>
+      <tbody>
+        <template is="dom-repeat" items="[[_left]]">
+          <tr>
+            <td></td>
+            <td class="header">[[item.section]]</td>
+          </tr>
+          <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+            <tr>
+              <td>
+                <gr-key-binding-display binding="[[shortcut.binding]]">
+                </gr-key-binding-display>
+              </td>
+              <td>[[shortcut.text]]</td>
+            </tr>
+          </template>
+        </template>
+      </tbody>
+    </table>
+    <template is="dom-if" if="[[_right]]">
+      <table>
+        <tbody>
+          <template is="dom-repeat" items="[[_right]]">
+            <tr>
+              <td></td>
+              <td class="header">[[item.section]]</td>
+            </tr>
+            <template is="dom-repeat" items="[[item.shortcuts]]" as="shortcut">
+              <tr>
+                <td>
+                  <gr-key-binding-display binding="[[shortcut.binding]]">
+                  </gr-key-binding-display>
+                </td>
+                <td>[[shortcut.text]]</td>
+              </tr>
+            </template>
+          </template>
+        </tbody>
+      </table>
+    </template>
+  </main>
+  <footer></footer>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
deleted file mode 100644
index 1b5cd0f..0000000
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html
+++ /dev/null
@@ -1,181 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-key-binding-display</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-keyboard-shortcuts-dialog></gr-keyboard-shortcuts-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-keyboard-shortcuts-dialog.js';
-import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-suite('gr-keyboard-shortcuts-dialog tests', () => {
-  const kb = KeyboardShortcutBinder;
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  function update(directory) {
-    element._onDirectoryUpdated(directory);
-    flushAsynchronousOperations();
-  }
-
-  suite('_left and _right contents', () => {
-    test('empty dialog', () => {
-      assert.strictEqual(element._left.length, 0);
-      assert.strictEqual(element._right.length, 0);
-    });
-
-    test('everywhere goes on left', () => {
-      update(new Map([
-        [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._left,
-          [
-            {
-              section: kb.ShortcutSection.EVERYWHERE,
-              shortcuts: ['everywhere shortcuts'],
-            },
-          ]);
-      assert.strictEqual(element._right.length, 0);
-    });
-
-    test('navigation goes on left', () => {
-      update(new Map([
-        [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._left,
-          [
-            {
-              section: kb.ShortcutSection.NAVIGATION,
-              shortcuts: ['navigation shortcuts'],
-            },
-          ]);
-      assert.strictEqual(element._right.length, 0);
-    });
-
-    test('actions go on right', () => {
-      update(new Map([
-        [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._right,
-          [
-            {
-              section: kb.ShortcutSection.ACTIONS,
-              shortcuts: ['actions shortcuts'],
-            },
-          ]);
-      assert.strictEqual(element._left.length, 0);
-    });
-
-    test('reply dialog goes on right', () => {
-      update(new Map([
-        [kb.ShortcutSection.REPLY_DIALOG, ['reply dialog shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._right,
-          [
-            {
-              section: kb.ShortcutSection.REPLY_DIALOG,
-              shortcuts: ['reply dialog shortcuts'],
-            },
-          ]);
-      assert.strictEqual(element._left.length, 0);
-    });
-
-    test('file list goes on right', () => {
-      update(new Map([
-        [kb.ShortcutSection.FILE_LIST, ['file list shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._right,
-          [
-            {
-              section: kb.ShortcutSection.FILE_LIST,
-              shortcuts: ['file list shortcuts'],
-            },
-          ]);
-      assert.strictEqual(element._left.length, 0);
-    });
-
-    test('diffs go on right', () => {
-      update(new Map([
-        [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._right,
-          [
-            {
-              section: kb.ShortcutSection.DIFFS,
-              shortcuts: ['diffs shortcuts'],
-            },
-          ]);
-      assert.strictEqual(element._left.length, 0);
-    });
-
-    test('multiple sections on each side', () => {
-      update(new Map([
-        [kb.ShortcutSection.ACTIONS, ['actions shortcuts']],
-        [kb.ShortcutSection.DIFFS, ['diffs shortcuts']],
-        [kb.ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
-        [kb.ShortcutSection.NAVIGATION, ['navigation shortcuts']],
-      ]));
-      assert.deepEqual(
-          element._left,
-          [
-            {
-              section: kb.ShortcutSection.EVERYWHERE,
-              shortcuts: ['everywhere shortcuts'],
-            },
-            {
-              section: kb.ShortcutSection.NAVIGATION,
-              shortcuts: ['navigation shortcuts'],
-            },
-          ]);
-      assert.deepEqual(
-          element._right,
-          [
-            {
-              section: kb.ShortcutSection.ACTIONS,
-              shortcuts: ['actions shortcuts'],
-            },
-            {
-              section: kb.ShortcutSection.DIFFS,
-              shortcuts: ['diffs shortcuts'],
-            },
-          ]);
-    });
-  });
-});
-</script>
-
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.js
new file mode 100644
index 0000000..cb7e87b8
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.js
@@ -0,0 +1,166 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-keyboard-shortcuts-dialog.js';
+import {ShortcutSection} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+
+const basicFixture = fixtureFromElement('gr-keyboard-shortcuts-dialog');
+
+suite('gr-keyboard-shortcuts-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  function update(directory) {
+    element._onDirectoryUpdated(directory);
+    flushAsynchronousOperations();
+  }
+
+  suite('_left and _right contents', () => {
+    test('empty dialog', () => {
+      assert.strictEqual(element._left.length, 0);
+      assert.strictEqual(element._right.length, 0);
+    });
+
+    test('everywhere goes on left', () => {
+      update(new Map([
+        [ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._left,
+          [
+            {
+              section: ShortcutSection.EVERYWHERE,
+              shortcuts: ['everywhere shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._right.length, 0);
+    });
+
+    test('navigation goes on left', () => {
+      update(new Map([
+        [ShortcutSection.NAVIGATION, ['navigation shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._left,
+          [
+            {
+              section: ShortcutSection.NAVIGATION,
+              shortcuts: ['navigation shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._right.length, 0);
+    });
+
+    test('actions go on right', () => {
+      update(new Map([
+        [ShortcutSection.ACTIONS, ['actions shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: ShortcutSection.ACTIONS,
+              shortcuts: ['actions shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
+
+    test('reply dialog goes on right', () => {
+      update(new Map([
+        [ShortcutSection.REPLY_DIALOG, ['reply dialog shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: ShortcutSection.REPLY_DIALOG,
+              shortcuts: ['reply dialog shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
+
+    test('file list goes on right', () => {
+      update(new Map([
+        [ShortcutSection.FILE_LIST, ['file list shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: ShortcutSection.FILE_LIST,
+              shortcuts: ['file list shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
+
+    test('diffs go on right', () => {
+      update(new Map([
+        [ShortcutSection.DIFFS, ['diffs shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: ShortcutSection.DIFFS,
+              shortcuts: ['diffs shortcuts'],
+            },
+          ]);
+      assert.strictEqual(element._left.length, 0);
+    });
+
+    test('multiple sections on each side', () => {
+      update(new Map([
+        [ShortcutSection.ACTIONS, ['actions shortcuts']],
+        [ShortcutSection.DIFFS, ['diffs shortcuts']],
+        [ShortcutSection.EVERYWHERE, ['everywhere shortcuts']],
+        [ShortcutSection.NAVIGATION, ['navigation shortcuts']],
+      ]));
+      assert.deepEqual(
+          element._left,
+          [
+            {
+              section: ShortcutSection.EVERYWHERE,
+              shortcuts: ['everywhere shortcuts'],
+            },
+            {
+              section: ShortcutSection.NAVIGATION,
+              shortcuts: ['navigation shortcuts'],
+            },
+          ]);
+      assert.deepEqual(
+          element._right,
+          [
+            {
+              section: ShortcutSection.ACTIONS,
+              shortcuts: ['actions shortcuts'],
+            },
+            {
+              section: ShortcutSection.DIFFS,
+              shortcuts: ['diffs shortcuts'],
+            },
+          ]);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index 3294f5f..b36435e 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
 import '../../shared/gr-dropdown/gr-dropdown.js';
 import '../../shared/gr-icons/gr-icons.js';
@@ -23,15 +21,13 @@
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-account-dropdown/gr-account-dropdown.js';
 import '../gr-smart-search/gr-smart-search.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-main-header_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {DocsUrlBehavior} from '../../../behaviors/docs-url-behavior/docs-url-behavior.js';
-import {AdminNavBehavior} from '../../../behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior.js';
+import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getAdminLinks} from '../../../utils/admin-nav-util.js';
 
 const DEFAULT_LINKS = [{
   title: 'Changes',
@@ -86,15 +82,11 @@
 ]);
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrMainHeader extends mixinBehaviors( [
-  AdminNavBehavior,
-  BaseUrlBehavior,
-  DocsUrlBehavior,
-], GestureEventListeners(
+class GrMainHeader extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-main-header'; }
@@ -187,7 +179,7 @@
   }
 
   _computeRelativeURL(path) {
-    return '//' + window.location.host + this.getBaseUrl() + path;
+    return '//' + window.location.host + getBaseUrl() + path;
   }
 
   _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
@@ -198,7 +190,7 @@
       adminLinks,
       topMenus,
       docBaseUrl,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return undefined;
     }
 
@@ -277,7 +269,7 @@
       this.loading = false;
       this._topMenus = result[1];
 
-      return this.getAdminLinks(account,
+      return getAdminLinks(account,
           this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
           this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI))
           .then(res => {
@@ -290,7 +282,7 @@
     this.$.restAPI.getConfig()
         .then(config => {
           this._retrieveRegisterURL(config);
-          return this.getDocsBaseUrl(config, this.$.restAPI);
+          return getDocsBaseUrl(config, this.$.restAPI);
         })
         .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
   }
@@ -336,7 +328,7 @@
   }
 
   _generateSettingsLink() {
-    return this.getBaseUrl() + '/settings/';
+    return getBaseUrl() + '/settings/';
   }
 
   _onMobileSearchTap(e) {
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js
deleted file mode 100644
index 19e833c..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.js
+++ /dev/null
@@ -1,233 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    nav {
-      align-items: center;
-      display: flex;
-    }
-    .bigTitle {
-      color: var(--header-text-color);
-      font-size: var(--header-title-font-size);
-      text-decoration: none;
-    }
-    .bigTitle:hover {
-      text-decoration: underline;
-    }
-    .titleText::before {
-      background-image: var(--header-icon);
-      background-size: var(--header-icon-size) var(--header-icon-size);
-      background-repeat: no-repeat;
-      content: '';
-      display: inline-block;
-      height: var(--header-icon-size);
-      margin-right: calc(var(--header-icon-size) / 4);
-      vertical-align: text-bottom;
-      width: var(--header-icon-size);
-    }
-    .titleText::after {
-      content: var(--header-title-content);
-    }
-    ul {
-      list-style: none;
-      padding-left: var(--spacing-l);
-    }
-    .links > li {
-      cursor: default;
-      display: inline-block;
-      padding: 0;
-      position: relative;
-    }
-    .linksTitle {
-      display: inline-block;
-      font-weight: var(--font-weight-bold);
-      position: relative;
-      text-transform: uppercase;
-    }
-    .linksTitle:hover {
-      opacity: 0.75;
-    }
-    .rightItems {
-      align-items: center;
-      display: flex;
-      flex: 1;
-      justify-content: flex-end;
-    }
-    .rightItems gr-endpoint-decorator:not(:empty) {
-      margin-left: var(--spacing-l);
-    }
-    gr-smart-search {
-      flex-grow: 1;
-      margin: 0 var(--spacing-m);
-      max-width: 500px;
-    }
-    gr-dropdown,
-    .browse {
-      padding: var(--spacing-m);
-    }
-    gr-dropdown {
-      --gr-dropdown-item: {
-        color: var(--primary-text-color);
-      }
-    }
-    .settingsButton {
-      margin-left: var(--spacing-m);
-    }
-    .browse {
-      color: var(--header-text-color);
-      /* Same as gr-button */
-      margin: 5px 4px;
-      text-decoration: none;
-    }
-    .invisible,
-    .settingsButton,
-    gr-account-dropdown {
-      display: none;
-    }
-    :host([loading]) .accountContainer,
-    :host([logged-in]) .loginButton,
-    :host([logged-in]) .registerButton {
-      display: none;
-    }
-    :host([logged-in]) .settingsButton,
-    :host([logged-in]) gr-account-dropdown {
-      display: inline;
-    }
-    .accountContainer {
-      align-items: center;
-      display: flex;
-      margin: 0 calc(0 - var(--spacing-m)) 0 var(--spacing-m);
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    .loginButton,
-    .registerButton {
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    .dropdown-trigger {
-      text-decoration: none;
-    }
-    .dropdown-content {
-      background-color: var(--view-background-color);
-      box-shadow: var(--elevation-level-2);
-    }
-    /*
-       * We are not using :host to do this, because :host has a lowest css priority
-       * compared to others. This means that using :host to do this would break styles.
-       */
-    .linksTitle,
-    .bigTitle,
-    .loginButton,
-    .registerButton,
-    iron-icon,
-    gr-account-dropdown {
-      color: var(--header-text-color);
-    }
-    #mobileSearch {
-      display: none;
-    }
-    @media screen and (max-width: 50em) {
-      .bigTitle {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      gr-smart-search,
-      .browse,
-      .rightItems .hideOnMobile,
-      .links > li.hideOnMobile {
-        display: none;
-      }
-      #mobileSearch {
-        display: inline-flex;
-      }
-      .accountContainer {
-        margin-left: var(--spacing-m) !important;
-      }
-      gr-dropdown {
-        padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
-      }
-    }
-  </style>
-  <nav>
-    <a href$="[[_computeRelativeURL('/')]]" class="bigTitle">
-      <gr-endpoint-decorator name="header-title">
-        <span class="titleText"></span>
-      </gr-endpoint-decorator>
-    </a>
-    <ul class="links">
-      <template is="dom-repeat" items="[[_links]]" as="linkGroup">
-        <li class$="[[_computeLinkGroupClass(linkGroup)]]">
-          <gr-dropdown
-            link=""
-            down-arrow=""
-            items="[[linkGroup.links]]"
-            horizontal-align="left"
-          >
-            <span class="linksTitle" id="[[linkGroup.title]]">
-              [[linkGroup.title]]
-            </span>
-          </gr-dropdown>
-        </li>
-      </template>
-    </ul>
-    <div class="rightItems">
-      <gr-endpoint-decorator
-        class="hideOnMobile"
-        name="header-small-banner"
-      ></gr-endpoint-decorator>
-      <gr-smart-search
-        id="search"
-        search-query="{{searchQuery}}"
-      ></gr-smart-search>
-      <gr-endpoint-decorator
-        class="hideOnMobile"
-        name="header-browse-source"
-      ></gr-endpoint-decorator>
-      <div class="accountContainer" id="accountContainer">
-        <iron-icon
-          id="mobileSearch"
-          icon="gr-icons:search"
-          on-tap="_onMobileSearchTap"
-        ></iron-icon>
-        <div class$="[[_computeIsInvisible(_registerURL)]]">
-          <a class="registerButton" href$="[[_registerURL]]">
-            [[_registerText]]
-          </a>
-        </div>
-        <a class="loginButton" href$="[[loginUrl]]">Sign in</a>
-        <a
-          class="settingsButton"
-          href$="[[_generateSettingsLink()]]"
-          title="Settings"
-        >
-          <iron-icon icon="gr-icons:settings"></iron-icon>
-        </a>
-        <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
-      </div>
-    </div>
-  </nav>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
new file mode 100644
index 0000000..8ef54d6
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
@@ -0,0 +1,236 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    nav {
+      align-items: center;
+      display: flex;
+    }
+    .bigTitle {
+      color: var(--header-text-color);
+      font-size: var(--header-title-font-size);
+      text-decoration: none;
+    }
+    .bigTitle:hover {
+      text-decoration: underline;
+    }
+    .titleText::before {
+      background-image: var(--header-icon);
+      background-size: var(--header-icon-size) var(--header-icon-size);
+      background-repeat: no-repeat;
+      content: '';
+      display: inline-block;
+      height: var(--header-icon-size);
+      margin-right: calc(var(--header-icon-size) / 4);
+      vertical-align: text-bottom;
+      width: var(--header-icon-size);
+    }
+    .titleText::after {
+      content: var(--header-title-content);
+    }
+    ul {
+      list-style: none;
+      padding-left: var(--spacing-l);
+    }
+    .links > li {
+      cursor: default;
+      display: inline-block;
+      padding: 0;
+      position: relative;
+    }
+    .linksTitle {
+      display: inline-block;
+      font-weight: var(--font-weight-bold);
+      position: relative;
+      text-transform: uppercase;
+    }
+    .linksTitle:hover {
+      opacity: 0.75;
+    }
+    .rightItems {
+      align-items: center;
+      display: flex;
+      flex: 1;
+      justify-content: flex-end;
+    }
+    .rightItems gr-endpoint-decorator:not(:empty) {
+      margin-left: var(--spacing-l);
+    }
+    gr-smart-search {
+      flex-grow: 1;
+      margin: 0 var(--spacing-m);
+      max-width: 500px;
+    }
+    gr-dropdown,
+    .browse {
+      padding: var(--spacing-m);
+    }
+    gr-dropdown {
+      --gr-dropdown-item: {
+        color: var(--primary-text-color);
+      }
+    }
+    .settingsButton {
+      margin-left: var(--spacing-m);
+    }
+    .browse {
+      color: var(--header-text-color);
+      /* Same as gr-button */
+      margin: 5px 4px;
+      text-decoration: none;
+    }
+    .invisible,
+    .settingsButton,
+    gr-account-dropdown {
+      display: none;
+    }
+    :host([loading]) .accountContainer,
+    :host([logged-in]) .loginButton,
+    :host([logged-in]) .registerButton {
+      display: none;
+    }
+    :host([logged-in]) .settingsButton,
+    :host([logged-in]) gr-account-dropdown {
+      display: inline;
+    }
+    .accountContainer {
+      align-items: center;
+      display: flex;
+      margin: 0 calc(0 - var(--spacing-m)) 0 var(--spacing-m);
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    .loginButton,
+    .registerButton {
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    .dropdown-trigger {
+      text-decoration: none;
+    }
+    .dropdown-content {
+      background-color: var(--view-background-color);
+      box-shadow: var(--elevation-level-2);
+    }
+    /*
+       * We are not using :host to do this, because :host has a lowest css priority
+       * compared to others. This means that using :host to do this would break styles.
+       */
+    .linksTitle,
+    .bigTitle,
+    .loginButton,
+    .registerButton,
+    iron-icon,
+    gr-account-dropdown {
+      color: var(--header-text-color);
+    }
+    #mobileSearch {
+      display: none;
+    }
+    @media screen and (max-width: 50em) {
+      .bigTitle {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+      }
+      gr-smart-search,
+      .browse,
+      .rightItems .hideOnMobile,
+      .links > li.hideOnMobile {
+        display: none;
+      }
+      #mobileSearch {
+        display: inline-flex;
+      }
+      .accountContainer {
+        margin-left: var(--spacing-m) !important;
+      }
+      gr-dropdown {
+        padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
+      }
+    }
+  </style>
+  <nav>
+    <a href$="[[_computeRelativeURL('/')]]" class="bigTitle">
+      <gr-endpoint-decorator name="header-title">
+        <span class="titleText"></span>
+      </gr-endpoint-decorator>
+    </a>
+    <ul class="links">
+      <template is="dom-repeat" items="[[_links]]" as="linkGroup">
+        <li class$="[[_computeLinkGroupClass(linkGroup)]]">
+          <gr-dropdown
+            link=""
+            down-arrow=""
+            items="[[linkGroup.links]]"
+            horizontal-align="left"
+          >
+            <span class="linksTitle" id="[[linkGroup.title]]">
+              [[linkGroup.title]]
+            </span>
+          </gr-dropdown>
+        </li>
+      </template>
+    </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"
+        search-query="{{searchQuery}}"
+      ></gr-smart-search>
+      <gr-endpoint-decorator
+        class="hideOnMobile"
+        name="header-browse-source"
+      ></gr-endpoint-decorator>
+      <div class="accountContainer" id="accountContainer">
+        <iron-icon
+          id="mobileSearch"
+          icon="gr-icons:search"
+          on-tap="_onMobileSearchTap"
+        ></iron-icon>
+        <div class$="[[_computeIsInvisible(_registerURL)]]">
+          <a class="registerButton" href$="[[_registerURL]]">
+            [[_registerText]]
+          </a>
+        </div>
+        <a class="loginButton" href$="[[loginUrl]]">Sign in</a>
+        <a
+          class="settingsButton"
+          href$="[[_generateSettingsLink()]]"
+          title="Settings"
+          aria-label="Settings"
+          role="button"
+        >
+          <iron-icon icon="gr-icons:settings"></iron-icon>
+        </a>
+        <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
+      </div>
+    </div>
+  </nav>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
deleted file mode 100644
index 336d873..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
+++ /dev/null
@@ -1,410 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-main-header</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-main-header></gr-main-header>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-main-header.js';
-suite('gr-main-header tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      probePath(path) { return Promise.resolve(false); },
-    });
-    stub('gr-main-header', {
-      _loadAccount() {},
-    });
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('link visibility', () => {
-    element.loading = true;
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.accountContainer')).display,
-    'none');
-    element.loading = false;
-    element.loggedIn = false;
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.accountContainer')).display,
-    'none');
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.loginButton')).display,
-    'none');
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.registerButton')).display,
-    'none');
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('gr-account-dropdown')).display,
-    'none');
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.settingsButton')).display,
-    'none');
-    element.loggedIn = true;
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.loginButton')).display,
-    'none');
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.registerButton')).display,
-    'none');
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('gr-account-dropdown'))
-        .display,
-    'none');
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.settingsButton')).display,
-    'none');
-  });
-
-  test('fix my menu item', () => {
-    assert.deepEqual([
-      {url: 'https://awesometown.com/#hashyhash'},
-      {url: 'url', target: '_blank'},
-    ].map(element._fixCustomMenuItem), [
-      {url: 'https://awesometown.com/#hashyhash'},
-      {url: 'url'},
-    ]);
-  });
-
-  test('user links', () => {
-    const defaultLinks = [{
-      title: 'Faves',
-      links: [{
-        name: 'Pinterest',
-        url: 'https://pinterest.com',
-      }],
-    }];
-    const userLinks = [{
-      name: 'Facebook',
-      url: 'https://facebook.com',
-    }];
-    const adminLinks = [{
-      name: 'Repos',
-      url: '/repos',
-    }];
-
-    // When no admin links are passed, it should use the default.
-    assert.deepEqual(element._computeLinks(
-        defaultLinks,
-        /* userLinks= */[],
-        adminLinks,
-        /* topMenus= */[],
-        /* docBaseUrl= */ ''
-    ),
-    defaultLinks.concat({
-      title: 'Browse',
-      links: adminLinks,
-    }));
-    assert.deepEqual(element._computeLinks(
-        defaultLinks,
-        userLinks,
-        adminLinks,
-        /* topMenus= */[],
-        /* docBaseUrl= */ ''
-    ),
-    defaultLinks.concat([
-      {
-        title: 'Your',
-        links: userLinks,
-      },
-      {
-        title: 'Browse',
-        links: adminLinks,
-      }])
-    );
-  });
-
-  test('documentation links', () => {
-    const docLinks = [
-      {
-        name: 'Table of Contents',
-        url: '/index.html',
-      },
-    ];
-
-    assert.deepEqual(element._getDocLinks(null, docLinks), []);
-    assert.deepEqual(element._getDocLinks('', docLinks), []);
-    assert.deepEqual(element._getDocLinks('base', null), []);
-    assert.deepEqual(element._getDocLinks('base', []), []);
-
-    assert.deepEqual(element._getDocLinks('base', docLinks), [{
-      name: 'Table of Contents',
-      target: '_blank',
-      url: 'base/index.html',
-    }]);
-
-    assert.deepEqual(element._getDocLinks('base/', docLinks), [{
-      name: 'Table of Contents',
-      target: '_blank',
-      url: 'base/index.html',
-    }]);
-  });
-
-  test('top menus', () => {
-    const adminLinks = [{
-      name: 'Repos',
-      url: '/repos',
-    }];
-    const topMenus = [{
-      name: 'Plugins',
-      items: [{
-        name: 'Manage',
-        target: '_blank',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }],
-    }];
-    assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
-        /* userLinks= */ [],
-        adminLinks,
-        topMenus,
-        /* baseDocUrl= */ ''
-    ), [{
-      title: 'Browse',
-      links: adminLinks,
-    },
-    {
-      title: 'Plugins',
-      links: [{
-        name: 'Manage',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }],
-    }]);
-  });
-
-  test('ignore top project menus', () => {
-    const adminLinks = [{
-      name: 'Repos',
-      url: '/repos',
-    }];
-    const topMenus = [{
-      name: 'Projects',
-      items: [{
-        name: 'Project Settings',
-        target: '_blank',
-        url: '/plugins/myplugin/${projectName}',
-      }, {
-        name: 'Project List',
-        target: '_blank',
-        url: '/plugins/myplugin/index.html',
-      }],
-    }];
-    assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
-        /* userLinks= */ [],
-        adminLinks,
-        topMenus,
-        /* baseDocUrl= */ ''
-    ), [{
-      title: 'Browse',
-      links: adminLinks,
-    },
-    {
-      title: 'Projects',
-      links: [{
-        name: 'Project List',
-        url: '/plugins/myplugin/index.html',
-      }],
-    }]);
-  });
-
-  test('merge top menus', () => {
-    const adminLinks = [{
-      name: 'Repos',
-      url: '/repos',
-    }];
-    const topMenus = [{
-      name: 'Plugins',
-      items: [{
-        name: 'Manage',
-        target: '_blank',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }],
-    }, {
-      name: 'Plugins',
-      items: [{
-        name: 'Create',
-        target: '_blank',
-        url: 'https://gerrit/plugins/plugin-manager/static/create.html',
-      }],
-    }];
-    assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
-        /* userLinks= */ [],
-        adminLinks,
-        topMenus,
-        /* baseDocUrl= */ ''
-    ), [{
-      title: 'Browse',
-      links: adminLinks,
-    }, {
-      title: 'Plugins',
-      links: [{
-        name: 'Manage',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }, {
-        name: 'Create',
-        url: 'https://gerrit/plugins/plugin-manager/static/create.html',
-      }],
-    }]);
-  });
-
-  test('merge top menus in default links', () => {
-    const defaultLinks = [{
-      title: 'Faves',
-      links: [{
-        name: 'Pinterest',
-        url: 'https://pinterest.com',
-      }],
-    }];
-    const topMenus = [{
-      name: 'Faves',
-      items: [{
-        name: 'Manage',
-        target: '_blank',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }],
-    }];
-    assert.deepEqual(element._computeLinks(
-        defaultLinks,
-        /* userLinks= */ [],
-        /* adminLinks= */ [],
-        topMenus,
-        /* baseDocUrl= */ ''
-    ), [{
-      title: 'Faves',
-      links: defaultLinks[0].links.concat([{
-        name: 'Manage',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }]),
-    }, {
-      title: 'Browse',
-      links: [],
-    }]);
-  });
-
-  test('merge top menus in user links', () => {
-    const userLinks = [{
-      name: 'Facebook',
-      url: 'https://facebook.com',
-    }];
-    const topMenus = [{
-      name: 'Your',
-      items: [{
-        name: 'Manage',
-        target: '_blank',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }],
-    }];
-    assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
-        userLinks,
-        /* adminLinks= */ [],
-        topMenus,
-        /* baseDocUrl= */ ''
-    ), [{
-      title: 'Your',
-      links: userLinks.concat([{
-        name: 'Manage',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }]),
-    }, {
-      title: 'Browse',
-      links: [],
-    }]);
-  });
-
-  test('merge top menus in admin links', () => {
-    const adminLinks = [{
-      name: 'Repos',
-      url: '/repos',
-    }];
-    const topMenus = [{
-      name: 'Browse',
-      items: [{
-        name: 'Manage',
-        target: '_blank',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }],
-    }];
-    assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
-        /* userLinks= */ [],
-        adminLinks,
-        topMenus,
-        /* baseDocUrl= */ ''
-    ), [{
-      title: 'Browse',
-      links: adminLinks.concat([{
-        name: 'Manage',
-        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-      }]),
-    }]);
-  });
-
-  test('register URL', () => {
-    const config = {
-      auth: {
-        auth_type: 'LDAP',
-        register_url: 'https//gerrit.example.com/register',
-      },
-    };
-    element._retrieveRegisterURL(config);
-    assert.equal(element._registerURL, config.auth.register_url);
-    assert.equal(element._registerText, 'Sign up');
-
-    config.auth.register_text = 'Create account';
-    element._retrieveRegisterURL(config);
-    assert.equal(element._registerURL, config.auth.register_url);
-    assert.equal(element._registerText, config.auth.register_text);
-  });
-
-  test('register URL ignored for wrong auth type', () => {
-    const config = {
-      auth: {
-        auth_type: 'OPENID',
-        register_url: 'https//gerrit.example.com/register',
-      },
-    };
-    element._retrieveRegisterURL(config);
-    assert.equal(element._registerURL, null);
-    assert.equal(element._registerText, 'Sign up');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
new file mode 100644
index 0000000..b3ac40f
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
@@ -0,0 +1,390 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-main-header.js';
+
+const basicFixture = fixtureFromElement('gr-main-header');
+
+suite('gr-main-header tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      probePath(path) { return Promise.resolve(false); },
+    });
+    stub('gr-main-header', {
+      _loadAccount() {},
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('link visibility', () => {
+    element.loading = true;
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.accountContainer')).display,
+    'none');
+    element.loading = false;
+    element.loggedIn = false;
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.accountContainer')).display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.loginButton')).display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.registerButton')).display,
+    'none');
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('gr-account-dropdown')).display,
+    'none');
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.settingsButton')).display,
+    'none');
+    element.loggedIn = true;
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.loginButton')).display,
+    'none');
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.registerButton')).display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('gr-account-dropdown'))
+        .display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.settingsButton')).display,
+    'none');
+  });
+
+  test('fix my menu item', () => {
+    assert.deepEqual([
+      {url: 'https://awesometown.com/#hashyhash'},
+      {url: 'url', target: '_blank'},
+    ].map(element._fixCustomMenuItem), [
+      {url: 'https://awesometown.com/#hashyhash'},
+      {url: 'url'},
+    ]);
+  });
+
+  test('user links', () => {
+    const defaultLinks = [{
+      title: 'Faves',
+      links: [{
+        name: 'Pinterest',
+        url: 'https://pinterest.com',
+      }],
+    }];
+    const userLinks = [{
+      name: 'Facebook',
+      url: 'https://facebook.com',
+    }];
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+
+    // When no admin links are passed, it should use the default.
+    assert.deepEqual(element._computeLinks(
+        defaultLinks,
+        /* userLinks= */[],
+        adminLinks,
+        /* topMenus= */[],
+        /* docBaseUrl= */ ''
+    ),
+    defaultLinks.concat({
+      title: 'Browse',
+      links: adminLinks,
+    }));
+    assert.deepEqual(element._computeLinks(
+        defaultLinks,
+        userLinks,
+        adminLinks,
+        /* topMenus= */[],
+        /* docBaseUrl= */ ''
+    ),
+    defaultLinks.concat([
+      {
+        title: 'Your',
+        links: userLinks,
+      },
+      {
+        title: 'Browse',
+        links: adminLinks,
+      }])
+    );
+  });
+
+  test('documentation links', () => {
+    const docLinks = [
+      {
+        name: 'Table of Contents',
+        url: '/index.html',
+      },
+    ];
+
+    assert.deepEqual(element._getDocLinks(null, docLinks), []);
+    assert.deepEqual(element._getDocLinks('', docLinks), []);
+    assert.deepEqual(element._getDocLinks('base', null), []);
+    assert.deepEqual(element._getDocLinks('base', []), []);
+
+    assert.deepEqual(element._getDocLinks('base', docLinks), [{
+      name: 'Table of Contents',
+      target: '_blank',
+      url: 'base/index.html',
+    }]);
+
+    assert.deepEqual(element._getDocLinks('base/', docLinks), [{
+      name: 'Table of Contents',
+      target: '_blank',
+      url: 'base/index.html',
+    }]);
+  });
+
+  test('top menus', () => {
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+    const topMenus = [{
+      name: 'Plugins',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Browse',
+      links: adminLinks,
+    },
+    {
+      title: 'Plugins',
+      links: [{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }]);
+  });
+
+  test('ignore top project menus', () => {
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+    const topMenus = [{
+      name: 'Projects',
+      items: [{
+        name: 'Project Settings',
+        target: '_blank',
+        url: '/plugins/myplugin/${projectName}',
+      }, {
+        name: 'Project List',
+        target: '_blank',
+        url: '/plugins/myplugin/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Browse',
+      links: adminLinks,
+    },
+    {
+      title: 'Projects',
+      links: [{
+        name: 'Project List',
+        url: '/plugins/myplugin/index.html',
+      }],
+    }]);
+  });
+
+  test('merge top menus', () => {
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+    const topMenus = [{
+      name: 'Plugins',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }, {
+      name: 'Plugins',
+      items: [{
+        name: 'Create',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Browse',
+      links: adminLinks,
+    }, {
+      title: 'Plugins',
+      links: [{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }, {
+        name: 'Create',
+        url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+      }],
+    }]);
+  });
+
+  test('merge top menus in default links', () => {
+    const defaultLinks = [{
+      title: 'Faves',
+      links: [{
+        name: 'Pinterest',
+        url: 'https://pinterest.com',
+      }],
+    }];
+    const topMenus = [{
+      name: 'Faves',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        defaultLinks,
+        /* userLinks= */ [],
+        /* adminLinks= */ [],
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Faves',
+      links: defaultLinks[0].links.concat([{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }]),
+    }, {
+      title: 'Browse',
+      links: [],
+    }]);
+  });
+
+  test('merge top menus in user links', () => {
+    const userLinks = [{
+      name: 'Facebook',
+      url: 'https://facebook.com',
+    }];
+    const topMenus = [{
+      name: 'Your',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        userLinks,
+        /* adminLinks= */ [],
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Your',
+      links: userLinks.concat([{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }]),
+    }, {
+      title: 'Browse',
+      links: [],
+    }]);
+  });
+
+  test('merge top menus in admin links', () => {
+    const adminLinks = [{
+      name: 'Repos',
+      url: '/repos',
+    }];
+    const topMenus = [{
+      name: 'Browse',
+      items: [{
+        name: 'Manage',
+        target: '_blank',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }],
+    }];
+    assert.deepEqual(element._computeLinks(
+        /* defaultLinks= */ [],
+        /* userLinks= */ [],
+        adminLinks,
+        topMenus,
+        /* baseDocUrl= */ ''
+    ), [{
+      title: 'Browse',
+      links: adminLinks.concat([{
+        name: 'Manage',
+        url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+      }]),
+    }]);
+  });
+
+  test('register URL', () => {
+    const config = {
+      auth: {
+        auth_type: 'LDAP',
+        register_url: 'https//gerrit.example.com/register',
+      },
+    };
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, config.auth.register_url);
+    assert.equal(element._registerText, 'Sign up');
+
+    config.auth.register_text = 'Create account';
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, config.auth.register_url);
+    assert.equal(element._registerText, config.auth.register_text);
+  });
+
+  test('register URL ignored for wrong auth type', () => {
+    const config = {
+      auth: {
+        auth_type: 'OPENID',
+        register_url: 'https//gerrit.example.com/register',
+      },
+    };
+    element._retrieveRegisterURL(config);
+    assert.equal(element._registerURL, null);
+    assert.equal(element._registerText, 'Sign up');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
index 2b87548..3203e0c 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
@@ -86,7 +86,7 @@
 const EDIT_PATCHNUM = 'edit';
 const PARENT_PATCHNUM = 'PARENT';
 
-const USER_PLACEHOLDER_PATTERN = /\$\{user\}/g;
+const USER_PLACEHOLDER_PATTERN = /\${user}/g;
 
 // NOTE: These queries are tested in Java. Any changes made to definitions
 // here require corresponding changes to:
@@ -102,12 +102,21 @@
     suffixForDashboard: 'limit:10',
   },
   {
+    // Changes where the user is in the attention set.
+    name: 'Your Turn',
+    query: 'attention:${user}',
+    hideIfEmpty: false,
+    suffixForDashboard: 'limit:25',
+    attentionSetOnly: true,
+  },
+  {
     // Changes that are assigned to the viewed user.
     name: 'Assigned reviews',
     query: 'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
         'is:open -is:ignored',
     hideIfEmpty: true,
     suffixForDashboard: 'limit:25',
+    assigneeOnly: true,
   },
   {
     // WIP open changes owned by viewing user. This section is omitted when
@@ -219,8 +228,10 @@
   /**
    * Setup router implementation.
    *
-   * @param {function(!string)} navigate the router-abstracted equivalent of
-   *     `window.location.href = ...`. Takes a string.
+   * @param {function(!string, boolean=)} navigate the router-abstracted equivalent of
+   *     `window.location.href = ...` or window.location.replace(...). The
+   *     string is a new location and boolean defines is it redirect or not
+   *     (true means redirect, i.e. equivalent of window.location.replace).
    * @param {function(!Object): string} generateUrl generates a URL given
    *     navigation parameters, detailed in the file header.
    * @param {function(!Object): string} generateWeblinks weblinks generator
@@ -408,10 +419,15 @@
    * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
    *     used for none.
    * @param {boolean=} opt_isEdit
+   * @param {boolean=} opt_redirect redirect to a change - if true, the current
+   *     location (i.e. page which makes redirect) is not added to a history.
+   *     I.e. back/forward buttons skip current location
+   *
    */
-  navigateToChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit) {
+  navigateToChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit,
+      opt_redirect) {
     this._navigate(this.getUrlForChange(change, opt_patchNum,
-        opt_basePatchNum, opt_isEdit));
+        opt_basePatchNum, opt_isEdit), opt_redirect);
   },
 
   /**
@@ -432,6 +448,23 @@
    * @param {number} changeNum
    * @param {string} project The name of the project.
    * @param {string} path The file path.
+   * @param {number} patchNum
+   * @param {number} commentId
+   * @return {string}
+   */
+  getUrlForComment(changeNum, project, commentId) {
+    return this._getUrlFor({
+      view: GerritNav.View.DIFF,
+      changeNum,
+      project,
+      commentId,
+    });
+  },
+
+  /**
+   * @param {number} changeNum
+   * @param {string} project The name of the project.
+   * @param {string} path The file path.
    * @param {number=} opt_patchNum
    * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
    *     used for none.
@@ -462,11 +495,12 @@
    * @param {{ _number: number, project: string }} change The change object.
    * @param {string} path The file path.
    * @param {number=} opt_patchNum
+   * @param {number=} opt_lineNum
    * @return {string}
    */
-  getEditUrlForDiff(change, path, opt_patchNum) {
+  getEditUrlForDiff(change, path, opt_patchNum, opt_lineNum) {
     return this.getEditUrlForDiffById(change._number, change.project, path,
-        opt_patchNum);
+        opt_patchNum, opt_lineNum);
   },
 
   /**
@@ -475,15 +509,17 @@
    * @param {string} path The file path.
    * @param {number|string=} opt_patchNum The patchNum the file content
    *    should be based on, or ${EDIT_PATCHNUM} if left undefined.
+   * @param {number=} opt_lineNum The line number to pass to the inline editor.
    * @return {string}
    */
-  getEditUrlForDiffById(changeNum, project, path, opt_patchNum) {
+  getEditUrlForDiffById(changeNum, project, path, opt_patchNum, opt_lineNum) {
     return this._getUrlFor({
       view: GerritNav.View.EDIT,
       changeNum,
       project,
       path,
       patchNum: opt_patchNum || EDIT_PATCHNUM,
+      lineNum: opt_lineNum,
     });
   },
 
@@ -730,13 +766,19 @@
   },
 
   getUserDashboard(user = 'self', sections = DEFAULT_SECTIONS,
-      title = '') {
+      title = '', config = {}) {
+    const attentionEnabled =
+        config.change && !!config.change.enable_attention_set;
+    const assigneeEnabled =
+        config.change && !!config.change.enable_assignee;
     sections = sections
+        .filter(section => (attentionEnabled || !section.attentionSetOnly))
+        .filter(section => (assigneeEnabled || !section.assigneeOnly))
         .filter(section => (user === 'self' || !section.selfOnly))
-        .map(section => Object.assign({}, section, {
-          name: section.name,
-          query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
-        }));
+        .map(section => {
+          return {...section, name: section.name,
+            query: section.query.replace(USER_PLACEHOLDER_PATTERN, user)};
+        });
     return {title, sections};
   },
 };
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
deleted file mode 100644
index 8fc4c75..0000000
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
+++ /dev/null
@@ -1,88 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-navigation</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../../test/common-test-setup.js';
-import {GerritNav} from './gr-navigation.js';
-
-suite('gr-navigation tests', () => {
-  test('invalid patch ranges throw exceptions', () => {
-    assert.throw(() => GerritNav.getUrlForChange('123', undefined, 12));
-    assert.throw(() => GerritNav.getUrlForDiff('123', 'x.c', undefined, 12));
-  });
-
-  suite('_getUserDashboard', () => {
-    const sections = [
-      {name: 'section 1', query: 'query 1'},
-      {name: 'section 2', query: 'query 2 for ${user}'},
-      {name: 'section 3', query: 'self only query', selfOnly: true},
-      {name: 'section 4', query: 'query 4', suffixForDashboard: 'suffix'},
-    ];
-
-    test('dashboard for self', () => {
-      const dashboard =
-           GerritNav.getUserDashboard('self', sections, 'title');
-      assert.deepEqual(
-          dashboard,
-          {
-            title: 'title',
-            sections: [
-              {name: 'section 1', query: 'query 1'},
-              {name: 'section 2', query: 'query 2 for self'},
-              {
-                name: 'section 3',
-                query: 'self only query',
-                selfOnly: true,
-              }, {
-                name: 'section 4',
-                query: 'query 4',
-                suffixForDashboard: 'suffix',
-              },
-            ],
-          });
-    });
-
-    test('dashboard for other user', () => {
-      const dashboard =
-           GerritNav.getUserDashboard('user', sections, 'title');
-      assert.deepEqual(
-          dashboard,
-          {
-            title: 'title',
-            sections: [
-              {name: 'section 1', query: 'query 1'},
-              {name: 'section 2', query: 'query 2 for user'},
-              {
-                name: 'section 4',
-                query: 'query 4',
-                suffixForDashboard: 'suffix',
-              },
-            ],
-          });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.js b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.js
new file mode 100644
index 0000000..93a1e9e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.js
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {GerritNav} from './gr-navigation.js';
+
+suite('gr-navigation tests', () => {
+  test('invalid patch ranges throw exceptions', () => {
+    assert.throw(() => GerritNav.getUrlForChange('123', undefined, 12));
+    assert.throw(() => GerritNav.getUrlForDiff('123', 'x.c', undefined, 12));
+  });
+
+  suite('_getUserDashboard', () => {
+    const sections = [
+      {name: 'section 1', query: 'query 1'},
+      {name: 'section 2', query: 'query 2 for ${user}'},
+      {name: 'section 3', query: 'self only query', selfOnly: true},
+      {name: 'section 4', query: 'query 4', suffixForDashboard: 'suffix'},
+    ];
+
+    test('dashboard for self', () => {
+      const dashboard =
+           GerritNav.getUserDashboard('self', sections, 'title');
+      assert.deepEqual(
+          dashboard,
+          {
+            title: 'title',
+            sections: [
+              {name: 'section 1', query: 'query 1'},
+              {name: 'section 2', query: 'query 2 for self'},
+              {
+                name: 'section 3',
+                query: 'self only query',
+                selfOnly: true,
+              }, {
+                name: 'section 4',
+                query: 'query 4',
+                suffixForDashboard: 'suffix',
+              },
+            ],
+          });
+    });
+
+    test('dashboard for other user', () => {
+      const dashboard =
+           GerritNav.getUserDashboard('user', sections, 'title');
+      assert.deepEqual(
+          dashboard,
+          {
+            title: 'title',
+            sections: [
+              {name: 'section 1', query: 'query 1'},
+              {name: 'section 2', query: 'query 2 for user'},
+              {
+                name: 'section 4',
+                query: 'query 4',
+                suffixForDashboard: 'suffix',
+              },
+            ],
+          });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
deleted file mode 100644
index c8b4ff5..0000000
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ /dev/null
@@ -1,603 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {appContext} from '../../../services/app-context.js';
-
-// Latency reporting constants.
-const TIMING = {
-  TYPE: 'timing-report',
-  CATEGORY_UI_LATENCY: 'UI Latency',
-  CATEGORY_RPC: 'RPC Timing',
-  // Reported events - alphabetize below.
-  APP_STARTED: 'App Started',
-};
-
-// Plugin-related reporting constants.
-const PLUGINS = {
-  TYPE: 'lifecycle',
-  // Reported events - alphabetize below.
-  INSTALLED: 'Plugins installed',
-};
-
-// Chrome extension-related reporting constants.
-const EXTENSION = {
-  TYPE: 'lifecycle',
-  // Reported events - alphabetize below.
-  DETECTED: 'Extension detected',
-};
-
-// Navigation reporting constants.
-const NAVIGATION = {
-  TYPE: 'nav-report',
-  CATEGORY: 'Location Changed',
-  PAGE: 'Page',
-};
-
-const ERROR = {
-  TYPE: 'error',
-  CATEGORY: 'exception',
-};
-
-const ERROR_DIALOG = {
-  TYPE: 'error',
-  CATEGORY: 'Error Dialog',
-};
-
-const TIMER = {
-  CHANGE_DISPLAYED: 'ChangeDisplayed',
-  CHANGE_LOAD_FULL: 'ChangeFullyLoaded',
-  DASHBOARD_DISPLAYED: 'DashboardDisplayed',
-  DIFF_VIEW_CONTENT_DISPLAYED: 'DiffViewOnlyContent',
-  DIFF_VIEW_DISPLAYED: 'DiffViewDisplayed',
-  DIFF_VIEW_LOAD_FULL: 'DiffViewFullyLoaded',
-  FILE_LIST_DISPLAYED: 'FileListDisplayed',
-  PLUGINS_LOADED: 'PluginsLoaded',
-  STARTUP_CHANGE_DISPLAYED: 'StartupChangeDisplayed',
-  STARTUP_CHANGE_LOAD_FULL: 'StartupChangeFullyLoaded',
-  STARTUP_DASHBOARD_DISPLAYED: 'StartupDashboardDisplayed',
-  STARTUP_DIFF_VIEW_CONTENT_DISPLAYED: 'StartupDiffViewOnlyContent',
-  STARTUP_DIFF_VIEW_DISPLAYED: 'StartupDiffViewDisplayed',
-  STARTUP_DIFF_VIEW_LOAD_FULL: 'StartupDiffViewFullyLoaded',
-  STARTUP_FILE_LIST_DISPLAYED: 'StartupFileListDisplayed',
-  WEB_COMPONENTS_READY: 'WebComponentsReady',
-  METRICS_PLUGIN_LOADED: 'MetricsPluginLoaded',
-};
-
-const STARTUP_TIMERS = {};
-STARTUP_TIMERS[TIMER.PLUGINS_LOADED] = 0;
-STARTUP_TIMERS[TIMER.METRICS_PLUGIN_LOADED] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_CHANGE_DISPLAYED] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_CHANGE_LOAD_FULL] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_DASHBOARD_DISPLAYED] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_DISPLAYED] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_LOAD_FULL] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_FILE_LIST_DISPLAYED] = 0;
-STARTUP_TIMERS[TIMING.APP_STARTED] = 0;
-// WebComponentsReady timer is triggered from gr-router.
-STARTUP_TIMERS[TIMER.WEB_COMPONENTS_READY] = 0;
-
-const INTERACTION_TYPE = 'interaction';
-
-const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
-const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
-
-let pending = [];
-let slowRpcList = [];
-const SLOW_RPC_THRESHOLD = 500;
-
-// Variables that hold context info in global scope
-let reportRepoName = undefined;
-
-const onError = function(oldOnError, msg, url, line, column, error) {
-  if (oldOnError) {
-    oldOnError(msg, url, line, column, error);
-  }
-  if (error) {
-    line = line || error.lineNumber;
-    column = column || error.columnNumber;
-    let shortenedErrorStack = msg;
-    if (error.stack) {
-      const errorStackLines = error.stack.split('\n');
-      shortenedErrorStack = errorStackLines.slice(0,
-          Math.min(3, errorStackLines.length)).join('\n');
-    }
-    msg = shortenedErrorStack || error.toString();
-  }
-  const payload = {
-    url,
-    line,
-    column,
-    error,
-  };
-  GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload);
-  return true;
-};
-
-const catchErrors = function(opt_context) {
-  const context = opt_context || window;
-  context.onerror = onError.bind(null, context.onerror);
-  context.addEventListener('unhandledrejection', e => {
-    const msg = e.reason.message;
-    const payload = {
-      error: e.reason,
-    };
-    GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload);
-  });
-};
-catchErrors();
-
-// PerformanceObserver interface is a browser API.
-if (window.PerformanceObserver) {
-  const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || [];
-  // Safari doesn't support longtask yet
-  if (supportedEntryTypes.includes('longtask')) {
-    const catchLongJsTasks = new PerformanceObserver(list => {
-      for (const task of list.getEntries()) {
-        // We are interested in longtask longer than 200 ms (default is 50 ms)
-        if (task.duration > 200) {
-          GrReporting.prototype.reporter(TIMING.TYPE,
-              TIMING.CATEGORY_UI_LATENCY, `Task ${task.name}`,
-              Math.round(task.duration), {}, false);
-        }
-      }
-    });
-    catchLongJsTasks.observe({entryTypes: ['longtask']});
-  }
-}
-
-document.addEventListener('visibilitychange', () => {
-  const eventName = `Visibility changed to ${document.visibilityState}`;
-  GrReporting.prototype.reporter(INTERACTION_TYPE, undefined, eventName,
-      undefined, {}, true);
-});
-
-// The Polymer pass of JSCompiler requires this to be reassignable
-// eslint-disable-next-line prefer-const
-let GrReporting = Polymer({
-  is: 'gr-reporting',
-
-  properties: {
-    category: String,
-
-    _baselines: {
-      type: Object,
-      value: STARTUP_TIMERS, // Shared across all instances.
-    },
-
-    _timers: {
-      type: Object,
-      value: {timeBetweenDraftActions: null}, // Shared across all instances.
-    },
-  },
-
-  get performanceTiming() {
-    return window.performance.timing;
-  },
-
-  get slowRpcSnapshot() {
-    return slowRpcList.slice();
-  },
-
-  now() {
-    return Math.round(window.performance.now());
-  },
-
-  _arePluginsLoaded() {
-    return this._baselines &&
-      !this._baselines.hasOwnProperty(TIMER.PLUGINS_LOADED);
-  },
-
-  _isMetricsPluginLoaded() {
-    return this._arePluginsLoaded() || this._baselines &&
-      !this._baselines.hasOwnProperty(TIMER.METRICS_PLUGIN_LOADED);
-  },
-
-  /**
-   * Reporter reports events. Events will be queued if metrics plugin is not
-   * yet installed.
-   *
-   * @param {string} type
-   * @param {string} category
-   * @param {string} eventName
-   * @param {string|number} eventValue
-   * @param {Object} eventDetails
-   * @param {boolean|undefined} opt_noLog If true, the event will not be
-   *     logged to the JS console.
-   */
-  reporter(type, category, eventName, eventValue, eventDetails, opt_noLog) {
-    const eventInfo = this._createEventInfo(type, category,
-        eventName, eventValue, eventDetails);
-    if (type === ERROR.TYPE && category === ERROR.CATEGORY) {
-      console.error(eventValue && eventValue.error || eventName);
-    }
-
-    // We report events immediately when metrics plugin is loaded
-    if (this._isMetricsPluginLoaded() && !pending.length) {
-      this._reportEvent(eventInfo, opt_noLog);
-    } else {
-      // We cache until metrics plugin is loaded
-      pending.push([eventInfo, opt_noLog]);
-      if (this._isMetricsPluginLoaded()) {
-        pending.forEach(([eventInfo, opt_noLog]) => {
-          this._reportEvent(eventInfo, opt_noLog);
-        });
-        pending = [];
-      }
-    }
-  },
-
-  _reportEvent(eventInfo, opt_noLog) {
-    const {type, value, name} = eventInfo;
-    document.dispatchEvent(new CustomEvent(type, {detail: eventInfo}));
-    if (opt_noLog) { return; }
-    if (type !== ERROR.TYPE) {
-      if (value !== undefined) {
-        console.log(`Reporting: ${name}: ${value}`);
-      } else {
-        console.log(`Reporting: ${name}`);
-      }
-    }
-  },
-
-  _createEventInfo(type, category, name, value, eventDetails) {
-    const eventInfo = {
-      type,
-      category,
-      name,
-      value,
-      eventStart: this.now(),
-    };
-
-    if (typeof(eventDetails) === 'object' &&
-      Object.entries(eventDetails).length !== 0) {
-      eventInfo.eventDetails = JSON.stringify(eventDetails);
-    }
-
-    if (reportRepoName) {
-      eventInfo.repoName = reportRepoName;
-    }
-
-    const isInBackgroundTab = document.visibilityState === 'hidden';
-    if (isInBackgroundTab !== undefined) {
-      eventInfo.inBackgroundTab = isInBackgroundTab;
-    }
-
-    const enabledExperiments = appContext.flagsService.enabledExperiments;
-    if (enabledExperiments.length) {
-      eventInfo.enabledExperiments = JSON.stringify(enabledExperiments);
-    }
-
-    return eventInfo;
-  },
-
-  /**
-   * User-perceived app start time, should be reported when the app is ready.
-   */
-  appStarted() {
-    this.timeEnd(TIMING.APP_STARTED);
-    this._reportNavResTimes();
-  },
-
-  /**
-   * Browser's navigation and resource timings
-   */
-  _reportNavResTimes() {
-    const perfEvents = Object.keys(this.performanceTiming.toJSON());
-    perfEvents.forEach(
-        eventName => this._reportPerformanceTiming(eventName)
-    );
-  },
-
-  _reportPerformanceTiming(eventName, eventDetails) {
-    const eventTiming = this.performanceTiming[eventName];
-    if (eventTiming > 0) {
-      const elapsedTime = eventTiming -
-          this.performanceTiming.navigationStart;
-      // NavResTime - Navigation and resource timings.
-      this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY,
-          `NavResTime - ${eventName}`, elapsedTime, eventDetails, true);
-    }
-  },
-
-  beforeLocationChanged() {
-    for (const prop of Object.keys(this._baselines)) {
-      delete this._baselines[prop];
-    }
-    this.time(TIMER.CHANGE_DISPLAYED);
-    this.time(TIMER.CHANGE_LOAD_FULL);
-    this.time(TIMER.DASHBOARD_DISPLAYED);
-    this.time(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
-    this.time(TIMER.DIFF_VIEW_DISPLAYED);
-    this.time(TIMER.DIFF_VIEW_LOAD_FULL);
-    this.time(TIMER.FILE_LIST_DISPLAYED);
-    reportRepoName = undefined;
-    // reset slow rpc list since here start page loads which report these rpcs
-    slowRpcList = [];
-  },
-
-  locationChanged(page) {
-    this.reporter(
-        NAVIGATION.TYPE, NAVIGATION.CATEGORY, NAVIGATION.PAGE, page);
-  },
-
-  dashboardDisplayed() {
-    if (this._baselines.hasOwnProperty(TIMER.STARTUP_DASHBOARD_DISPLAYED)) {
-      this.timeEnd(TIMER.STARTUP_DASHBOARD_DISPLAYED, this._pageLoadDetails());
-    } else {
-      this.timeEnd(TIMER.DASHBOARD_DISPLAYED, this._pageLoadDetails());
-    }
-  },
-
-  changeDisplayed() {
-    if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_DISPLAYED)) {
-      this.timeEnd(TIMER.STARTUP_CHANGE_DISPLAYED, this._pageLoadDetails());
-    } else {
-      this.timeEnd(TIMER.CHANGE_DISPLAYED, this._pageLoadDetails());
-    }
-  },
-
-  changeFullyLoaded() {
-    if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_LOAD_FULL)) {
-      this.timeEnd(TIMER.STARTUP_CHANGE_LOAD_FULL);
-    } else {
-      this.timeEnd(TIMER.CHANGE_LOAD_FULL);
-    }
-  },
-
-  diffViewDisplayed() {
-    if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_DISPLAYED)) {
-      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
-    } else {
-      this.timeEnd(TIMER.DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
-    }
-  },
-
-  diffViewFullyLoaded() {
-    if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL)) {
-      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL);
-    } else {
-      this.timeEnd(TIMER.DIFF_VIEW_LOAD_FULL);
-    }
-  },
-
-  diffViewContentDisplayed() {
-    if (this._baselines.hasOwnProperty(
-        TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED)) {
-      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED);
-    } else {
-      this.timeEnd(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
-    }
-  },
-
-  fileListDisplayed() {
-    if (this._baselines.hasOwnProperty(TIMER.STARTUP_FILE_LIST_DISPLAYED)) {
-      this.timeEnd(TIMER.STARTUP_FILE_LIST_DISPLAYED);
-    } else {
-      this.timeEnd(TIMER.FILE_LIST_DISPLAYED);
-    }
-  },
-
-  _pageLoadDetails() {
-    const details = {
-      rpcList: this.slowRpcSnapshot,
-    };
-
-    if (window.screen) {
-      details.screenSize = {
-        width: window.screen.width,
-        height: window.screen.height,
-      };
-    }
-
-    if (document && document.documentElement) {
-      details.viewport = {
-        width: document.documentElement.clientWidth,
-        height: document.documentElement.clientHeight,
-      };
-    }
-
-    if (window.performance && window.performance.memory) {
-      const toMb = bytes => Math.round((bytes / (1024 * 1024)) * 100) / 100;
-      details.usedJSHeapSizeMb =
-        toMb(window.performance.memory.usedJSHeapSize);
-    }
-
-    return details;
-  },
-
-  reportExtension(name) {
-    this.reporter(EXTENSION.TYPE, EXTENSION.DETECTED, name);
-  },
-
-  pluginLoaded(name) {
-    if (name.startsWith('metrics-')) {
-      this.timeEnd(TIMER.METRICS_PLUGIN_LOADED);
-    }
-  },
-
-  pluginsLoaded(pluginsList) {
-    this.timeEnd(TIMER.PLUGINS_LOADED);
-    this.reporter(
-        PLUGINS.TYPE, PLUGINS.INSTALLED, PLUGINS.INSTALLED, undefined,
-        {pluginsList: pluginsList || []}, true);
-  },
-
-  /**
-   * Reset named timer.
-   */
-  time(name) {
-    this._baselines[name] = this.now();
-    window.performance.mark(`${name}-start`);
-  },
-
-  /**
-   * Finish named timer and report it to server.
-   */
-  timeEnd(name, eventDetails) {
-    if (!this._baselines.hasOwnProperty(name)) { return; }
-    const baseTime = this._baselines[name];
-    delete this._baselines[name];
-    this._reportTiming(name, this.now() - baseTime, eventDetails);
-
-    // Finalize the interval. Either from a registered start mark or
-    // the navigation start time (if baseTime is 0).
-    if (baseTime !== 0) {
-      window.performance.measure(name, `${name}-start`);
-    } else {
-      // Microsft Edge does not handle the 2nd param correctly
-      // (if undefined).
-      window.performance.measure(name);
-    }
-  },
-
-  /**
-   * Reports just line timeEnd, but additionally reports an average given a
-   * denominator and a separate reporiting name for the average.
-   *
-   * @param {string} name Timing name.
-   * @param {string} averageName Average timing name.
-   * @param {number} denominator Number by which to divide the total to
-   *     compute the average.
-   */
-  timeEndWithAverage(name, averageName, denominator) {
-    if (!this._baselines.hasOwnProperty(name)) { return; }
-    const baseTime = this._baselines[name];
-    this.timeEnd(name);
-
-    // Guard against division by zero.
-    if (!denominator) { return; }
-    const time = this.now() - baseTime;
-    this._reportTiming(averageName, time / denominator);
-  },
-
-  /**
-   * Send a timing report with an arbitrary time value.
-   *
-   * @param {string} name Timing name.
-   * @param {number} time The time to report as an integer of milliseconds.
-   * @param {Object} eventDetails non sensitive details
-   */
-  _reportTiming(name, time, eventDetails) {
-    this.reporter(TIMING.TYPE, TIMING.CATEGORY_UI_LATENCY, name, time,
-        eventDetails);
-  },
-
-  /**
-   * Get a timer object to for reporing a user timing. The start time will be
-   * the time that the object has been created, and the end time will be the
-   * time that the "end" method is called on the object.
-   *
-   * @param {string} name Timing name.
-   * @returns {!Object} The timer object.
-   */
-  getTimer(name) {
-    let called = false;
-    let start;
-    let max = null;
-
-    const timer = {
-
-      // Clear the timer and reset the start time.
-      reset: () => {
-        called = false;
-        start = this.now();
-        return timer;
-      },
-
-      // Stop the timer and report the intervening time.
-      end: () => {
-        if (called) {
-          throw new Error(`Timer for "${name}" already ended.`);
-        }
-        called = true;
-        const time = this.now() - start;
-
-        // If a maximum is specified and the time exceeds it, do not report.
-        if (max && time > max) { return timer; }
-
-        this._reportTiming(name, time);
-        return timer;
-      },
-
-      // Set a maximum reportable time. If a maximum is set and the timer is
-      // ended after the specified amount of time, the value is not reported.
-      withMaximum(maximum) {
-        max = maximum;
-        return timer;
-      },
-    };
-
-    // The timer is initialized to its creation time.
-    return timer.reset();
-  },
-
-  /**
-   * Log timing information for an RPC.
-   *
-   * @param {string} anonymizedUrl The URL of the RPC with tokens obfuscated.
-   * @param {number} elapsed The time elapsed of the RPC.
-   */
-  reportRpcTiming(anonymizedUrl, elapsed) {
-    this.reporter(TIMING.TYPE, TIMING.CATEGORY_RPC, 'RPC-' + anonymizedUrl,
-        elapsed, {}, true);
-    if (elapsed >= SLOW_RPC_THRESHOLD) {
-      slowRpcList.push({anonymizedUrl, elapsed});
-    }
-  },
-
-  reportInteraction(eventName, details) {
-    this.reporter(INTERACTION_TYPE, this.category, eventName, undefined,
-        details, true);
-  },
-
-  /**
-   * A draft interaction was started. Update the time-betweeen-draft-actions
-   * timer.
-   */
-  recordDraftInteraction() {
-    // If there is no timer defined, then this is the first interaction.
-    // Set up the timer so that it's ready to record the intervening time when
-    // called again.
-    const timer = this._timers.timeBetweenDraftActions;
-    if (!timer) {
-      // Create a timer with a maximum length.
-      this._timers.timeBetweenDraftActions = this.getTimer(DRAFT_ACTION_TIMER)
-          .withMaximum(DRAFT_ACTION_TIMER_MAX);
-      return;
-    }
-
-    // Mark the time and reinitialize the timer.
-    timer.end().reset();
-  },
-
-  reportErrorDialog(message) {
-    this.reporter(ERROR_DIALOG.TYPE, ERROR_DIALOG.CATEGORY,
-        'ErrorDialog: ' + message, {error: new Error(message)});
-  },
-
-  setRepoName(repoName) {
-    reportRepoName = repoName;
-  },
-});
-
-window.GrReporting = GrReporting;
-// Expose onerror installation so it would be accessible from tests.
-window.GrReporting._catchErrors = catchErrors;
-window.GrReporting.STARTUP_TIMERS = Object.assign({}, STARTUP_TIMERS);
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
deleted file mode 100644
index e140d6c..0000000
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ /dev/null
@@ -1,437 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-reporting</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-reporting></gr-reporting>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-reporting.js';
-suite('gr-reporting tests', () => {
-  let element;
-  let sandbox;
-  let clock;
-  let fakePerformance;
-
-  const NOW_TIME = 100;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    clock = sinon.useFakeTimers(NOW_TIME);
-    element = fixture('basic');
-    element._baselines = Object.assign({}, GrReporting.STARTUP_TIMERS);
-    fakePerformance = {
-      navigationStart: 1,
-      loadEventEnd: 2,
-    };
-    fakePerformance.toJSON = () => fakePerformance;
-    sinon.stub(element, 'performanceTiming',
-        {get() { return fakePerformance; }});
-    sandbox.stub(element, 'reporter');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-    clock.restore();
-  });
-
-  test('appStarted', () => {
-    sandbox.stub(element, 'now').returns(42);
-    element.appStarted();
-    assert.isTrue(
-        element.reporter.calledWithMatch(
-            'timing-report', 'UI Latency', 'App Started', 42
-        ));
-    assert.isTrue(
-        element.reporter.calledWithExactly(
-            'timing-report', 'UI Latency', 'NavResTime - loadEventEnd',
-            fakePerformance.loadEventEnd - fakePerformance.navigationStart,
-            undefined, true)
-    );
-  });
-
-  test('WebComponentsReady', () => {
-    sandbox.stub(element, 'now').returns(42);
-    element.timeEnd('WebComponentsReady');
-    assert.isTrue(element.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'WebComponentsReady', 42
-    ));
-  });
-
-  test('beforeLocationChanged', () => {
-    element._baselines['garbage'] = 'monster';
-    sandbox.stub(element, 'time');
-    element.beforeLocationChanged();
-    assert.isTrue(element.time.calledWithExactly('DashboardDisplayed'));
-    assert.isTrue(element.time.calledWithExactly('ChangeDisplayed'));
-    assert.isTrue(element.time.calledWithExactly('ChangeFullyLoaded'));
-    assert.isTrue(element.time.calledWithExactly('DiffViewDisplayed'));
-    assert.isTrue(element.time.calledWithExactly('FileListDisplayed'));
-    assert.isFalse(element._baselines.hasOwnProperty('garbage'));
-  });
-
-  test('changeDisplayed', () => {
-    sandbox.spy(element, 'timeEnd');
-    element.changeDisplayed();
-    assert.isFalse(element.timeEnd.calledWith('ChangeDisplayed'));
-    assert.isTrue(element.timeEnd.calledWith('StartupChangeDisplayed'));
-    element.changeDisplayed();
-    assert.isTrue(element.timeEnd.calledWith('ChangeDisplayed'));
-  });
-
-  test('changeFullyLoaded', () => {
-    sandbox.spy(element, 'timeEnd');
-    element.changeFullyLoaded();
-    assert.isFalse(
-        element.timeEnd.calledWithExactly('ChangeFullyLoaded'));
-    assert.isTrue(
-        element.timeEnd.calledWithExactly('StartupChangeFullyLoaded'));
-    element.changeFullyLoaded();
-    assert.isTrue(element.timeEnd.calledWithExactly('ChangeFullyLoaded'));
-  });
-
-  test('diffViewDisplayed', () => {
-    sandbox.spy(element, 'timeEnd');
-    element.diffViewDisplayed();
-    assert.isFalse(element.timeEnd.calledWith('DiffViewDisplayed'));
-    assert.isTrue(element.timeEnd.calledWith('StartupDiffViewDisplayed'));
-    element.diffViewDisplayed();
-    assert.isTrue(element.timeEnd.calledWith('DiffViewDisplayed'));
-  });
-
-  test('fileListDisplayed', () => {
-    sandbox.spy(element, 'timeEnd');
-    element.fileListDisplayed();
-    assert.isFalse(
-        element.timeEnd.calledWithExactly('FileListDisplayed'));
-    assert.isTrue(
-        element.timeEnd.calledWithExactly('StartupFileListDisplayed'));
-    element.fileListDisplayed();
-    assert.isTrue(element.timeEnd.calledWithExactly('FileListDisplayed'));
-  });
-
-  test('dashboardDisplayed', () => {
-    sandbox.spy(element, 'timeEnd');
-    element.dashboardDisplayed();
-    assert.isFalse(element.timeEnd.calledWith('DashboardDisplayed'));
-    assert.isTrue(element.timeEnd.calledWith('StartupDashboardDisplayed'));
-    element.dashboardDisplayed();
-    assert.isTrue(element.timeEnd.calledWith('DashboardDisplayed'));
-  });
-
-  test('dashboardDisplayed details', () => {
-    sandbox.spy(element, 'timeEnd');
-    sandbox.stub(window, 'performance', {
-      memory: {
-        usedJSHeapSize: 1024 * 1024,
-      },
-      measure: () => {},
-    });
-    sandbox.stub(element, 'now').returns(42);
-    element.reportRpcTiming('/changes/*~*/comments', 500);
-    element.dashboardDisplayed();
-    assert.isTrue(
-        element.timeEnd.calledWithExactly('StartupDashboardDisplayed',
-            {rpcList: [
-              {
-                anonymizedUrl: '/changes/*~*/comments',
-                elapsed: 500,
-              },
-            ],
-            screenSize: {
-              width: window.screen.width,
-              height: window.screen.height,
-            },
-            viewport: {
-              width: document.documentElement.clientWidth,
-              height: document.documentElement.clientHeight,
-            },
-            usedJSHeapSizeMb: 1,
-            }
-        ));
-  });
-
-  test('time and timeEnd', () => {
-    const nowStub = sandbox.stub(element, 'now').returns(0);
-    element.time('foo');
-    nowStub.returns(1);
-    element.time('bar');
-    nowStub.returns(2);
-    element.timeEnd('bar');
-    nowStub.returns(3);
-    element.timeEnd('foo');
-    assert.isTrue(element.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'foo', 3
-    ));
-    assert.isTrue(element.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'bar', 1
-    ));
-  });
-
-  test('timer object', () => {
-    const nowStub = sandbox.stub(element, 'now').returns(100);
-    const timer = element.getTimer('foo-bar');
-    nowStub.returns(150);
-    timer.end();
-    assert.isTrue(element.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'foo-bar', 50));
-  });
-
-  test('timer object double call', () => {
-    const timer = element.getTimer('foo-bar');
-    timer.end();
-    assert.isTrue(element.reporter.calledOnce);
-    assert.throws(() => {
-      timer.end();
-    }, 'Timer for "foo-bar" already ended.');
-  });
-
-  test('timer object maximum', () => {
-    const nowStub = sandbox.stub(element, 'now').returns(100);
-    const timer = element.getTimer('foo-bar').withMaximum(100);
-    nowStub.returns(150);
-    timer.end();
-    assert.isTrue(element.reporter.calledOnce);
-
-    timer.reset();
-    nowStub.returns(260);
-    timer.end();
-    assert.isTrue(element.reporter.calledOnce);
-  });
-
-  test('recordDraftInteraction', () => {
-    const key = 'TimeBetweenDraftActions';
-    const nowStub = sandbox.stub(element, 'now').returns(100);
-    const timingStub = sandbox.stub(element, '_reportTiming');
-    element.recordDraftInteraction();
-    assert.isFalse(timingStub.called);
-
-    nowStub.returns(200);
-    element.recordDraftInteraction();
-    assert.isTrue(timingStub.calledOnce);
-    assert.equal(timingStub.lastCall.args[0], key);
-    assert.equal(timingStub.lastCall.args[1], 100);
-
-    nowStub.returns(350);
-    element.recordDraftInteraction();
-    assert.isTrue(timingStub.calledTwice);
-    assert.equal(timingStub.lastCall.args[0], key);
-    assert.equal(timingStub.lastCall.args[1], 150);
-
-    nowStub.returns(370 + 2 * 60 * 1000);
-    element.recordDraftInteraction();
-    assert.isFalse(timingStub.calledThrice);
-  });
-
-  test('timeEndWithAverage', () => {
-    const nowStub = sandbox.stub(element, 'now').returns(0);
-    nowStub.returns(1000);
-    element.time('foo');
-    nowStub.returns(1100);
-    element.timeEndWithAverage('foo', 'bar', 10);
-    assert.isTrue(element.reporter.calledTwice);
-    assert.isTrue(element.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'foo', 100));
-    assert.isTrue(element.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'bar', 10));
-  });
-
-  test('reportExtension', () => {
-    element.reportExtension('foo');
-    assert.isTrue(element.reporter.calledWithExactly(
-        'lifecycle', 'Extension detected', 'foo'
-    ));
-  });
-
-  test('reportInteraction', () => {
-    element.reporter.restore();
-    sandbox.spy(element, '_reportEvent');
-    element.pluginsLoaded(); // so we don't cache
-    element.reportInteraction('button-click', {name: 'sendReply'});
-    assert.isTrue(element._reportEvent.getCall(2).calledWithMatch(
-        {
-          type: 'interaction',
-          name: 'button-click',
-          eventDetails: JSON.stringify({name: 'sendReply'}),
-        }
-    ));
-  });
-
-  test('report start time', () => {
-    element.reporter.restore();
-    sandbox.stub(element, 'now').returns(42);
-    sandbox.spy(element, '_reportEvent');
-    const dispatchStub = sandbox.spy(document, 'dispatchEvent');
-    element.pluginsLoaded();
-    element.time('timeAction');
-    element.timeEnd('timeAction');
-    assert.isTrue(element._reportEvent.getCall(2).calledWithMatch(
-        {
-          type: 'timing-report',
-          category: 'UI Latency',
-          name: 'timeAction',
-          value: 0,
-          eventStart: 42,
-        }
-    ));
-    assert.equal(dispatchStub.getCall(2).args[0].detail.eventStart, 42);
-  });
-
-  suite('plugins', () => {
-    setup(() => {
-      element.reporter.restore();
-      sandbox.stub(element, '_reportEvent');
-    });
-
-    test('pluginsLoaded reports time', () => {
-      sandbox.stub(element, 'now').returns(42);
-      element.pluginsLoaded();
-      assert.isTrue(element._reportEvent.calledWithMatch(
-          {
-            type: 'timing-report',
-            category: 'UI Latency',
-            name: 'PluginsLoaded',
-            value: 42,
-          }
-      ));
-    });
-
-    test('pluginsLoaded reports plugins', () => {
-      element.pluginsLoaded(['foo', 'bar']);
-      assert.isTrue(element._reportEvent.calledWithMatch(
-          {
-            type: 'lifecycle',
-            category: 'Plugins installed',
-            eventDetails: JSON.stringify({pluginsList: ['foo', 'bar']}),
-          }
-      ));
-    });
-
-    test('caches reports if plugins are not loaded', () => {
-      element.timeEnd('foo');
-      assert.isFalse(element._reportEvent.called);
-    });
-
-    test('reports if plugins are loaded', () => {
-      element.pluginsLoaded();
-      assert.isTrue(element._reportEvent.called);
-    });
-
-    test('reports if metrics plugin xyz is loaded', () => {
-      element.pluginLoaded('metrics-xyz');
-      assert.isTrue(element._reportEvent.called);
-    });
-
-    test('reports cached events preserving order', () => {
-      element.time('foo');
-      element.time('bar');
-      element.timeEnd('foo');
-      element.pluginsLoaded();
-      element.timeEnd('bar');
-      assert.isTrue(element._reportEvent.getCall(0).calledWithMatch(
-          {type: 'timing-report', category: 'UI Latency', name: 'foo'}
-      ));
-      assert.isTrue(element._reportEvent.getCall(1).calledWithMatch(
-          {type: 'timing-report', category: 'UI Latency',
-            name: 'PluginsLoaded'}
-      ));
-      assert.isTrue(element._reportEvent.getCall(2).calledWithMatch(
-          {type: 'lifecycle', category: 'Plugins installed'}
-      ));
-      assert.isTrue(element._reportEvent.getCall(3).calledWithMatch(
-          {type: 'timing-report', category: 'UI Latency', name: 'bar'}
-      ));
-    });
-  });
-
-  test('search', () => {
-    element.locationChanged('_handleSomeRoute');
-    assert.isTrue(element.reporter.calledWithExactly(
-        'nav-report', 'Location Changed', 'Page', '_handleSomeRoute'));
-  });
-
-  suite('exception logging', () => {
-    let fakeWindow;
-    let reporter;
-
-    const emulateThrow = function(msg, url, line, column, error) {
-      return fakeWindow.onerror(msg, url, line, column, error);
-    };
-
-    setup(() => {
-      reporter = sandbox.stub(GrReporting.prototype, 'reporter');
-      fakeWindow = {
-        handlers: {},
-        addEventListener(type, handler) {
-          this.handlers[type] = handler;
-        },
-      };
-      sandbox.stub(console, 'error');
-      window.GrReporting._catchErrors(fakeWindow);
-    });
-
-    test('is reported', () => {
-      const error = new Error('bar');
-      error.stack = undefined;
-      emulateThrow('bar', 'http://url', 4, 2, error);
-      assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
-      const payload = reporter.lastCall.args[3];
-      assert.deepEqual(payload, {
-        url: 'http://url',
-        line: 4,
-        column: 2,
-        error,
-      });
-    });
-
-    test('is reported with 3 lines of stack', () => {
-      const error = new Error('bar');
-      emulateThrow('bar', 'http://url', 4, 2, error);
-      const expectedStack = error.stack.split('\n').slice(0, 3)
-          .join('\n');
-      assert.isTrue(reporter.calledWith('error', 'exception',
-          expectedStack));
-    });
-
-    test('prevent default event handler', () => {
-      assert.isTrue(emulateThrow());
-    });
-
-    test('unhandled rejection', () => {
-      fakeWindow.handlers['unhandledrejection']({
-        reason: {
-          message: 'bar',
-        },
-      });
-      assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index 11465ba..47ea525 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -14,20 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-reporting/gr-reporting.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import page from 'page/page.mjs';
 import {htmlTemplate} from './gr-router_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util.js';
 import {GerritNav} from '../gr-navigation/gr-navigation.js';
+import {appContext} from '../../../services/app-context.js';
+import {patchNumEquals} from '../../../utils/patch-set-util.js';
 
 const RoutePattern = {
   ROOT: '/',
@@ -137,6 +133,12 @@
   // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>],edit
   CHANGE_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(\d+))?,edit\/?$/,
 
+  // Matches /c/<project>/+/<changeNum>/comment/<commentId>/
+  // Navigates to the diff view
+  // This route is needed to resolve to patchNum vs latestPatchNum used in the
+  // links generated in the emails.
+  COMMENT: /^\/c\/(.+)\/\+\/(\d+)\/comment\/(\w+)\/?$/,
+
   // Matches
   // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..]<patchNum|edit>/<path>.
   // TODO(kaspern): Migrate completely to project based URLs, with backwards
@@ -145,7 +147,7 @@
   DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))))\/?$/,
 
   // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>]/<path>,edit[#lineNum]
-  DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(\#\d+)?$/,
+  DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(#\d+)?$/,
 
   // Matches non-project-relative
   // /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
@@ -161,7 +163,7 @@
 
   // Matches /c/<changeNum>/ /<URL tail>
   // Catches improperly encoded URLs (context: Issue 7100)
-  IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/\ \/(.+)$/,
+  IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/ \/(.+)$/,
 
   PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
 
@@ -170,6 +172,8 @@
   DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
 };
 
+export const _testOnly_RoutePattern = RoutePattern;
+
 /**
  * Pattern to recognize and parse the diff line locations as they appear in
  * the hash of diff URLs. In this format, a number on its own indicates that
@@ -197,7 +201,7 @@
 
 const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
 
-const REPO_TOKEN_PATTERN = /\$\{(project|repo)\}/g;
+const REPO_TOKEN_PATTERN = /\${(project|repo)}/g;
 
 // Polymer makes `app` intrinsically defined on the window by virtue of the
 // custom element having the id "app", but it is made explicit here.
@@ -205,28 +209,21 @@
 // gr-router and gr-app in the PolyGerritIndexHtml.soy file if needed
 const app = document.querySelector('#app');
 if (!app) {
-  console.log('No gr-app found (running tests)');
+  console.info('No gr-app found (running tests)');
 }
 
 // Setup listeners outside of the router component initialization.
 (function() {
-  const reporting = document.createElement('gr-reporting');
-
   window.addEventListener('WebComponentsReady', () => {
-    reporting.timeEnd('WebComponentsReady');
+    appContext.reportingService.timeEnd('WebComponentsReady');
   });
 })();
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrRouter extends mixinBehaviors( [
-  BaseUrlBehavior,
-  PatchSetBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrRouter extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-router'; }
@@ -247,6 +244,11 @@
     };
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   start() {
     if (!this._app) { return; }
     this._startRouter();
@@ -276,7 +278,7 @@
    * @return {string}
    */
   _generateUrl(params) {
-    const base = this.getBaseUrl();
+    const base = getBaseUrl();
     let url = '';
     const Views = GerritNav.View;
 
@@ -330,7 +332,7 @@
   }
 
   _firstCodeBrowserWeblink(weblinks) {
-    // This is an ordered whitelist of web link types that provide direct
+    // This is an ordered allowed list of web link types that provide direct
     // links to the commit in the url property.
     const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
     for (let i = 0; i < codeBrowserLinks.length; i++) {
@@ -381,34 +383,34 @@
     }
 
     if (params.query) {
-      return '/q/' + this.encodeURL(params.query, true) + offsetExpr;
+      return '/q/' + encodeURL(params.query, true) + offsetExpr;
     }
 
     const operators = [];
     if (params.owner) {
-      operators.push('owner:' + this.encodeURL(params.owner, false));
+      operators.push('owner:' + encodeURL(params.owner, false));
     }
     if (params.project) {
-      operators.push('project:' + this.encodeURL(params.project, false));
+      operators.push('project:' + encodeURL(params.project, false));
     }
     if (params.branch) {
-      operators.push('branch:' + this.encodeURL(params.branch, false));
+      operators.push('branch:' + encodeURL(params.branch, false));
     }
     if (params.topic) {
-      operators.push('topic:"' + this.encodeURL(params.topic, false) + '"');
+      operators.push('topic:"' + encodeURL(params.topic, false) + '"');
     }
     if (params.hashtag) {
       operators.push('hashtag:"' +
-          this.encodeURL(params.hashtag.toLowerCase(), false) + '"');
+          encodeURL(params.hashtag.toLowerCase(), false) + '"');
     }
     if (params.statuses) {
       if (params.statuses.length === 1) {
         operators.push(
-            'status:' + this.encodeURL(params.statuses[0], false));
+            'status:' + encodeURL(params.statuses[0], false));
       } else if (params.statuses.length > 1) {
         operators.push(
             '(' +
-            params.statuses.map(s => `status:${this.encodeURL(s, false)}`)
+            params.statuses.map(s => `status:${encodeURL(s, false)}`)
                 .join(' OR ') +
             ')');
       }
@@ -434,7 +436,7 @@
       suffix += params.messageHash;
     }
     if (params.project) {
-      const encodedProject = this.encodeURL(params.project, true);
+      const encodedProject = encodeURL(params.project, true);
       return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
     } else {
       return `/c/${params.changeNum}${suffix}`;
@@ -458,7 +460,7 @@
       return `/dashboard/${user}?${queryParams.join('&')}`;
     } else if (repoName) {
       // Project dashboard.
-      const encodedRepo = this.encodeURL(repoName, true);
+      const encodedRepo = encodeURL(repoName, true);
       return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
     } else {
       // User dashboard.
@@ -491,7 +493,7 @@
     let range = this._getPatchRangeExpression(params);
     if (range.length) { range = '/' + range; }
 
-    let suffix = `${range}/${this.encodeURL(params.path, true)}`;
+    let suffix = `${range}/${encodeURL(params.path || '', true)}`;
 
     if (params.view === GerritNav.View.EDIT) { suffix += ',edit'; }
 
@@ -501,8 +503,12 @@
       suffix += params.lineNum;
     }
 
+    if (params.commentId) {
+      suffix = `/comment/${params.commentId}` + suffix;
+    }
+
     if (params.project) {
-      const encodedProject = this.encodeURL(params.project, true);
+      const encodedProject = encodeURL(params.project, true);
       return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
     } else {
       return `/c/${params.changeNum}${suffix}`;
@@ -514,7 +520,7 @@
    * @return {string}
    */
   _generateGroupUrl(params) {
-    let url = `/admin/groups/${this.encodeURL(params.groupId + '', true)}`;
+    let url = `/admin/groups/${encodeURL(params.groupId + '', true)}`;
     if (params.detail === GerritNav.GroupDetailView.MEMBERS) {
       url += ',members';
     } else if (params.detail === GerritNav.GroupDetailView.LOG) {
@@ -528,7 +534,7 @@
    * @return {string}
    */
   _generateRepoUrl(params) {
-    let url = `/admin/repos/${this.encodeURL(params.repoName + '', true)}`;
+    let url = `/admin/repos/${encodeURL(params.repoName + '', true)}`;
     if (params.detail === GerritNav.RepoDetailView.ACCESS) {
       url += ',access';
     } else if (params.detail === GerritNav.RepoDetailView.BRANCHES) {
@@ -607,7 +613,7 @@
     // Diffing a patch against itself is invalid, so if the base and revision
     // patches are equal clear the base.
     if (hasBasePatchNum &&
-        this.patchNumEquals(params.basePatchNum, params.patchNum)) {
+        patchNumEquals(params.basePatchNum, params.patchNum)) {
       needsRedirect = true;
       params.basePatchNum = null;
     } else if (hasBasePatchNum && !hasPatchNum) {
@@ -626,7 +632,7 @@
    * @param {string} returnUrl
    */
   _redirectToLogin(returnUrl) {
-    const basePath = this.getBaseUrl() || '';
+    const basePath = getBaseUrl() || '';
     page(
         '/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
   }
@@ -716,7 +722,7 @@
         (ctx, next) => this._loadUserMiddleware(ctx, next),
         (ctx, next) => this._queryStringMiddleware(ctx, next),
         data => {
-          this.$.reporting.locationChanged(handlerName);
+          this.reporting.locationChanged(handlerName);
           const promise = opt_authRedirect ?
             this._redirectIfNotLoggedIn(data) : Promise.resolve();
           promise.then(() => { this[handlerName](data); });
@@ -724,13 +730,19 @@
   }
 
   _startRouter() {
-    const base = this.getBaseUrl();
+    const base = getBaseUrl();
     if (base) {
       page.base(base);
     }
 
     GerritNav.setup(
-        url => { page.show(url); },
+        (url, opt_redirect) => {
+          if (opt_redirect) {
+            page.redirect(url);
+          } else {
+            page.show(url);
+          }
+        },
         this._generateUrl.bind(this),
         params => this._generateWeblinks(params),
         x => x
@@ -738,7 +750,7 @@
 
     page.exit('*', (ctx, next) => {
       if (!this._isRedirecting) {
-        this.$.reporting.beforeLocationChanged();
+        this.reporting.beforeLocationChanged();
       }
       this._isRedirecting = false;
       this._isInitialLoad = false;
@@ -877,6 +889,8 @@
 
     this._mapRoute(RoutePattern.CHANGE_EDIT, '_handleChangeEditRoute', true);
 
+    this._mapRoute(RoutePattern.COMMENT, '_handleCommentRoute');
+
     this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');
 
     this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');
@@ -946,7 +960,7 @@
         // See Issue 6888.
         hash = hash.replace('/ /', '/+/');
       }
-      const base = this.getBaseUrl();
+      const base = getBaseUrl();
       let newUrl = base + hash;
       if (hash.startsWith('/VE/')) {
         newUrl = base + '/settings' + hash;
@@ -1089,7 +1103,7 @@
       project,
       dashboard: decodeURIComponent(data.params[1]),
     });
-    this.$.reporting.setRepoName(project);
+    this.reporting.setRepoName(project);
   }
 
   _handleGroupInfoRoute(data) {
@@ -1170,7 +1184,7 @@
       detail: GerritNav.RepoDetailView.COMMANDS,
       repo,
     });
-    this.$.reporting.setRepoName(repo);
+    this.reporting.setRepoName(repo);
   }
 
   _handleRepoAccessRoute(data) {
@@ -1180,7 +1194,7 @@
       detail: GerritNav.RepoDetailView.ACCESS,
       repo,
     });
-    this.$.reporting.setRepoName(repo);
+    this.reporting.setRepoName(repo);
   }
 
   _handleRepoDashboardsRoute(data) {
@@ -1190,7 +1204,7 @@
       detail: GerritNav.RepoDetailView.DASHBOARDS,
       repo,
     });
-    this.$.reporting.setRepoName(repo);
+    this.reporting.setRepoName(repo);
   }
 
   _handleBranchListOffsetRoute(data) {
@@ -1296,7 +1310,7 @@
       view: GerritNav.View.REPO,
       repo,
     });
-    this.$.reporting.setRepoName(repo);
+    this.reporting.setRepoName(repo);
   }
 
   _handlePluginListOffsetRoute(data) {
@@ -1359,7 +1373,19 @@
       queryMap: ctx.queryMap,
     };
 
-    this.$.reporting.setRepoName(params.project);
+    this.reporting.setRepoName(params.project);
+    this._redirectOrNavigate(params);
+  }
+
+  _handleCommentRoute(ctx) {
+    const params = {
+      project: ctx.params[0],
+      changeNum: ctx.params[1],
+      commentId: ctx.params[2],
+      view: GerritNav.View.DIFF,
+      commentLink: true,
+    };
+    this.reporting.setRepoName(params.project);
     this._redirectOrNavigate(params);
   }
 
@@ -1373,13 +1399,12 @@
       path: ctx.params[8],
       view: GerritNav.View.DIFF,
     };
-
     const address = this._parseLineAddress(ctx.hash);
     if (address) {
       params.leftSide = address.leftSide;
       params.lineNum = address.lineNum;
     }
-    this.$.reporting.setRepoName(params.project);
+    this.reporting.setRepoName(params.project);
     this._redirectOrNavigate(params);
   }
 
@@ -1430,7 +1455,7 @@
       lineNum: ctx.hash,
       view: GerritNav.View.EDIT,
     });
-    this.$.reporting.setRepoName(project);
+    this.reporting.setRepoName(project);
   }
 
   _handleChangeEditRoute(ctx) {
@@ -1443,7 +1468,7 @@
       view: GerritNav.View.CHANGE,
       edit: true,
     });
-    this.$.reporting.setRepoName(project);
+    this.reporting.setRepoName(project);
   }
 
   /**
@@ -1491,7 +1516,7 @@
     if (path.startsWith('/register')) { path = '/'; }
 
     if (path[0] !== '/') { return; }
-    this._redirect(this.getBaseUrl() + path);
+    this._redirect(getBaseUrl() + path);
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_html.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.js
deleted file mode 100644
index 07f067e..0000000
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_html.js
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-reporting id="reporting"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts
new file mode 100644
index 0000000..91d8b41
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_html.ts
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
deleted file mode 100644
index b5bfd4e..0000000
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ /dev/null
@@ -1,1652 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-router</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-router></gr-router>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-router.js';
-import page from 'page/page.mjs';
-import {GerritNav} from '../gr-navigation/gr-navigation.js';
-
-suite('gr-router tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('_firstCodeBrowserWeblink', () => {
-    assert.deepEqual(element._firstCodeBrowserWeblink([
-      {name: 'gitweb'},
-      {name: 'gitiles'},
-      {name: 'browse'},
-      {name: 'test'}]), {name: 'gitiles'});
-
-    assert.deepEqual(element._firstCodeBrowserWeblink([
-      {name: 'gitweb'},
-      {name: 'test'}]), {name: 'gitweb'});
-  });
-
-  test('_getBrowseCommitWeblink', () => {
-    const browserLink = {name: 'browser', url: 'browser/url'};
-    const link = {name: 'test', url: 'test/url'};
-    const weblinks = [browserLink, link];
-    const config = {gerrit: {primary_weblink_name: browserLink.name}};
-    sandbox.stub(element, '_firstCodeBrowserWeblink').returns(link);
-
-    assert.deepEqual(element._getBrowseCommitWeblink(weblinks, config),
-        browserLink);
-
-    assert.deepEqual(element._getBrowseCommitWeblink(weblinks, {}), link);
-  });
-
-  test('_getChangeWeblinks', () => {
-    const link = {name: 'test', url: 'test/url'};
-    const browserLink = {name: 'browser', url: 'browser/url'};
-    const mapLinksToConfig = weblinks => { return {options: {weblinks}}; };
-    sandbox.stub(element, '_getBrowseCommitWeblink').returns(browserLink);
-
-    assert.deepEqual(
-        element._getChangeWeblinks(mapLinksToConfig([link, browserLink]))[0],
-        {name: 'test', url: 'test/url'});
-
-    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
-        {name: 'test', url: 'test/url'});
-
-    link.url = 'https://' + link.url;
-    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
-        {name: 'test', url: 'https://test/url'});
-  });
-
-  test('_getHashFromCanonicalPath', () => {
-    let url = '/foo/bar';
-    let hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, '');
-
-    url = '';
-    hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, '');
-
-    url = '/foo#bar';
-    hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, 'bar');
-
-    url = '/foo#bar#baz';
-    hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, 'bar#baz');
-
-    url = '#foo#bar#baz';
-    hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, 'foo#bar#baz');
-  });
-
-  suite('_parseLineAddress', () => {
-    test('returns null for empty and invalid hashes', () => {
-      let actual = element._parseLineAddress('');
-      assert.isNull(actual);
-
-      actual = element._parseLineAddress('foobar');
-      assert.isNull(actual);
-
-      actual = element._parseLineAddress('foo123');
-      assert.isNull(actual);
-
-      actual = element._parseLineAddress('123bar');
-      assert.isNull(actual);
-    });
-
-    test('parses correctly', () => {
-      let actual = element._parseLineAddress('1234');
-      assert.isOk(actual);
-      assert.equal(actual.lineNum, 1234);
-      assert.isFalse(actual.leftSide);
-
-      actual = element._parseLineAddress('a4');
-      assert.isOk(actual);
-      assert.equal(actual.lineNum, 4);
-      assert.isTrue(actual.leftSide);
-
-      actual = element._parseLineAddress('b77');
-      assert.isOk(actual);
-      assert.equal(actual.lineNum, 77);
-      assert.isTrue(actual.leftSide);
-    });
-  });
-
-  test('_startRouter requires auth for the right handlers', () => {
-    // This test encodes the lists of route handler methods that gr-router
-    // automatically checks for authentication before triggering.
-
-    const requiresAuth = {};
-    const doesNotRequireAuth = {};
-    sandbox.stub(GerritNav, 'setup');
-    sandbox.stub(page, 'start');
-    sandbox.stub(page, 'base');
-    sandbox.stub(element, '_mapRoute', (pattern, methodName, usesAuth) => {
-      if (usesAuth) {
-        requiresAuth[methodName] = true;
-      } else {
-        doesNotRequireAuth[methodName] = true;
-      }
-    });
-    element._startRouter();
-
-    const actualRequiresAuth = Object.keys(requiresAuth);
-    actualRequiresAuth.sort();
-    const actualDoesNotRequireAuth = Object.keys(doesNotRequireAuth);
-    actualDoesNotRequireAuth.sort();
-
-    const shouldRequireAutoAuth = [
-      '_handleAgreementsRoute',
-      '_handleChangeEditRoute',
-      '_handleCreateGroupRoute',
-      '_handleCreateProjectRoute',
-      '_handleDiffEditRoute',
-      '_handleGroupAuditLogRoute',
-      '_handleGroupInfoRoute',
-      '_handleGroupListFilterOffsetRoute',
-      '_handleGroupListFilterRoute',
-      '_handleGroupListOffsetRoute',
-      '_handleGroupMembersRoute',
-      '_handleGroupRoute',
-      '_handleGroupSelfRedirectRoute',
-      '_handleNewAgreementsRoute',
-      '_handlePluginListFilterOffsetRoute',
-      '_handlePluginListFilterRoute',
-      '_handlePluginListOffsetRoute',
-      '_handlePluginListRoute',
-      '_handleRepoCommandsRoute',
-      '_handleSettingsLegacyRoute',
-      '_handleSettingsRoute',
-    ];
-    assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
-
-    const unauthenticatedHandlers = [
-      '_handleBranchListFilterOffsetRoute',
-      '_handleBranchListFilterRoute',
-      '_handleBranchListOffsetRoute',
-      '_handleChangeNumberLegacyRoute',
-      '_handleChangeRoute',
-      '_handleDiffRoute',
-      '_handleDefaultRoute',
-      '_handleChangeLegacyRoute',
-      '_handleDiffLegacyRoute',
-      '_handleDocumentationRedirectRoute',
-      '_handleDocumentationSearchRoute',
-      '_handleDocumentationSearchRedirectRoute',
-      '_handleLegacyLinenum',
-      '_handleImproperlyEncodedPlusRoute',
-      '_handlePassThroughRoute',
-      '_handleProjectDashboardRoute',
-      '_handleProjectsOldRoute',
-      '_handleRepoAccessRoute',
-      '_handleRepoDashboardsRoute',
-      '_handleRepoListFilterOffsetRoute',
-      '_handleRepoListFilterRoute',
-      '_handleRepoListOffsetRoute',
-      '_handleRepoRoute',
-      '_handleQueryLegacySuffixRoute',
-      '_handleQueryRoute',
-      '_handleRegisterRoute',
-      '_handleTagListFilterOffsetRoute',
-      '_handleTagListFilterRoute',
-      '_handleTagListOffsetRoute',
-      '_handlePluginScreen',
-    ];
-
-    // Handler names that check authentication themselves, and thus don't need
-    // it performed for them.
-    const selfAuthenticatingHandlers = [
-      '_handleDashboardRoute',
-      '_handleCustomDashboardRoute',
-      '_handleRootRoute',
-    ];
-
-    const shouldNotRequireAuth = unauthenticatedHandlers
-        .concat(selfAuthenticatingHandlers);
-    shouldNotRequireAuth.sort();
-    assert.deepEqual(actualDoesNotRequireAuth, shouldNotRequireAuth);
-  });
-
-  test('_redirectIfNotLoggedIn while logged in', () => {
-    sandbox.stub(element.$.restAPI, 'getLoggedIn')
-        .returns(Promise.resolve(true));
-    const data = {canonicalPath: ''};
-    const redirectStub = sandbox.stub(element, '_redirectToLogin');
-    return element._redirectIfNotLoggedIn(data).then(() => {
-      assert.isFalse(redirectStub.called);
-    });
-  });
-
-  test('_redirectIfNotLoggedIn while logged out', () => {
-    sandbox.stub(element.$.restAPI, 'getLoggedIn')
-        .returns(Promise.resolve(false));
-    const redirectStub = sandbox.stub(element, '_redirectToLogin');
-    const data = {canonicalPath: ''};
-    return new Promise(resolve => {
-      element._redirectIfNotLoggedIn(data)
-          .then(() => {
-            assert.isTrue(false, 'Should never execute');
-          })
-          .catch(() => {
-            assert.isTrue(redirectStub.calledOnce);
-            resolve();
-          });
-    });
-  });
-
-  suite('generateUrl', () => {
-    test('search', () => {
-      let params = {
-        view: GerritNav.View.SEARCH,
-        owner: 'a%b',
-        project: 'c%d',
-        branch: 'e%f',
-        topic: 'g%h',
-        statuses: ['op%en'],
-      };
-      assert.equal(element._generateUrl(params),
-          '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-          'topic:"g%2525h"+status:op%2525en');
-
-      params.offset = 100;
-      assert.equal(element._generateUrl(params),
-          '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-          'topic:"g%2525h"+status:op%2525en,100');
-      delete params.offset;
-
-      // The presence of the query param overrides other params.
-      params.query = 'foo$bar';
-      assert.equal(element._generateUrl(params), '/q/foo%2524bar');
-
-      params.offset = 100;
-      assert.equal(element._generateUrl(params), '/q/foo%2524bar,100');
-
-      params = {
-        view: GerritNav.View.SEARCH,
-        statuses: ['a', 'b', 'c'],
-      };
-      assert.equal(element._generateUrl(params),
-          '/q/(status:a OR status:b OR status:c)');
-    });
-
-    test('change', () => {
-      const params = {
-        view: GerritNav.View.CHANGE,
-        changeNum: '1234',
-        project: 'test',
-      };
-      const paramsWithQuery = {
-        view: GerritNav.View.CHANGE,
-        changeNum: '1234',
-        project: 'test',
-        querystring: 'revert&foo=bar',
-      };
-
-      assert.equal(element._generateUrl(params), '/c/test/+/1234');
-      assert.equal(element._generateUrl(paramsWithQuery),
-          '/c/test/+/1234?revert&foo=bar');
-
-      params.patchNum = 10;
-      assert.equal(element._generateUrl(params), '/c/test/+/1234/10');
-      paramsWithQuery.patchNum = 10;
-      assert.equal(element._generateUrl(paramsWithQuery),
-          '/c/test/+/1234/10?revert&foo=bar');
-
-      params.basePatchNum = 5;
-      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10');
-      paramsWithQuery.basePatchNum = 5;
-      assert.equal(element._generateUrl(paramsWithQuery),
-          '/c/test/+/1234/5..10?revert&foo=bar');
-
-      params.messageHash = '#123';
-      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10#123');
-    });
-
-    test('change with repo name encoding', () => {
-      const params = {
-        view: GerritNav.View.CHANGE,
-        changeNum: '1234',
-        project: 'x+/y+/z+/w',
-      };
-      assert.equal(element._generateUrl(params),
-          '/c/x%252B/y%252B/z%252B/w/+/1234');
-    });
-
-    test('diff', () => {
-      const params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        path: 'x+y/path.cpp',
-        patchNum: 12,
-      };
-      assert.equal(element._generateUrl(params),
-          '/c/42/12/x%252By/path.cpp');
-
-      params.project = 'test';
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/12/x%252By/path.cpp');
-
-      params.basePatchNum = 6;
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/6..12/x%252By/path.cpp');
-
-      params.path = 'foo bar/my+file.txt%';
-      params.patchNum = 2;
-      delete params.basePatchNum;
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525');
-
-      params.path = 'file.cpp';
-      params.lineNum = 123;
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/2/file.cpp#123');
-
-      params.leftSide = true;
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/2/file.cpp#b123');
-    });
-
-    test('diff with repo name encoding', () => {
-      const params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        path: 'x+y/path.cpp',
-        patchNum: 12,
-        project: 'x+/y',
-      };
-      assert.equal(element._generateUrl(params),
-          '/c/x%252B/y/+/42/12/x%252By/path.cpp');
-    });
-
-    test('edit', () => {
-      const params = {
-        view: GerritNav.View.EDIT,
-        changeNum: '42',
-        project: 'test',
-        path: 'x+y/path.cpp',
-      };
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/x%252By/path.cpp,edit');
-    });
-
-    test('_getPatchRangeExpression', () => {
-      const params = {};
-      let actual = element._getPatchRangeExpression(params);
-      assert.equal(actual, '');
-
-      params.patchNum = 4;
-      actual = element._getPatchRangeExpression(params);
-      assert.equal(actual, '4');
-
-      params.basePatchNum = 2;
-      actual = element._getPatchRangeExpression(params);
-      assert.equal(actual, '2..4');
-
-      delete params.patchNum;
-      actual = element._getPatchRangeExpression(params);
-      assert.equal(actual, '2..');
-    });
-
-    suite('dashboard', () => {
-      test('self dashboard', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-        };
-        assert.equal(element._generateUrl(params), '/dashboard/self');
-      });
-
-      test('user dashboard', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          user: 'user',
-        };
-        assert.equal(element._generateUrl(params), '/dashboard/user');
-      });
-
-      test('custom self dashboard, no title', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          sections: [
-            {name: 'section 1', query: 'query 1'},
-            {name: 'section 2', query: 'query 2'},
-          ],
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/dashboard/?section%201=query%201&section%202=query%202');
-      });
-
-      test('custom repo dashboard', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          sections: [
-            {name: 'section 1', query: 'query 1 ${project}'},
-            {name: 'section 2', query: 'query 2 ${repo}'},
-          ],
-          repo: 'repo-name',
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/dashboard/?section%201=query%201%20repo-name&' +
-            'section%202=query%202%20repo-name');
-      });
-
-      test('custom user dashboard, with title', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          user: 'user',
-          sections: [{name: 'name', query: 'query'}],
-          title: 'custom dashboard',
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/dashboard/user?name=query&title=custom%20dashboard');
-      });
-
-      test('repo dashboard', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          repo: 'gerrit/repo',
-          dashboard: 'default:main',
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/p/gerrit/repo/+/dashboard/default:main');
-      });
-
-      test('project dashboard (legacy)', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          project: 'gerrit/project',
-          dashboard: 'default:main',
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/p/gerrit/project/+/dashboard/default:main');
-      });
-    });
-
-    suite('groups', () => {
-      test('group info', () => {
-        const params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-        };
-        assert.equal(element._generateUrl(params), '/admin/groups/1234');
-      });
-
-      test('group members', () => {
-        const params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-          detail: 'members',
-        };
-        assert.equal(element._generateUrl(params),
-            '/admin/groups/1234,members');
-      });
-
-      test('group audit log', () => {
-        const params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-          detail: 'log',
-        };
-        assert.equal(element._generateUrl(params),
-            '/admin/groups/1234,audit-log');
-      });
-    });
-  });
-
-  suite('param normalization', () => {
-    let projectLookupStub;
-
-    setup(() => {
-      projectLookupStub = sandbox
-          .stub(element.$.restAPI, 'getFromProjectLookup');
-      sandbox.stub(element, '_generateUrl');
-    });
-
-    suite('_normalizeLegacyRouteParams', () => {
-      let rangeStub;
-      let redirectStub;
-      let show404Stub;
-
-      setup(() => {
-        rangeStub = sandbox.stub(element, '_normalizePatchRangeParams')
-            .returns(Promise.resolve());
-        redirectStub = sandbox.stub(element, '_redirect');
-        show404Stub = sandbox.stub(element, '_show404');
-      });
-
-      test('w/o changeNum', () => {
-        projectLookupStub.returns(Promise.resolve('foo/bar'));
-        const params = {};
-        return element._normalizeLegacyRouteParams(params).then(() => {
-          assert.isFalse(projectLookupStub.called);
-          assert.isFalse(rangeStub.called);
-          assert.isNotOk(params.project);
-          assert.isFalse(redirectStub.called);
-          assert.isFalse(show404Stub.called);
-        });
-      });
-
-      test('w/ changeNum', () => {
-        projectLookupStub.returns(Promise.resolve('foo/bar'));
-        const params = {changeNum: 1234};
-        return element._normalizeLegacyRouteParams(params).then(() => {
-          assert.isTrue(projectLookupStub.called);
-          assert.isTrue(rangeStub.called);
-          assert.equal(params.project, 'foo/bar');
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isFalse(show404Stub.called);
-        });
-      });
-
-      test('halts on project lookup failure', () => {
-        projectLookupStub.returns(Promise.resolve(undefined));
-        const params = {changeNum: 1234};
-        return element._normalizeLegacyRouteParams(params).then(() => {
-          assert.isTrue(projectLookupStub.called);
-          assert.isFalse(rangeStub.called);
-          assert.isUndefined(params.project);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(show404Stub.calledOnce);
-        });
-      });
-    });
-
-    suite('_normalizePatchRangeParams', () => {
-      test('range n..n normalizes to n', () => {
-        const params = {basePatchNum: 4, patchNum: 4};
-        const needsRedirect = element._normalizePatchRangeParams(params);
-        assert.isTrue(needsRedirect);
-        assert.isNotOk(params.basePatchNum);
-        assert.equal(params.patchNum, 4);
-      });
-
-      test('range n.. normalizes to n', () => {
-        const params = {basePatchNum: 4};
-        const needsRedirect = element._normalizePatchRangeParams(params);
-        assert.isFalse(needsRedirect);
-        assert.isNotOk(params.basePatchNum);
-        assert.equal(params.patchNum, 4);
-      });
-    });
-  });
-
-  suite('route handlers', () => {
-    let redirectStub;
-    let setParamsStub;
-    let handlePassThroughRoute;
-
-    // Simple route handlers are direct mappings from parsed route data to a
-    // new set of app.params. This test helper asserts that passing `data`
-    // into `methodName` results in setting the params specified in `params`.
-    function assertDataToParams(data, methodName, params) {
-      element[methodName](data);
-      assert.deepEqual(setParamsStub.lastCall.args[0], params);
-    }
-
-    setup(() => {
-      redirectStub = sandbox.stub(element, '_redirect');
-      setParamsStub = sandbox.stub(element, '_setParams');
-      handlePassThroughRoute = sandbox.stub(element, '_handlePassThroughRoute');
-    });
-
-    test('_handleAgreementsRoute', () => {
-      const data = {params: {}};
-      element._handleAgreementsRoute(data);
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
-    });
-
-    test('_handleNewAgreementsRoute', () => {
-      element._handleNewAgreementsRoute({params: {}});
-      assert.isTrue(setParamsStub.calledOnce);
-      assert.equal(setParamsStub.lastCall.args[0].view,
-          GerritNav.View.AGREEMENTS);
-    });
-
-    test('_handleSettingsLegacyRoute', () => {
-      const data = {params: {0: 'my-token'}};
-      assertDataToParams(data, '_handleSettingsLegacyRoute', {
-        view: GerritNav.View.SETTINGS,
-        emailToken: 'my-token',
-      });
-    });
-
-    test('_handleSettingsLegacyRoute with +', () => {
-      const data = {params: {0: 'my-token test'}};
-      assertDataToParams(data, '_handleSettingsLegacyRoute', {
-        view: GerritNav.View.SETTINGS,
-        emailToken: 'my-token+test',
-      });
-    });
-
-    test('_handleSettingsRoute', () => {
-      const data = {};
-      assertDataToParams(data, '_handleSettingsRoute', {
-        view: GerritNav.View.SETTINGS,
-      });
-    });
-
-    test('_handleDefaultRoute on first load', () => {
-      const appElementStub = {dispatchEvent: sinon.stub()};
-      element._appElement = () => appElementStub;
-      element._handleDefaultRoute();
-      assert.isTrue(appElementStub.dispatchEvent.calledOnce);
-      assert.equal(
-          appElementStub.dispatchEvent.lastCall.args[0].detail.response.status,
-          404);
-    });
-
-    test('_handleDefaultRoute after internal navigation', () => {
-      let onExit = null;
-      const onRegisteringExit = (match, _onExit) => {
-        onExit = _onExit;
-      };
-      sandbox.stub(page, 'exit', onRegisteringExit);
-      sandbox.stub(GerritNav, 'setup');
-      sandbox.stub(page, 'start');
-      sandbox.stub(page, 'base');
-      element._startRouter();
-
-      const appElementStub = {dispatchEvent: sinon.stub()};
-      element._appElement = () => appElementStub;
-      element._handleDefaultRoute();
-
-      onExit('', () => {}); // we left page;
-
-      element._handleDefaultRoute();
-      assert.isTrue(handlePassThroughRoute.calledOnce);
-    });
-
-    test('_handleImproperlyEncodedPlusRoute', () => {
-      // Regression test for Issue 7100.
-      element._handleImproperlyEncodedPlusRoute(
-          {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(
-          redirectStub.lastCall.args[0],
-          '/c/test/+/42');
-
-      sandbox.stub(element, '_getHashFromCanonicalPath').returns('foo');
-      element._handleImproperlyEncodedPlusRoute(
-          {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
-      assert.equal(
-          redirectStub.lastCall.args[0],
-          '/c/test/+/42#foo');
-    });
-
-    test('_handleQueryRoute', () => {
-      const data = {params: ['project:foo/bar/baz']};
-      assertDataToParams(data, '_handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'project:foo/bar/baz',
-        offset: undefined,
-      });
-
-      data.params.push(',123', '123');
-      assertDataToParams(data, '_handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'project:foo/bar/baz',
-        offset: '123',
-      });
-    });
-
-    test('_handleQueryLegacySuffixRoute', () => {
-      element._handleQueryLegacySuffixRoute({path: '/q/foo+bar,n,z'});
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
-    });
-
-    test('_handleQueryRoute', () => {
-      const data = {params: ['project:foo/bar/baz']};
-      assertDataToParams(data, '_handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'project:foo/bar/baz',
-        offset: undefined,
-      });
-
-      data.params.push(',123', '123');
-      assertDataToParams(data, '_handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'project:foo/bar/baz',
-        offset: '123',
-      });
-    });
-
-    suite('_handleRegisterRoute', () => {
-      test('happy path', () => {
-        const ctx = {params: ['/foo/bar']};
-        element._handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-      });
-
-      test('no param', () => {
-        const ctx = {params: ['']};
-        element._handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-      });
-
-      test('prevent redirect', () => {
-        const ctx = {params: ['/register']};
-        element._handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-      });
-    });
-
-    suite('_handleRootRoute', () => {
-      test('closes for closeAfterLogin', () => {
-        const data = {querystring: 'closeAfterLogin', canonicalPath: ''};
-        const closeStub = sandbox.stub(window, 'close');
-        const result = element._handleRootRoute(data);
-        assert.isNotOk(result);
-        assert.isTrue(closeStub.called);
-        assert.isFalse(redirectStub.called);
-      });
-
-      test('redirects to dashboard if logged in', () => {
-        sandbox.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(true));
-        const data = {
-          canonicalPath: '/', path: '/', querystring: '', hash: '',
-        };
-        const result = element._handleRootRoute(data);
-        assert.isOk(result);
-        return result.then(() => {
-          assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
-        });
-      });
-
-      test('redirects to open changes if not logged in', () => {
-        sandbox.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(false));
-        const data = {
-          canonicalPath: '/', path: '/', querystring: '', hash: '',
-        };
-        const result = element._handleRootRoute(data);
-        assert.isOk(result);
-        return result.then(() => {
-          assert.isTrue(
-              redirectStub.calledWithExactly('/q/status:open+-is:wip'));
-        });
-      });
-
-      suite('GWT hash-path URLs', () => {
-        test('redirects hash-path URLs', () => {
-          const data = {
-            canonicalPath: '/#/foo/bar/baz',
-            hash: '/foo/bar/baz',
-            querystring: '',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
-        });
-
-        test('redirects hash-path URLs w/o leading slash', () => {
-          const data = {
-            canonicalPath: '/#foo/bar/baz',
-            querystring: '',
-            hash: 'foo/bar/baz',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
-        });
-
-        test('normalizes "/ /" in hash to "/+/"', () => {
-          const data = {
-            canonicalPath: '/#/foo/bar/+/123/4',
-            querystring: '',
-            hash: '/foo/bar/ /123/4',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
-        });
-
-        test('prepends baseurl to hash-path', () => {
-          const data = {
-            canonicalPath: '/#/foo/bar',
-            querystring: '',
-            hash: '/foo/bar',
-          };
-          sandbox.stub(element, 'getBaseUrl').returns('/baz');
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
-        });
-
-        test('normalizes /VE/ settings hash-paths', () => {
-          const data = {
-            canonicalPath: '/#/VE/foo/bar',
-            querystring: '',
-            hash: '/VE/foo/bar',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly(
-              '/settings/VE/foo/bar'));
-        });
-
-        test('does not drop "inner hashes"', () => {
-          const data = {
-            canonicalPath: '/#/foo/bar#baz',
-            querystring: '',
-            hash: '/foo/bar',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
-        });
-      });
-    });
-
-    suite('_handleDashboardRoute', () => {
-      let redirectToLoginStub;
-
-      setup(() => {
-        redirectToLoginStub = sandbox.stub(element, '_redirectToLogin');
-      });
-
-      test('own dashboard but signed out redirects to login', () => {
-        sandbox.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(false));
-        const data = {canonicalPath: '/dashboard/', params: {0: 'seLF'}};
-        return element._handleDashboardRoute(data, '').then(() => {
-          assert.isTrue(redirectToLoginStub.calledOnce);
-          assert.isFalse(redirectStub.called);
-          assert.isFalse(setParamsStub.called);
-        });
-      });
-
-      test('non-self dashboard but signed out does not redirect', () => {
-        sandbox.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(false));
-        const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
-        return element._handleDashboardRoute(data, '').then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(setParamsStub.called);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
-        });
-      });
-
-      test('dashboard while signed in sets params', () => {
-        sandbox.stub(element.$.restAPI, 'getLoggedIn')
-            .returns(Promise.resolve(true));
-        const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
-        return element._handleDashboardRoute(data, '').then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setParamsStub.calledOnce);
-          assert.deepEqual(setParamsStub.lastCall.args[0], {
-            view: GerritNav.View.DASHBOARD,
-            user: 'foo',
-          });
-        });
-      });
-    });
-
-    suite('_handleCustomDashboardRoute', () => {
-      let redirectToLoginStub;
-
-      setup(() => {
-        redirectToLoginStub = sandbox.stub(element, '_redirectToLogin');
-      });
-
-      test('no user specified', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-        return element._handleCustomDashboardRoute(data, '').then(() => {
-          assert.isFalse(setParamsStub.called);
-          assert.isTrue(redirectStub.called);
-          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
-        });
-      });
-
-      test('custom dashboard without title', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-        return element._handleCustomDashboardRoute(data, '?a=b&c&d=e')
-            .then(() => {
-              assert.isFalse(redirectStub.called);
-              assert.isTrue(setParamsStub.calledOnce);
-              assert.deepEqual(setParamsStub.lastCall.args[0], {
-                view: GerritNav.View.DASHBOARD,
-                user: 'self',
-                sections: [
-                  {name: 'a', query: 'b'},
-                  {name: 'd', query: 'e'},
-                ],
-                title: 'Custom Dashboard',
-              });
-            });
-      });
-
-      test('custom dashboard with title', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-        return element._handleCustomDashboardRoute(data,
-            '?a=b&c&d=&=e&title=t')
-            .then(() => {
-              assert.isFalse(redirectToLoginStub.called);
-              assert.isFalse(redirectStub.called);
-              assert.isTrue(setParamsStub.calledOnce);
-              assert.deepEqual(setParamsStub.lastCall.args[0], {
-                view: GerritNav.View.DASHBOARD,
-                user: 'self',
-                sections: [
-                  {name: 'a', query: 'b'},
-                ],
-                title: 't',
-              });
-            });
-      });
-
-      test('custom dashboard with foreach', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-        return element._handleCustomDashboardRoute(data,
-            '?a=b&c&d=&=e&foreach=is:open')
-            .then(() => {
-              assert.isFalse(redirectToLoginStub.called);
-              assert.isFalse(redirectStub.called);
-              assert.isTrue(setParamsStub.calledOnce);
-              assert.deepEqual(setParamsStub.lastCall.args[0], {
-                view: GerritNav.View.DASHBOARD,
-                user: 'self',
-                sections: [
-                  {name: 'a', query: 'is:open b'},
-                ],
-                title: 'Custom Dashboard',
-              });
-            });
-      });
-    });
-
-    suite('group routes', () => {
-      test('_handleGroupInfoRoute', () => {
-        const data = {params: {0: 1234}};
-        element._handleGroupInfoRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
-      });
-
-      test('_handleGroupAuditLogRoute', () => {
-        const data = {params: {0: 1234}};
-        assertDataToParams(data, '_handleGroupAuditLogRoute', {
-          view: GerritNav.View.GROUP,
-          detail: 'log',
-          groupId: 1234,
-        });
-      });
-
-      test('_handleGroupMembersRoute', () => {
-        const data = {params: {0: 1234}};
-        assertDataToParams(data, '_handleGroupMembersRoute', {
-          view: GerritNav.View.GROUP,
-          detail: 'members',
-          groupId: 1234,
-        });
-      });
-
-      test('_handleGroupListOffsetRoute', () => {
-        const data = {params: {}};
-        assertDataToParams(data, '_handleGroupListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          offset: 0,
-          filter: null,
-          openCreateModal: false,
-        });
-
-        data.params[1] = 42;
-        assertDataToParams(data, '_handleGroupListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          offset: 42,
-          filter: null,
-          openCreateModal: false,
-        });
-
-        data.hash = 'create';
-        assertDataToParams(data, '_handleGroupListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          offset: 42,
-          filter: null,
-          openCreateModal: true,
-        });
-      });
-
-      test('_handleGroupListFilterOffsetRoute', () => {
-        const data = {params: {filter: 'foo', offset: 42}};
-        assertDataToParams(data, '_handleGroupListFilterOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          offset: 42,
-          filter: 'foo',
-        });
-      });
-
-      test('_handleGroupListFilterRoute', () => {
-        const data = {params: {filter: 'foo'}};
-        assertDataToParams(data, '_handleGroupListFilterRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          filter: 'foo',
-        });
-      });
-
-      test('_handleGroupRoute', () => {
-        const data = {params: {0: 4321}};
-        assertDataToParams(data, '_handleGroupRoute', {
-          view: GerritNav.View.GROUP,
-          groupId: 4321,
-        });
-      });
-    });
-
-    suite('repo routes', () => {
-      test('_handleProjectsOldRoute', () => {
-        const data = {params: {}};
-        element._handleProjectsOldRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
-      });
-
-      test('_handleProjectsOldRoute test', () => {
-        const data = {params: {1: 'test'}};
-        element._handleProjectsOldRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
-      });
-
-      test('_handleProjectsOldRoute test,branches', () => {
-        const data = {params: {1: 'test,branches'}};
-        element._handleProjectsOldRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(
-            redirectStub.lastCall.args[0], '/admin/repos/test,branches');
-      });
-
-      test('_handleRepoRoute', () => {
-        const data = {params: {0: 4321}};
-        assertDataToParams(data, '_handleRepoRoute', {
-          view: GerritNav.View.REPO,
-          repo: 4321,
-        });
-      });
-
-      test('_handleRepoCommandsRoute', () => {
-        const data = {params: {0: 4321}};
-        assertDataToParams(data, '_handleRepoCommandsRoute', {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.COMMANDS,
-          repo: 4321,
-        });
-      });
-
-      test('_handleRepoAccessRoute', () => {
-        const data = {params: {0: 4321}};
-        assertDataToParams(data, '_handleRepoAccessRoute', {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.ACCESS,
-          repo: 4321,
-        });
-      });
-
-      suite('branch list routes', () => {
-        test('_handleBranchListOffsetRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleBranchListOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
-            repo: 4321,
-            offset: 0,
-            filter: null,
-          });
-
-          data.params[2] = 42;
-          assertDataToParams(data, '_handleBranchListOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
-            repo: 4321,
-            offset: 42,
-            filter: null,
-          });
-        });
-
-        test('_handleBranchListFilterOffsetRoute', () => {
-          const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
-          assertDataToParams(data, '_handleBranchListFilterOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
-            repo: 4321,
-            offset: 42,
-            filter: 'foo',
-          });
-        });
-
-        test('_handleBranchListFilterRoute', () => {
-          const data = {params: {repo: 4321, filter: 'foo'}};
-          assertDataToParams(data, '_handleBranchListFilterRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
-            repo: 4321,
-            filter: 'foo',
-          });
-        });
-      });
-
-      suite('tag list routes', () => {
-        test('_handleTagListOffsetRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleTagListOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
-            repo: 4321,
-            offset: 0,
-            filter: null,
-          });
-        });
-
-        test('_handleTagListFilterOffsetRoute', () => {
-          const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
-          assertDataToParams(data, '_handleTagListFilterOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
-            repo: 4321,
-            offset: 42,
-            filter: 'foo',
-          });
-        });
-
-        test('_handleTagListFilterRoute', () => {
-          const data = {params: {repo: 4321}};
-          assertDataToParams(data, '_handleTagListFilterRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
-            repo: 4321,
-            filter: null,
-          });
-
-          data.params.filter = 'foo';
-          assertDataToParams(data, '_handleTagListFilterRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
-            repo: 4321,
-            filter: 'foo',
-          });
-        });
-      });
-
-      suite('repo list routes', () => {
-        test('_handleRepoListOffsetRoute', () => {
-          const data = {params: {}};
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 0,
-            filter: null,
-            openCreateModal: false,
-          });
-
-          data.params[1] = 42;
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 42,
-            filter: null,
-            openCreateModal: false,
-          });
-
-          data.hash = 'create';
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 42,
-            filter: null,
-            openCreateModal: true,
-          });
-        });
-
-        test('_handleRepoListFilterOffsetRoute', () => {
-          const data = {params: {filter: 'foo', offset: 42}};
-          assertDataToParams(data, '_handleRepoListFilterOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 42,
-            filter: 'foo',
-          });
-        });
-
-        test('_handleRepoListFilterRoute', () => {
-          const data = {params: {}};
-          assertDataToParams(data, '_handleRepoListFilterRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            filter: null,
-          });
-
-          data.params.filter = 'foo';
-          assertDataToParams(data, '_handleRepoListFilterRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            filter: 'foo',
-          });
-        });
-      });
-    });
-
-    suite('plugin routes', () => {
-      test('_handlePluginListOffsetRoute', () => {
-        const data = {params: {}};
-        assertDataToParams(data, '_handlePluginListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          offset: 0,
-          filter: null,
-        });
-
-        data.params[1] = 42;
-        assertDataToParams(data, '_handlePluginListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          offset: 42,
-          filter: null,
-        });
-      });
-
-      test('_handlePluginListFilterOffsetRoute', () => {
-        const data = {params: {filter: 'foo', offset: 42}};
-        assertDataToParams(data, '_handlePluginListFilterOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          offset: 42,
-          filter: 'foo',
-        });
-      });
-
-      test('_handlePluginListFilterRoute', () => {
-        const data = {params: {}};
-        assertDataToParams(data, '_handlePluginListFilterRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          filter: null,
-        });
-
-        data.params.filter = 'foo';
-        assertDataToParams(data, '_handlePluginListFilterRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          filter: 'foo',
-        });
-      });
-
-      test('_handlePluginListRoute', () => {
-        const data = {params: {}};
-        assertDataToParams(data, '_handlePluginListRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-        });
-      });
-    });
-
-    suite('change/diff routes', () => {
-      test('_handleChangeNumberLegacyRoute', () => {
-        const data = {params: {0: 12345}};
-        element._handleChangeNumberLegacyRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
-      });
-
-      test('_handleChangeLegacyRoute', () => {
-        const normalizeRouteStub = sandbox.stub(element,
-            '_normalizeLegacyRouteParams');
-        const ctx = {
-          params: [
-            1234, // 0 Change number
-            null, // 1 Unused
-            null, // 2 Unused
-            6, // 3 Base patch number
-            null, // 4 Unused
-            9, // 5 Patch number
-          ],
-          querystring: '',
-        };
-        element._handleChangeLegacyRoute(ctx);
-        assert.isTrue(normalizeRouteStub.calledOnce);
-        assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
-          changeNum: 1234,
-          basePatchNum: 6,
-          patchNum: 9,
-          view: GerritNav.View.CHANGE,
-          querystring: '',
-        });
-      });
-
-      test('_handleDiffLegacyRoute', () => {
-        const normalizeRouteStub = sandbox.stub(element,
-            '_normalizeLegacyRouteParams');
-        const ctx = {
-          params: [
-            1234, // 0 Change number
-            null, // 1 Unused
-            3, // 2 Base patch number
-            null, // 3 Unused
-            8, // 4 Patch number
-            'foo/bar', // 5 Diff path
-          ],
-          path: '/c/1234/3..8/foo/bar',
-          hash: 'b123',
-        };
-        element._handleDiffLegacyRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.isTrue(normalizeRouteStub.calledOnce);
-        assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
-          changeNum: 1234,
-          basePatchNum: 3,
-          patchNum: 8,
-          view: GerritNav.View.DIFF,
-          path: 'foo/bar',
-          lineNum: 123,
-          leftSide: true,
-        });
-      });
-
-      test('_handleLegacyLinenum w/ @321', () => {
-        const ctx = {path: '/c/1234/3..8/foo/bar@321'};
-        element._handleLegacyLinenum(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(redirectStub.calledWithExactly(
-            '/c/1234/3..8/foo/bar#321'));
-      });
-
-      test('_handleLegacyLinenum w/ @b123', () => {
-        const ctx = {path: '/c/1234/3..8/foo/bar@b123'};
-        element._handleLegacyLinenum(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(redirectStub.calledWithExactly(
-            '/c/1234/3..8/foo/bar#b123'));
-      });
-
-      suite('_handleChangeRoute', () => {
-        let normalizeRangeStub;
-
-        function makeParams(path, hash) {
-          return {
-            params: [
-              'foo/bar', // 0 Project
-              1234, // 1 Change number
-              null, // 2 Unused
-              null, // 3 Unused
-              4, // 4 Base patch number
-              null, // 5 Unused
-              7, // 6 Patch number
-            ],
-            queryMap: new Map(),
-          };
-        }
-
-        setup(() => {
-          normalizeRangeStub = sandbox.stub(element,
-              '_normalizePatchRangeParams');
-          sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-        });
-
-        test('needs redirect', () => {
-          normalizeRangeStub.returns(true);
-          sandbox.stub(element, '_generateUrl').returns('foo');
-          const ctx = makeParams(null, '');
-          element._handleChangeRoute(ctx);
-          assert.isTrue(normalizeRangeStub.called);
-          assert.isFalse(setParamsStub.called);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isTrue(redirectStub.calledWithExactly('foo'));
-        });
-
-        test('change view', () => {
-          normalizeRangeStub.returns(false);
-          sandbox.stub(element, '_generateUrl').returns('foo');
-          const ctx = makeParams(null, '');
-          assertDataToParams(ctx, '_handleChangeRoute', {
-            view: GerritNav.View.CHANGE,
-            project: 'foo/bar',
-            changeNum: 1234,
-            basePatchNum: 4,
-            patchNum: 7,
-            queryMap: new Map(),
-          });
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRangeStub.called);
-        });
-      });
-
-      suite('_handleDiffRoute', () => {
-        let normalizeRangeStub;
-
-        function makeParams(path, hash) {
-          return {
-            params: [
-              'foo/bar', // 0 Project
-              1234, // 1 Change number
-              null, // 2 Unused
-              null, // 3 Unused
-              4, // 4 Base patch number
-              null, // 5 Unused
-              7, // 6 Patch number
-              null, // 7 Unused,
-              path, // 8 Diff path
-            ],
-            hash,
-          };
-        }
-
-        setup(() => {
-          normalizeRangeStub = sandbox.stub(element,
-              '_normalizePatchRangeParams');
-          sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-        });
-
-        test('needs redirect', () => {
-          normalizeRangeStub.returns(true);
-          sandbox.stub(element, '_generateUrl').returns('foo');
-          const ctx = makeParams(null, '');
-          element._handleDiffRoute(ctx);
-          assert.isTrue(normalizeRangeStub.called);
-          assert.isFalse(setParamsStub.called);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isTrue(redirectStub.calledWithExactly('foo'));
-        });
-
-        test('diff view', () => {
-          normalizeRangeStub.returns(false);
-          sandbox.stub(element, '_generateUrl').returns('foo');
-          const ctx = makeParams('foo/bar/baz', 'b44');
-          assertDataToParams(ctx, '_handleDiffRoute', {
-            view: GerritNav.View.DIFF,
-            project: 'foo/bar',
-            changeNum: 1234,
-            basePatchNum: 4,
-            patchNum: 7,
-            path: 'foo/bar/baz',
-            leftSide: true,
-            lineNum: 44,
-          });
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRangeStub.called);
-        });
-      });
-
-      test('_handleDiffEditRoute', () => {
-        const normalizeRangeSpy =
-            sandbox.spy(element, '_normalizePatchRangeParams');
-        sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-        const ctx = {
-          params: [
-            'foo/bar', // 0 Project
-            1234, // 1 Change number
-            3, // 2 Patch num
-            'foo/bar/baz', // 3 File path
-          ],
-        };
-        const appParams = {
-          project: 'foo/bar',
-          changeNum: 1234,
-          view: GerritNav.View.EDIT,
-          path: 'foo/bar/baz',
-          patchNum: 3,
-          lineNum: undefined,
-        };
-
-        element._handleDiffEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.isTrue(normalizeRangeSpy.calledOnce);
-        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-      });
-
-      test('_handleDiffEditRoute with lineNum', () => {
-        const normalizeRangeSpy =
-            sandbox.spy(element, '_normalizePatchRangeParams');
-        sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-        const ctx = {
-          params: [
-            'foo/bar', // 0 Project
-            1234, // 1 Change number
-            3, // 2 Patch num
-            'foo/bar/baz', // 3 File path
-          ],
-          hash: 4,
-        };
-        const appParams = {
-          project: 'foo/bar',
-          changeNum: 1234,
-          view: GerritNav.View.EDIT,
-          path: 'foo/bar/baz',
-          patchNum: 3,
-          lineNum: 4,
-        };
-
-        element._handleDiffEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.isTrue(normalizeRangeSpy.calledOnce);
-        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-      });
-
-      test('_handleChangeEditRoute', () => {
-        const normalizeRangeSpy =
-            sandbox.spy(element, '_normalizePatchRangeParams');
-        sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-        const ctx = {
-          params: [
-            'foo/bar', // 0 Project
-            1234, // 1 Change number
-            null,
-            3, // 3 Patch num
-          ],
-        };
-        const appParams = {
-          project: 'foo/bar',
-          changeNum: 1234,
-          view: GerritNav.View.CHANGE,
-          patchNum: 3,
-          edit: true,
-        };
-
-        element._handleChangeEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.isTrue(normalizeRangeSpy.calledOnce);
-        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-      });
-    });
-
-    test('_handlePluginScreen', () => {
-      const ctx = {params: ['foo', 'bar']};
-      assertDataToParams(ctx, '_handlePluginScreen', {
-        view: GerritNav.View.PLUGIN_SCREEN,
-        plugin: 'foo',
-        screen: 'bar',
-      });
-      assert.isFalse(redirectStub.called);
-    });
-  });
-
-  suite('_parseQueryString', () => {
-    test('empty queries', () => {
-      assert.deepEqual(element._parseQueryString(''), []);
-      assert.deepEqual(element._parseQueryString('?'), []);
-      assert.deepEqual(element._parseQueryString('??'), []);
-      assert.deepEqual(element._parseQueryString('&&&'), []);
-    });
-
-    test('url decoding', () => {
-      assert.deepEqual(element._parseQueryString('+'), [[' ', '']]);
-      assert.deepEqual(element._parseQueryString('???+%3d+'), [[' = ', '']]);
-      assert.deepEqual(
-          element._parseQueryString('%6e%61%6d%65=%76%61%6c%75%65'),
-          [['name', 'value']]);
-    });
-
-    test('multiple parameters', () => {
-      assert.deepEqual(
-          element._parseQueryString('a=b&c=d&e=f'),
-          [['a', 'b'], ['c', 'd'], ['e', 'f']]);
-      assert.deepEqual(
-          element._parseQueryString('&a=b&&&e=f&c'),
-          [['a', 'b'], ['e', 'f'], ['c', '']]);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
new file mode 100644
index 0000000..c640109
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
@@ -0,0 +1,1654 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-router.js';
+import page from 'page/page.mjs';
+import {GerritNav} from '../gr-navigation/gr-navigation.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
+import {_testOnly_RoutePattern} from './gr-router.js';
+
+const basicFixture = fixtureFromElement('gr-router');
+
+suite('gr-router tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_firstCodeBrowserWeblink', () => {
+    assert.deepEqual(element._firstCodeBrowserWeblink([
+      {name: 'gitweb'},
+      {name: 'gitiles'},
+      {name: 'browse'},
+      {name: 'test'}]), {name: 'gitiles'});
+
+    assert.deepEqual(element._firstCodeBrowserWeblink([
+      {name: 'gitweb'},
+      {name: 'test'}]), {name: 'gitweb'});
+  });
+
+  test('_getBrowseCommitWeblink', () => {
+    const browserLink = {name: 'browser', url: 'browser/url'};
+    const link = {name: 'test', url: 'test/url'};
+    const weblinks = [browserLink, link];
+    const config = {gerrit: {primary_weblink_name: browserLink.name}};
+    sinon.stub(element, '_firstCodeBrowserWeblink').returns(link);
+
+    assert.deepEqual(element._getBrowseCommitWeblink(weblinks, config),
+        browserLink);
+
+    assert.deepEqual(element._getBrowseCommitWeblink(weblinks, {}), link);
+  });
+
+  test('_getChangeWeblinks', () => {
+    const link = {name: 'test', url: 'test/url'};
+    const browserLink = {name: 'browser', url: 'browser/url'};
+    const mapLinksToConfig = weblinks => { return {options: {weblinks}}; };
+    sinon.stub(element, '_getBrowseCommitWeblink').returns(browserLink);
+
+    assert.deepEqual(
+        element._getChangeWeblinks(mapLinksToConfig([link, browserLink]))[0],
+        {name: 'test', url: 'test/url'});
+
+    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
+        {name: 'test', url: 'test/url'});
+
+    link.url = 'https://' + link.url;
+    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
+        {name: 'test', url: 'https://test/url'});
+  });
+
+  test('_getHashFromCanonicalPath', () => {
+    let url = '/foo/bar';
+    let hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, '');
+
+    url = '';
+    hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, '');
+
+    url = '/foo#bar';
+    hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, 'bar');
+
+    url = '/foo#bar#baz';
+    hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, 'bar#baz');
+
+    url = '#foo#bar#baz';
+    hash = element._getHashFromCanonicalPath(url);
+    assert.equal(hash, 'foo#bar#baz');
+  });
+
+  suite('_parseLineAddress', () => {
+    test('returns null for empty and invalid hashes', () => {
+      let actual = element._parseLineAddress('');
+      assert.isNull(actual);
+
+      actual = element._parseLineAddress('foobar');
+      assert.isNull(actual);
+
+      actual = element._parseLineAddress('foo123');
+      assert.isNull(actual);
+
+      actual = element._parseLineAddress('123bar');
+      assert.isNull(actual);
+    });
+
+    test('parses correctly', () => {
+      let actual = element._parseLineAddress('1234');
+      assert.isOk(actual);
+      assert.equal(actual.lineNum, 1234);
+      assert.isFalse(actual.leftSide);
+
+      actual = element._parseLineAddress('a4');
+      assert.isOk(actual);
+      assert.equal(actual.lineNum, 4);
+      assert.isTrue(actual.leftSide);
+
+      actual = element._parseLineAddress('b77');
+      assert.isOk(actual);
+      assert.equal(actual.lineNum, 77);
+      assert.isTrue(actual.leftSide);
+    });
+  });
+
+  test('_startRouter requires auth for the right handlers', () => {
+    // This test encodes the lists of route handler methods that gr-router
+    // automatically checks for authentication before triggering.
+
+    const requiresAuth = {};
+    const doesNotRequireAuth = {};
+    sinon.stub(GerritNav, 'setup');
+    sinon.stub(page, 'start');
+    sinon.stub(page, 'base');
+    sinon.stub(element, '_mapRoute').callsFake(
+        (pattern, methodName, usesAuth) => {
+          if (usesAuth) {
+            requiresAuth[methodName] = true;
+          } else {
+            doesNotRequireAuth[methodName] = true;
+          }
+        });
+    element._startRouter();
+
+    const actualRequiresAuth = Object.keys(requiresAuth);
+    actualRequiresAuth.sort();
+    const actualDoesNotRequireAuth = Object.keys(doesNotRequireAuth);
+    actualDoesNotRequireAuth.sort();
+
+    const shouldRequireAutoAuth = [
+      '_handleAgreementsRoute',
+      '_handleChangeEditRoute',
+      '_handleCreateGroupRoute',
+      '_handleCreateProjectRoute',
+      '_handleDiffEditRoute',
+      '_handleGroupAuditLogRoute',
+      '_handleGroupInfoRoute',
+      '_handleGroupListFilterOffsetRoute',
+      '_handleGroupListFilterRoute',
+      '_handleGroupListOffsetRoute',
+      '_handleGroupMembersRoute',
+      '_handleGroupRoute',
+      '_handleGroupSelfRedirectRoute',
+      '_handleNewAgreementsRoute',
+      '_handlePluginListFilterOffsetRoute',
+      '_handlePluginListFilterRoute',
+      '_handlePluginListOffsetRoute',
+      '_handlePluginListRoute',
+      '_handleRepoCommandsRoute',
+      '_handleSettingsLegacyRoute',
+      '_handleSettingsRoute',
+    ];
+    assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
+
+    const unauthenticatedHandlers = [
+      '_handleBranchListFilterOffsetRoute',
+      '_handleBranchListFilterRoute',
+      '_handleBranchListOffsetRoute',
+      '_handleChangeNumberLegacyRoute',
+      '_handleChangeRoute',
+      '_handleCommentRoute',
+      '_handleDiffRoute',
+      '_handleDefaultRoute',
+      '_handleChangeLegacyRoute',
+      '_handleDiffLegacyRoute',
+      '_handleDocumentationRedirectRoute',
+      '_handleDocumentationSearchRoute',
+      '_handleDocumentationSearchRedirectRoute',
+      '_handleLegacyLinenum',
+      '_handleImproperlyEncodedPlusRoute',
+      '_handlePassThroughRoute',
+      '_handleProjectDashboardRoute',
+      '_handleProjectsOldRoute',
+      '_handleRepoAccessRoute',
+      '_handleRepoDashboardsRoute',
+      '_handleRepoListFilterOffsetRoute',
+      '_handleRepoListFilterRoute',
+      '_handleRepoListOffsetRoute',
+      '_handleRepoRoute',
+      '_handleQueryLegacySuffixRoute',
+      '_handleQueryRoute',
+      '_handleRegisterRoute',
+      '_handleTagListFilterOffsetRoute',
+      '_handleTagListFilterRoute',
+      '_handleTagListOffsetRoute',
+      '_handlePluginScreen',
+    ];
+
+    // Handler names that check authentication themselves, and thus don't need
+    // it performed for them.
+    const selfAuthenticatingHandlers = [
+      '_handleDashboardRoute',
+      '_handleCustomDashboardRoute',
+      '_handleRootRoute',
+    ];
+
+    const shouldNotRequireAuth = unauthenticatedHandlers
+        .concat(selfAuthenticatingHandlers);
+    shouldNotRequireAuth.sort();
+    assert.deepEqual(actualDoesNotRequireAuth, shouldNotRequireAuth);
+  });
+
+  test('_redirectIfNotLoggedIn while logged in', () => {
+    sinon.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(true));
+    const data = {canonicalPath: ''};
+    const redirectStub = sinon.stub(element, '_redirectToLogin');
+    return element._redirectIfNotLoggedIn(data).then(() => {
+      assert.isFalse(redirectStub.called);
+    });
+  });
+
+  test('_redirectIfNotLoggedIn while logged out', () => {
+    sinon.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(false));
+    const redirectStub = sinon.stub(element, '_redirectToLogin');
+    const data = {canonicalPath: ''};
+    return new Promise(resolve => {
+      element._redirectIfNotLoggedIn(data)
+          .then(() => {
+            assert.isTrue(false, 'Should never execute');
+          })
+          .catch(() => {
+            assert.isTrue(redirectStub.calledOnce);
+            resolve();
+          });
+    });
+  });
+
+  suite('generateUrl', () => {
+    test('search', () => {
+      let params = {
+        view: GerritNav.View.SEARCH,
+        owner: 'a%b',
+        project: 'c%d',
+        branch: 'e%f',
+        topic: 'g%h',
+        statuses: ['op%en'],
+      };
+      assert.equal(element._generateUrl(params),
+          '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+          'topic:"g%2525h"+status:op%2525en');
+
+      params.offset = 100;
+      assert.equal(element._generateUrl(params),
+          '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+          'topic:"g%2525h"+status:op%2525en,100');
+      delete params.offset;
+
+      // The presence of the query param overrides other params.
+      params.query = 'foo$bar';
+      assert.equal(element._generateUrl(params), '/q/foo%2524bar');
+
+      params.offset = 100;
+      assert.equal(element._generateUrl(params), '/q/foo%2524bar,100');
+
+      params = {
+        view: GerritNav.View.SEARCH,
+        statuses: ['a', 'b', 'c'],
+      };
+      assert.equal(element._generateUrl(params),
+          '/q/(status:a OR status:b OR status:c)');
+    });
+
+    test('change', () => {
+      const params = {
+        view: GerritNav.View.CHANGE,
+        changeNum: '1234',
+        project: 'test',
+      };
+      const paramsWithQuery = {
+        view: GerritNav.View.CHANGE,
+        changeNum: '1234',
+        project: 'test',
+        querystring: 'revert&foo=bar',
+      };
+
+      assert.equal(element._generateUrl(params), '/c/test/+/1234');
+      assert.equal(element._generateUrl(paramsWithQuery),
+          '/c/test/+/1234?revert&foo=bar');
+
+      params.patchNum = 10;
+      assert.equal(element._generateUrl(params), '/c/test/+/1234/10');
+      paramsWithQuery.patchNum = 10;
+      assert.equal(element._generateUrl(paramsWithQuery),
+          '/c/test/+/1234/10?revert&foo=bar');
+
+      params.basePatchNum = 5;
+      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10');
+      paramsWithQuery.basePatchNum = 5;
+      assert.equal(element._generateUrl(paramsWithQuery),
+          '/c/test/+/1234/5..10?revert&foo=bar');
+
+      params.messageHash = '#123';
+      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10#123');
+    });
+
+    test('change with repo name encoding', () => {
+      const params = {
+        view: GerritNav.View.CHANGE,
+        changeNum: '1234',
+        project: 'x+/y+/z+/w',
+      };
+      assert.equal(element._generateUrl(params),
+          '/c/x%252B/y%252B/z%252B/w/+/1234');
+    });
+
+    test('diff', () => {
+      const params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        path: 'x+y/path.cpp',
+        patchNum: 12,
+      };
+      assert.equal(element._generateUrl(params),
+          '/c/42/12/x%252By/path.cpp');
+
+      params.project = 'test';
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/12/x%252By/path.cpp');
+
+      params.basePatchNum = 6;
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/6..12/x%252By/path.cpp');
+
+      params.path = 'foo bar/my+file.txt%';
+      params.patchNum = 2;
+      delete params.basePatchNum;
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525');
+
+      params.path = 'file.cpp';
+      params.lineNum = 123;
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/2/file.cpp#123');
+
+      params.leftSide = true;
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/2/file.cpp#b123');
+    });
+
+    test('diff with repo name encoding', () => {
+      const params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        path: 'x+y/path.cpp',
+        patchNum: 12,
+        project: 'x+/y',
+      };
+      assert.equal(element._generateUrl(params),
+          '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+    });
+
+    test('edit', () => {
+      const params = {
+        view: GerritNav.View.EDIT,
+        changeNum: '42',
+        project: 'test',
+        path: 'x+y/path.cpp',
+      };
+      assert.equal(element._generateUrl(params),
+          '/c/test/+/42/x%252By/path.cpp,edit');
+    });
+
+    test('_getPatchRangeExpression', () => {
+      const params = {};
+      let actual = element._getPatchRangeExpression(params);
+      assert.equal(actual, '');
+
+      params.patchNum = 4;
+      actual = element._getPatchRangeExpression(params);
+      assert.equal(actual, '4');
+
+      params.basePatchNum = 2;
+      actual = element._getPatchRangeExpression(params);
+      assert.equal(actual, '2..4');
+
+      delete params.patchNum;
+      actual = element._getPatchRangeExpression(params);
+      assert.equal(actual, '2..');
+    });
+
+    suite('dashboard', () => {
+      test('self dashboard', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+        };
+        assert.equal(element._generateUrl(params), '/dashboard/self');
+      });
+
+      test('user dashboard', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+          user: 'user',
+        };
+        assert.equal(element._generateUrl(params), '/dashboard/user');
+      });
+
+      test('custom self dashboard, no title', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+          sections: [
+            {name: 'section 1', query: 'query 1'},
+            {name: 'section 2', query: 'query 2'},
+          ],
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/dashboard/?section%201=query%201&section%202=query%202');
+      });
+
+      test('custom repo dashboard', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+          sections: [
+            {name: 'section 1', query: 'query 1 ${project}'},
+            {name: 'section 2', query: 'query 2 ${repo}'},
+          ],
+          repo: 'repo-name',
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/dashboard/?section%201=query%201%20repo-name&' +
+            'section%202=query%202%20repo-name');
+      });
+
+      test('custom user dashboard, with title', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+          user: 'user',
+          sections: [{name: 'name', query: 'query'}],
+          title: 'custom dashboard',
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/dashboard/user?name=query&title=custom%20dashboard');
+      });
+
+      test('repo dashboard', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+          repo: 'gerrit/repo',
+          dashboard: 'default:main',
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/p/gerrit/repo/+/dashboard/default:main');
+      });
+
+      test('project dashboard (legacy)', () => {
+        const params = {
+          view: GerritNav.View.DASHBOARD,
+          project: 'gerrit/project',
+          dashboard: 'default:main',
+        };
+        assert.equal(
+            element._generateUrl(params),
+            '/p/gerrit/project/+/dashboard/default:main');
+      });
+    });
+
+    suite('groups', () => {
+      test('group info', () => {
+        const params = {
+          view: GerritNav.View.GROUP,
+          groupId: 1234,
+        };
+        assert.equal(element._generateUrl(params), '/admin/groups/1234');
+      });
+
+      test('group members', () => {
+        const params = {
+          view: GerritNav.View.GROUP,
+          groupId: 1234,
+          detail: 'members',
+        };
+        assert.equal(element._generateUrl(params),
+            '/admin/groups/1234,members');
+      });
+
+      test('group audit log', () => {
+        const params = {
+          view: GerritNav.View.GROUP,
+          groupId: 1234,
+          detail: 'log',
+        };
+        assert.equal(element._generateUrl(params),
+            '/admin/groups/1234,audit-log');
+      });
+    });
+  });
+
+  suite('param normalization', () => {
+    let projectLookupStub;
+
+    setup(() => {
+      projectLookupStub = sinon
+          .stub(element.$.restAPI, 'getFromProjectLookup');
+      sinon.stub(element, '_generateUrl');
+    });
+
+    suite('_normalizeLegacyRouteParams', () => {
+      let rangeStub;
+      let redirectStub;
+      let show404Stub;
+
+      setup(() => {
+        rangeStub = sinon.stub(element, '_normalizePatchRangeParams')
+            .returns(Promise.resolve());
+        redirectStub = sinon.stub(element, '_redirect');
+        show404Stub = sinon.stub(element, '_show404');
+      });
+
+      test('w/o changeNum', () => {
+        projectLookupStub.returns(Promise.resolve('foo/bar'));
+        const params = {};
+        return element._normalizeLegacyRouteParams(params).then(() => {
+          assert.isFalse(projectLookupStub.called);
+          assert.isFalse(rangeStub.called);
+          assert.isNotOk(params.project);
+          assert.isFalse(redirectStub.called);
+          assert.isFalse(show404Stub.called);
+        });
+      });
+
+      test('w/ changeNum', () => {
+        projectLookupStub.returns(Promise.resolve('foo/bar'));
+        const params = {changeNum: 1234};
+        return element._normalizeLegacyRouteParams(params).then(() => {
+          assert.isTrue(projectLookupStub.called);
+          assert.isTrue(rangeStub.called);
+          assert.equal(params.project, 'foo/bar');
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isFalse(show404Stub.called);
+        });
+      });
+
+      test('halts on project lookup failure', () => {
+        projectLookupStub.returns(Promise.resolve(undefined));
+        const params = {changeNum: 1234};
+        return element._normalizeLegacyRouteParams(params).then(() => {
+          assert.isTrue(projectLookupStub.called);
+          assert.isFalse(rangeStub.called);
+          assert.isUndefined(params.project);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(show404Stub.calledOnce);
+        });
+      });
+    });
+
+    suite('_normalizePatchRangeParams', () => {
+      test('range n..n normalizes to n', () => {
+        const params = {basePatchNum: 4, patchNum: 4};
+        const needsRedirect = element._normalizePatchRangeParams(params);
+        assert.isTrue(needsRedirect);
+        assert.isNotOk(params.basePatchNum);
+        assert.equal(params.patchNum, 4);
+      });
+
+      test('range n.. normalizes to n', () => {
+        const params = {basePatchNum: 4};
+        const needsRedirect = element._normalizePatchRangeParams(params);
+        assert.isFalse(needsRedirect);
+        assert.isNotOk(params.basePatchNum);
+        assert.equal(params.patchNum, 4);
+      });
+    });
+  });
+
+  suite('route handlers', () => {
+    let redirectStub;
+    let setParamsStub;
+    let handlePassThroughRoute;
+
+    // Simple route handlers are direct mappings from parsed route data to a
+    // new set of app.params. This test helper asserts that passing `data`
+    // into `methodName` results in setting the params specified in `params`.
+    function assertDataToParams(data, methodName, params) {
+      element[methodName](data);
+      assert.deepEqual(setParamsStub.lastCall.args[0], params);
+    }
+
+    setup(() => {
+      redirectStub = sinon.stub(element, '_redirect');
+      setParamsStub = sinon.stub(element, '_setParams');
+      handlePassThroughRoute = sinon.stub(element, '_handlePassThroughRoute');
+    });
+
+    test('_handleAgreementsRoute', () => {
+      const data = {params: {}};
+      element._handleAgreementsRoute(data);
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
+    });
+
+    test('_handleNewAgreementsRoute', () => {
+      element._handleNewAgreementsRoute({params: {}});
+      assert.isTrue(setParamsStub.calledOnce);
+      assert.equal(setParamsStub.lastCall.args[0].view,
+          GerritNav.View.AGREEMENTS);
+    });
+
+    test('_handleSettingsLegacyRoute', () => {
+      const data = {params: {0: 'my-token'}};
+      assertDataToParams(data, '_handleSettingsLegacyRoute', {
+        view: GerritNav.View.SETTINGS,
+        emailToken: 'my-token',
+      });
+    });
+
+    test('_handleSettingsLegacyRoute with +', () => {
+      const data = {params: {0: 'my-token test'}};
+      assertDataToParams(data, '_handleSettingsLegacyRoute', {
+        view: GerritNav.View.SETTINGS,
+        emailToken: 'my-token+test',
+      });
+    });
+
+    test('_handleSettingsRoute', () => {
+      const data = {};
+      assertDataToParams(data, '_handleSettingsRoute', {
+        view: GerritNav.View.SETTINGS,
+      });
+    });
+
+    test('_handleDefaultRoute on first load', () => {
+      const appElementStub = {dispatchEvent: sinon.stub()};
+      element._appElement = () => appElementStub;
+      element._handleDefaultRoute();
+      assert.isTrue(appElementStub.dispatchEvent.calledOnce);
+      assert.equal(
+          appElementStub.dispatchEvent.lastCall.args[0].detail.response.status,
+          404);
+    });
+
+    test('_handleDefaultRoute after internal navigation', () => {
+      let onExit = null;
+      const onRegisteringExit = (match, _onExit) => {
+        onExit = _onExit;
+      };
+      sinon.stub(page, 'exit').callsFake( onRegisteringExit);
+      sinon.stub(GerritNav, 'setup');
+      sinon.stub(page, 'start');
+      sinon.stub(page, 'base');
+      element._startRouter();
+
+      const appElementStub = {dispatchEvent: sinon.stub()};
+      element._appElement = () => appElementStub;
+      element._handleDefaultRoute();
+
+      onExit('', () => {}); // we left page;
+
+      element._handleDefaultRoute();
+      assert.isTrue(handlePassThroughRoute.calledOnce);
+    });
+
+    test('_handleImproperlyEncodedPlusRoute', () => {
+      // Regression test for Issue 7100.
+      element._handleImproperlyEncodedPlusRoute(
+          {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(
+          redirectStub.lastCall.args[0],
+          '/c/test/+/42');
+
+      sinon.stub(element, '_getHashFromCanonicalPath').returns('foo');
+      element._handleImproperlyEncodedPlusRoute(
+          {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
+      assert.equal(
+          redirectStub.lastCall.args[0],
+          '/c/test/+/42#foo');
+    });
+
+    test('_handleQueryRoute', () => {
+      const data = {params: ['project:foo/bar/baz']};
+      assertDataToParams(data, '_handleQueryRoute', {
+        view: GerritNav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: undefined,
+      });
+
+      data.params.push(',123', '123');
+      assertDataToParams(data, '_handleQueryRoute', {
+        view: GerritNav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: '123',
+      });
+    });
+
+    test('_handleQueryLegacySuffixRoute', () => {
+      element._handleQueryLegacySuffixRoute({path: '/q/foo+bar,n,z'});
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
+    });
+
+    test('_handleQueryRoute', () => {
+      const data = {params: ['project:foo/bar/baz']};
+      assertDataToParams(data, '_handleQueryRoute', {
+        view: GerritNav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: undefined,
+      });
+
+      data.params.push(',123', '123');
+      assertDataToParams(data, '_handleQueryRoute', {
+        view: GerritNav.View.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: '123',
+      });
+    });
+
+    suite('_handleRegisterRoute', () => {
+      test('happy path', () => {
+        const ctx = {params: ['/foo/bar']};
+        element._handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
+        assert.isTrue(setParamsStub.calledOnce);
+        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+      });
+
+      test('no param', () => {
+        const ctx = {params: ['']};
+        element._handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/'));
+        assert.isTrue(setParamsStub.calledOnce);
+        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+      });
+
+      test('prevent redirect', () => {
+        const ctx = {params: ['/register']};
+        element._handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/'));
+        assert.isTrue(setParamsStub.calledOnce);
+        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+      });
+    });
+
+    suite('_handleRootRoute', () => {
+      test('closes for closeAfterLogin', () => {
+        const data = {querystring: 'closeAfterLogin', canonicalPath: ''};
+        const closeStub = sinon.stub(window, 'close');
+        const result = element._handleRootRoute(data);
+        assert.isNotOk(result);
+        assert.isTrue(closeStub.called);
+        assert.isFalse(redirectStub.called);
+      });
+
+      test('redirects to dashboard if logged in', () => {
+        sinon.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(true));
+        const data = {
+          canonicalPath: '/', path: '/', querystring: '', hash: '',
+        };
+        const result = element._handleRootRoute(data);
+        assert.isOk(result);
+        return result.then(() => {
+          assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
+        });
+      });
+
+      test('redirects to open changes if not logged in', () => {
+        sinon.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(false));
+        const data = {
+          canonicalPath: '/', path: '/', querystring: '', hash: '',
+        };
+        const result = element._handleRootRoute(data);
+        assert.isOk(result);
+        return result.then(() => {
+          assert.isTrue(
+              redirectStub.calledWithExactly('/q/status:open+-is:wip'));
+        });
+      });
+
+      suite('GWT hash-path URLs', () => {
+        test('redirects hash-path URLs', () => {
+          const data = {
+            canonicalPath: '/#/foo/bar/baz',
+            hash: '/foo/bar/baz',
+            querystring: '',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+        });
+
+        test('redirects hash-path URLs w/o leading slash', () => {
+          const data = {
+            canonicalPath: '/#foo/bar/baz',
+            querystring: '',
+            hash: 'foo/bar/baz',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+        });
+
+        test('normalizes "/ /" in hash to "/+/"', () => {
+          const data = {
+            canonicalPath: '/#/foo/bar/+/123/4',
+            querystring: '',
+            hash: '/foo/bar/ /123/4',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
+        });
+
+        test('prepends baseurl to hash-path', () => {
+          const data = {
+            canonicalPath: '/#/foo/bar',
+            querystring: '',
+            hash: '/foo/bar',
+          };
+          stubBaseUrl('/baz');
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
+        });
+
+        test('normalizes /VE/ settings hash-paths', () => {
+          const data = {
+            canonicalPath: '/#/VE/foo/bar',
+            querystring: '',
+            hash: '/VE/foo/bar',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly(
+              '/settings/VE/foo/bar'));
+        });
+
+        test('does not drop "inner hashes"', () => {
+          const data = {
+            canonicalPath: '/#/foo/bar#baz',
+            querystring: '',
+            hash: '/foo/bar',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
+        });
+      });
+    });
+
+    suite('_handleDashboardRoute', () => {
+      let redirectToLoginStub;
+
+      setup(() => {
+        redirectToLoginStub = sinon.stub(element, '_redirectToLogin');
+      });
+
+      test('own dashboard but signed out redirects to login', () => {
+        sinon.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(false));
+        const data = {canonicalPath: '/dashboard/', params: {0: 'seLF'}};
+        return element._handleDashboardRoute(data, '').then(() => {
+          assert.isTrue(redirectToLoginStub.calledOnce);
+          assert.isFalse(redirectStub.called);
+          assert.isFalse(setParamsStub.called);
+        });
+      });
+
+      test('non-self dashboard but signed out does not redirect', () => {
+        sinon.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(false));
+        const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
+        return element._handleDashboardRoute(data, '').then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
+        });
+      });
+
+      test('dashboard while signed in sets params', () => {
+        sinon.stub(element.$.restAPI, 'getLoggedIn')
+            .returns(Promise.resolve(true));
+        const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
+        return element._handleDashboardRoute(data, '').then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setParamsStub.calledOnce);
+          assert.deepEqual(setParamsStub.lastCall.args[0], {
+            view: GerritNav.View.DASHBOARD,
+            user: 'foo',
+          });
+        });
+      });
+    });
+
+    suite('_handleCustomDashboardRoute', () => {
+      let redirectToLoginStub;
+
+      setup(() => {
+        redirectToLoginStub = sinon.stub(element, '_redirectToLogin');
+      });
+
+      test('no user specified', () => {
+        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+        return element._handleCustomDashboardRoute(data, '').then(() => {
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.called);
+          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
+        });
+      });
+
+      test('custom dashboard without title', () => {
+        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+        return element._handleCustomDashboardRoute(data, '?a=b&c&d=e')
+            .then(() => {
+              assert.isFalse(redirectStub.called);
+              assert.isTrue(setParamsStub.calledOnce);
+              assert.deepEqual(setParamsStub.lastCall.args[0], {
+                view: GerritNav.View.DASHBOARD,
+                user: 'self',
+                sections: [
+                  {name: 'a', query: 'b'},
+                  {name: 'd', query: 'e'},
+                ],
+                title: 'Custom Dashboard',
+              });
+            });
+      });
+
+      test('custom dashboard with title', () => {
+        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+        return element._handleCustomDashboardRoute(data,
+            '?a=b&c&d=&=e&title=t')
+            .then(() => {
+              assert.isFalse(redirectToLoginStub.called);
+              assert.isFalse(redirectStub.called);
+              assert.isTrue(setParamsStub.calledOnce);
+              assert.deepEqual(setParamsStub.lastCall.args[0], {
+                view: GerritNav.View.DASHBOARD,
+                user: 'self',
+                sections: [
+                  {name: 'a', query: 'b'},
+                ],
+                title: 't',
+              });
+            });
+      });
+
+      test('custom dashboard with foreach', () => {
+        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
+        return element._handleCustomDashboardRoute(data,
+            '?a=b&c&d=&=e&foreach=is:open')
+            .then(() => {
+              assert.isFalse(redirectToLoginStub.called);
+              assert.isFalse(redirectStub.called);
+              assert.isTrue(setParamsStub.calledOnce);
+              assert.deepEqual(setParamsStub.lastCall.args[0], {
+                view: GerritNav.View.DASHBOARD,
+                user: 'self',
+                sections: [
+                  {name: 'a', query: 'is:open b'},
+                ],
+                title: 'Custom Dashboard',
+              });
+            });
+      });
+    });
+
+    suite('group routes', () => {
+      test('_handleGroupInfoRoute', () => {
+        const data = {params: {0: 1234}};
+        element._handleGroupInfoRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
+      });
+
+      test('_handleGroupAuditLogRoute', () => {
+        const data = {params: {0: 1234}};
+        assertDataToParams(data, '_handleGroupAuditLogRoute', {
+          view: GerritNav.View.GROUP,
+          detail: 'log',
+          groupId: 1234,
+        });
+      });
+
+      test('_handleGroupMembersRoute', () => {
+        const data = {params: {0: 1234}};
+        assertDataToParams(data, '_handleGroupMembersRoute', {
+          view: GerritNav.View.GROUP,
+          detail: 'members',
+          groupId: 1234,
+        });
+      });
+
+      test('_handleGroupListOffsetRoute', () => {
+        const data = {params: {}};
+        assertDataToParams(data, '_handleGroupListOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: 0,
+          filter: null,
+          openCreateModal: false,
+        });
+
+        data.params[1] = 42;
+        assertDataToParams(data, '_handleGroupListOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: 42,
+          filter: null,
+          openCreateModal: false,
+        });
+
+        data.hash = 'create';
+        assertDataToParams(data, '_handleGroupListOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: 42,
+          filter: null,
+          openCreateModal: true,
+        });
+      });
+
+      test('_handleGroupListFilterOffsetRoute', () => {
+        const data = {params: {filter: 'foo', offset: 42}};
+        assertDataToParams(data, '_handleGroupListFilterOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          offset: 42,
+          filter: 'foo',
+        });
+      });
+
+      test('_handleGroupListFilterRoute', () => {
+        const data = {params: {filter: 'foo'}};
+        assertDataToParams(data, '_handleGroupListFilterRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-admin-group-list',
+          filter: 'foo',
+        });
+      });
+
+      test('_handleGroupRoute', () => {
+        const data = {params: {0: 4321}};
+        assertDataToParams(data, '_handleGroupRoute', {
+          view: GerritNav.View.GROUP,
+          groupId: 4321,
+        });
+      });
+    });
+
+    suite('repo routes', () => {
+      test('_handleProjectsOldRoute', () => {
+        const data = {params: {}};
+        element._handleProjectsOldRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
+      });
+
+      test('_handleProjectsOldRoute test', () => {
+        const data = {params: {1: 'test'}};
+        element._handleProjectsOldRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
+      });
+
+      test('_handleProjectsOldRoute test,branches', () => {
+        const data = {params: {1: 'test,branches'}};
+        element._handleProjectsOldRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(
+            redirectStub.lastCall.args[0], '/admin/repos/test,branches');
+      });
+
+      test('_handleRepoRoute', () => {
+        const data = {params: {0: 4321}};
+        assertDataToParams(data, '_handleRepoRoute', {
+          view: GerritNav.View.REPO,
+          repo: 4321,
+        });
+      });
+
+      test('_handleRepoCommandsRoute', () => {
+        const data = {params: {0: 4321}};
+        assertDataToParams(data, '_handleRepoCommandsRoute', {
+          view: GerritNav.View.REPO,
+          detail: GerritNav.RepoDetailView.COMMANDS,
+          repo: 4321,
+        });
+      });
+
+      test('_handleRepoAccessRoute', () => {
+        const data = {params: {0: 4321}};
+        assertDataToParams(data, '_handleRepoAccessRoute', {
+          view: GerritNav.View.REPO,
+          detail: GerritNav.RepoDetailView.ACCESS,
+          repo: 4321,
+        });
+      });
+
+      suite('branch list routes', () => {
+        test('_handleBranchListOffsetRoute', () => {
+          const data = {params: {0: 4321}};
+          assertDataToParams(data, '_handleBranchListOffsetRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.BRANCHES,
+            repo: 4321,
+            offset: 0,
+            filter: null,
+          });
+
+          data.params[2] = 42;
+          assertDataToParams(data, '_handleBranchListOffsetRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.BRANCHES,
+            repo: 4321,
+            offset: 42,
+            filter: null,
+          });
+        });
+
+        test('_handleBranchListFilterOffsetRoute', () => {
+          const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
+          assertDataToParams(data, '_handleBranchListFilterOffsetRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.BRANCHES,
+            repo: 4321,
+            offset: 42,
+            filter: 'foo',
+          });
+        });
+
+        test('_handleBranchListFilterRoute', () => {
+          const data = {params: {repo: 4321, filter: 'foo'}};
+          assertDataToParams(data, '_handleBranchListFilterRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.BRANCHES,
+            repo: 4321,
+            filter: 'foo',
+          });
+        });
+      });
+
+      suite('tag list routes', () => {
+        test('_handleTagListOffsetRoute', () => {
+          const data = {params: {0: 4321}};
+          assertDataToParams(data, '_handleTagListOffsetRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.TAGS,
+            repo: 4321,
+            offset: 0,
+            filter: null,
+          });
+        });
+
+        test('_handleTagListFilterOffsetRoute', () => {
+          const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
+          assertDataToParams(data, '_handleTagListFilterOffsetRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.TAGS,
+            repo: 4321,
+            offset: 42,
+            filter: 'foo',
+          });
+        });
+
+        test('_handleTagListFilterRoute', () => {
+          const data = {params: {repo: 4321}};
+          assertDataToParams(data, '_handleTagListFilterRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.TAGS,
+            repo: 4321,
+            filter: null,
+          });
+
+          data.params.filter = 'foo';
+          assertDataToParams(data, '_handleTagListFilterRoute', {
+            view: GerritNav.View.REPO,
+            detail: GerritNav.RepoDetailView.TAGS,
+            repo: 4321,
+            filter: 'foo',
+          });
+        });
+      });
+
+      suite('repo list routes', () => {
+        test('_handleRepoListOffsetRoute', () => {
+          const data = {params: {}};
+          assertDataToParams(data, '_handleRepoListOffsetRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
+            offset: 0,
+            filter: null,
+            openCreateModal: false,
+          });
+
+          data.params[1] = 42;
+          assertDataToParams(data, '_handleRepoListOffsetRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
+            offset: 42,
+            filter: null,
+            openCreateModal: false,
+          });
+
+          data.hash = 'create';
+          assertDataToParams(data, '_handleRepoListOffsetRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
+            offset: 42,
+            filter: null,
+            openCreateModal: true,
+          });
+        });
+
+        test('_handleRepoListFilterOffsetRoute', () => {
+          const data = {params: {filter: 'foo', offset: 42}};
+          assertDataToParams(data, '_handleRepoListFilterOffsetRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
+            offset: 42,
+            filter: 'foo',
+          });
+        });
+
+        test('_handleRepoListFilterRoute', () => {
+          const data = {params: {}};
+          assertDataToParams(data, '_handleRepoListFilterRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
+            filter: null,
+          });
+
+          data.params.filter = 'foo';
+          assertDataToParams(data, '_handleRepoListFilterRoute', {
+            view: GerritNav.View.ADMIN,
+            adminView: 'gr-repo-list',
+            filter: 'foo',
+          });
+        });
+      });
+    });
+
+    suite('plugin routes', () => {
+      test('_handlePluginListOffsetRoute', () => {
+        const data = {params: {}};
+        assertDataToParams(data, '_handlePluginListOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          offset: 0,
+          filter: null,
+        });
+
+        data.params[1] = 42;
+        assertDataToParams(data, '_handlePluginListOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          offset: 42,
+          filter: null,
+        });
+      });
+
+      test('_handlePluginListFilterOffsetRoute', () => {
+        const data = {params: {filter: 'foo', offset: 42}};
+        assertDataToParams(data, '_handlePluginListFilterOffsetRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          offset: 42,
+          filter: 'foo',
+        });
+      });
+
+      test('_handlePluginListFilterRoute', () => {
+        const data = {params: {}};
+        assertDataToParams(data, '_handlePluginListFilterRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          filter: null,
+        });
+
+        data.params.filter = 'foo';
+        assertDataToParams(data, '_handlePluginListFilterRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+          filter: 'foo',
+        });
+      });
+
+      test('_handlePluginListRoute', () => {
+        const data = {params: {}};
+        assertDataToParams(data, '_handlePluginListRoute', {
+          view: GerritNav.View.ADMIN,
+          adminView: 'gr-plugin-list',
+        });
+      });
+    });
+
+    suite('change/diff routes', () => {
+      test('_handleChangeNumberLegacyRoute', () => {
+        const data = {params: {0: 12345}};
+        element._handleChangeNumberLegacyRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
+      });
+
+      test('_handleChangeLegacyRoute', () => {
+        const normalizeRouteStub = sinon.stub(element,
+            '_normalizeLegacyRouteParams');
+        const ctx = {
+          params: [
+            1234, // 0 Change number
+            null, // 1 Unused
+            null, // 2 Unused
+            6, // 3 Base patch number
+            null, // 4 Unused
+            9, // 5 Patch number
+          ],
+          querystring: '',
+        };
+        element._handleChangeLegacyRoute(ctx);
+        assert.isTrue(normalizeRouteStub.calledOnce);
+        assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
+          changeNum: 1234,
+          basePatchNum: 6,
+          patchNum: 9,
+          view: GerritNav.View.CHANGE,
+          querystring: '',
+        });
+      });
+
+      test('_handleDiffLegacyRoute', () => {
+        const normalizeRouteStub = sinon.stub(element,
+            '_normalizeLegacyRouteParams');
+        const ctx = {
+          params: [
+            1234, // 0 Change number
+            null, // 1 Unused
+            3, // 2 Base patch number
+            null, // 3 Unused
+            8, // 4 Patch number
+            'foo/bar', // 5 Diff path
+          ],
+          path: '/c/1234/3..8/foo/bar',
+          hash: 'b123',
+        };
+        element._handleDiffLegacyRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRouteStub.calledOnce);
+        assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
+          changeNum: 1234,
+          basePatchNum: 3,
+          patchNum: 8,
+          view: GerritNav.View.DIFF,
+          path: 'foo/bar',
+          lineNum: 123,
+          leftSide: true,
+        });
+      });
+
+      test('_handleLegacyLinenum w/ @321', () => {
+        const ctx = {path: '/c/1234/3..8/foo/bar@321'};
+        element._handleLegacyLinenum(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(redirectStub.calledWithExactly(
+            '/c/1234/3..8/foo/bar#321'));
+      });
+
+      test('_handleLegacyLinenum w/ @b123', () => {
+        const ctx = {path: '/c/1234/3..8/foo/bar@b123'};
+        element._handleLegacyLinenum(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(redirectStub.calledWithExactly(
+            '/c/1234/3..8/foo/bar#b123'));
+      });
+
+      suite('_handleChangeRoute', () => {
+        let normalizeRangeStub;
+
+        function makeParams(path, hash) {
+          return {
+            params: [
+              'foo/bar', // 0 Project
+              1234, // 1 Change number
+              null, // 2 Unused
+              null, // 3 Unused
+              4, // 4 Base patch number
+              null, // 5 Unused
+              7, // 6 Patch number
+            ],
+            queryMap: new Map(),
+          };
+        }
+
+        setup(() => {
+          normalizeRangeStub = sinon.stub(element,
+              '_normalizePatchRangeParams');
+          sinon.stub(element.$.restAPI, 'setInProjectLookup');
+        });
+
+        test('needs redirect', () => {
+          normalizeRangeStub.returns(true);
+          sinon.stub(element, '_generateUrl').returns('foo');
+          const ctx = makeParams(null, '');
+          element._handleChangeRoute(ctx);
+          assert.isTrue(normalizeRangeStub.called);
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isTrue(redirectStub.calledWithExactly('foo'));
+        });
+
+        test('change view', () => {
+          normalizeRangeStub.returns(false);
+          sinon.stub(element, '_generateUrl').returns('foo');
+          const ctx = makeParams(null, '');
+          assertDataToParams(ctx, '_handleChangeRoute', {
+            view: GerritNav.View.CHANGE,
+            project: 'foo/bar',
+            changeNum: 1234,
+            basePatchNum: 4,
+            patchNum: 7,
+            queryMap: new Map(),
+          });
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(normalizeRangeStub.called);
+        });
+      });
+
+      suite('_handleDiffRoute', () => {
+        let normalizeRangeStub;
+
+        function makeParams(path, hash) {
+          return {
+            params: [
+              'foo/bar', // 0 Project
+              1234, // 1 Change number
+              null, // 2 Unused
+              null, // 3 Unused
+              4, // 4 Base patch number
+              null, // 5 Unused
+              7, // 6 Patch number
+              null, // 7 Unused,
+              path, // 8 Diff path
+            ],
+            hash,
+          };
+        }
+
+        setup(() => {
+          normalizeRangeStub = sinon.stub(element,
+              '_normalizePatchRangeParams');
+          sinon.stub(element.$.restAPI, 'setInProjectLookup');
+        });
+
+        test('needs redirect', () => {
+          normalizeRangeStub.returns(true);
+          sinon.stub(element, '_generateUrl').returns('foo');
+          const ctx = makeParams(null, '');
+          element._handleDiffRoute(ctx);
+          assert.isTrue(normalizeRangeStub.called);
+          assert.isFalse(setParamsStub.called);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isTrue(redirectStub.calledWithExactly('foo'));
+        });
+
+        test('diff view', () => {
+          normalizeRangeStub.returns(false);
+          sinon.stub(element, '_generateUrl').returns('foo');
+          const ctx = makeParams('foo/bar/baz', 'b44');
+          assertDataToParams(ctx, '_handleDiffRoute', {
+            view: GerritNav.View.DIFF,
+            project: 'foo/bar',
+            changeNum: 1234,
+            basePatchNum: 4,
+            patchNum: 7,
+            path: 'foo/bar/baz',
+            leftSide: true,
+            lineNum: 44,
+          });
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(normalizeRangeStub.called);
+        });
+
+        test('comment route', () => {
+          const url = '/c/gerrit/+/264833/comment/00049681_f34fd6a9/';
+          const groups = url.match(_testOnly_RoutePattern.COMMENT);
+          assert.deepEqual(groups.slice(1), [
+            'gerrit', // project
+            '264833', // changeNum
+            '00049681_f34fd6a9', // commentId
+          ]);
+          assertDataToParams({params: groups.slice(1)}, '_handleCommentRoute', {
+            project: 'gerrit',
+            changeNum: '264833',
+            commentId: '00049681_f34fd6a9',
+            commentLink: true,
+            view: GerritNav.View.DIFF,
+          });
+        });
+      });
+
+      test('_handleDiffEditRoute', () => {
+        const normalizeRangeSpy =
+            sinon.spy(element, '_normalizePatchRangeParams');
+        sinon.stub(element.$.restAPI, 'setInProjectLookup');
+        const ctx = {
+          params: [
+            'foo/bar', // 0 Project
+            1234, // 1 Change number
+            3, // 2 Patch num
+            'foo/bar/baz', // 3 File path
+          ],
+        };
+        const appParams = {
+          project: 'foo/bar',
+          changeNum: 1234,
+          view: GerritNav.View.EDIT,
+          path: 'foo/bar/baz',
+          patchNum: 3,
+          lineNum: undefined,
+        };
+
+        element._handleDiffEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRangeSpy.calledOnce);
+        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
+        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
+        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+      });
+
+      test('_handleDiffEditRoute with lineNum', () => {
+        const normalizeRangeSpy =
+            sinon.spy(element, '_normalizePatchRangeParams');
+        sinon.stub(element.$.restAPI, 'setInProjectLookup');
+        const ctx = {
+          params: [
+            'foo/bar', // 0 Project
+            1234, // 1 Change number
+            3, // 2 Patch num
+            'foo/bar/baz', // 3 File path
+          ],
+          hash: 4,
+        };
+        const appParams = {
+          project: 'foo/bar',
+          changeNum: 1234,
+          view: GerritNav.View.EDIT,
+          path: 'foo/bar/baz',
+          patchNum: 3,
+          lineNum: 4,
+        };
+
+        element._handleDiffEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRangeSpy.calledOnce);
+        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
+        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
+        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+      });
+
+      test('_handleChangeEditRoute', () => {
+        const normalizeRangeSpy =
+            sinon.spy(element, '_normalizePatchRangeParams');
+        sinon.stub(element.$.restAPI, 'setInProjectLookup');
+        const ctx = {
+          params: [
+            'foo/bar', // 0 Project
+            1234, // 1 Change number
+            null,
+            3, // 3 Patch num
+          ],
+        };
+        const appParams = {
+          project: 'foo/bar',
+          changeNum: 1234,
+          view: GerritNav.View.CHANGE,
+          patchNum: 3,
+          edit: true,
+        };
+
+        element._handleChangeEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.isTrue(normalizeRangeSpy.calledOnce);
+        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
+        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
+        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+      });
+    });
+
+    test('_handlePluginScreen', () => {
+      const ctx = {params: ['foo', 'bar']};
+      assertDataToParams(ctx, '_handlePluginScreen', {
+        view: GerritNav.View.PLUGIN_SCREEN,
+        plugin: 'foo',
+        screen: 'bar',
+      });
+      assert.isFalse(redirectStub.called);
+    });
+  });
+
+  suite('_parseQueryString', () => {
+    test('empty queries', () => {
+      assert.deepEqual(element._parseQueryString(''), []);
+      assert.deepEqual(element._parseQueryString('?'), []);
+      assert.deepEqual(element._parseQueryString('??'), []);
+      assert.deepEqual(element._parseQueryString('&&&'), []);
+    });
+
+    test('url decoding', () => {
+      assert.deepEqual(element._parseQueryString('+'), [[' ', '']]);
+      assert.deepEqual(element._parseQueryString('???+%3d+'), [[' = ', '']]);
+      assert.deepEqual(
+          element._parseQueryString('%6e%61%6d%65=%76%61%6c%75%65'),
+          [['name', 'value']]);
+    });
+
+    test('multiple parameters', () => {
+      assert.deepEqual(
+          element._parseQueryString('a=b&c=d&e=f'),
+          [['a', 'b'], ['c', 'd'], ['e', 'f']]);
+      assert.deepEqual(
+          element._parseQueryString('&a=b&&&e=f&c'),
+          [['a', 'b'], ['e', 'f'], ['c', '']]);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index f638f14..6d50fcf 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -14,18 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 import '../../shared/gr-autocomplete/gr-autocomplete.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../../styles/shared-styles.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-search-bar_html.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS = [
@@ -114,14 +111,10 @@
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrSearchBar extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrSearchBar extends KeyboardShortcutMixin(GestureEventListeners(
+    LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-search-bar'; }
@@ -172,6 +165,14 @@
         type: Number,
         value: 1,
       },
+      /**
+       * Invisible label for input element. This label is exposed to
+       * screen readers by nested element
+       */
+      label: {
+        type: String,
+        value: '',
+      },
     };
   }
 
@@ -198,7 +199,7 @@
 
   keyboardShortcuts() {
     return {
-      [this.Shortcut.SEARCH]: '_handleSearch',
+      [Shortcut.SEARCH]: '_handleSearch',
     };
   }
 
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js
deleted file mode 100644
index e26f8a3..0000000
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    form {
-      display: flex;
-    }
-    gr-autocomplete {
-      background-color: var(--view-background-color);
-      border-radius: var(--border-radius);
-      flex: 1;
-      outline: none;
-    }
-  </style>
-  <form>
-    <gr-autocomplete
-      show-search-icon=""
-      id="searchInput"
-      text="{{_inputVal}}"
-      query="[[query]]"
-      on-commit="_handleInputCommit"
-      allow-non-suggested-values=""
-      multi=""
-      threshold="[[_threshold]]"
-      tab-complete=""
-      vertical-offset="30"
-    ></gr-autocomplete>
-  </form>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
new file mode 100644
index 0000000..a4d5e69
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    form {
+      display: flex;
+    }
+    gr-autocomplete {
+      background-color: var(--view-background-color);
+      border-radius: var(--border-radius);
+      flex: 1;
+      outline: none;
+    }
+  </style>
+  <form>
+    <gr-autocomplete
+      label="[[label]]"
+      show-search-icon=""
+      id="searchInput"
+      text="{{_inputVal}}"
+      query="[[query]]"
+      on-commit="_handleInputCommit"
+      allow-non-suggested-values=""
+      multi=""
+      threshold="[[_threshold]]"
+      tab-complete=""
+      vertical-offset="30"
+    ></gr-autocomplete>
+  </form>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
deleted file mode 100644
index 3b37e09..0000000
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
+++ /dev/null
@@ -1,239 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-search-bar</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script src="/node_modules/page/page.js"></script>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-search-bar.js';
-void (0);
-</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-search-bar></gr-search-bar>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-search-bar.js';
-import '../../../scripts/util.js';
-import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-suite('gr-search-bar tests', () => {
-  const kb = KeyboardShortcutBinder;
-  kb.bindShortcut(kb.Shortcut.SEARCH, '/');
-
-  let element;
-  let sandbox;
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    flush(done);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('value is propagated to _inputVal', () => {
-    element.value = 'foo';
-    assert.equal(element._inputVal, 'foo');
-  });
-
-  const getActiveElement = () => (document.activeElement.shadowRoot ?
-    document.activeElement.shadowRoot.activeElement :
-    document.activeElement);
-
-  test('enter in search input fires event', done => {
-    element.addEventListener('handle-search', () => {
-      assert.notEqual(getActiveElement(), element.$.searchInput);
-      assert.notEqual(getActiveElement(), element.$.searchButton);
-      done();
-    });
-    element.value = 'test';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-  });
-
-  test('input blurred after commit', () => {
-    const blurSpy = sandbox.spy(element.$.searchInput.$.input, 'blur');
-    element.$.searchInput.text = 'fate/stay';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-    assert.isTrue(blurSpy.called);
-  });
-
-  test('empty search query does not trigger nav', () => {
-    const searchSpy = sandbox.spy();
-    element.addEventListener('handle-search', searchSpy);
-    element.value = '';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-    assert.isFalse(searchSpy.called);
-  });
-
-  test('Predefined query op with no predication doesnt trigger nav', () => {
-    const searchSpy = sandbox.spy();
-    element.addEventListener('handle-search', searchSpy);
-    element.value = 'added:';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-    assert.isFalse(searchSpy.called);
-  });
-
-  test('predefined predicate query triggers nav', () => {
-    const searchSpy = sandbox.spy();
-    element.addEventListener('handle-search', searchSpy);
-    element.value = 'age:1week';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-    assert.isTrue(searchSpy.called);
-  });
-
-  test('undefined predicate query triggers nav', () => {
-    const searchSpy = sandbox.spy();
-    element.addEventListener('handle-search', searchSpy);
-    element.value = 'random:1week';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-    assert.isTrue(searchSpy.called);
-  });
-
-  test('empty undefined predicate query triggers nav', () => {
-    const searchSpy = sandbox.spy();
-    element.addEventListener('handle-search', searchSpy);
-    element.value = 'random:';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-    assert.isTrue(searchSpy.called);
-  });
-
-  test('keyboard shortcuts', () => {
-    const focusSpy = sandbox.spy(element.$.searchInput, 'focus');
-    const selectAllSpy = sandbox.spy(element.$.searchInput, 'selectAll');
-    MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
-    assert.isTrue(focusSpy.called);
-    assert.isTrue(selectAllSpy.called);
-  });
-
-  suite('_getSearchSuggestions', () => {
-    test('Autocompletes accounts', () => {
-      sandbox.stub(element, 'accountSuggestions', () =>
-        Promise.resolve([{text: 'owner:fred@goog.co'}])
-      );
-      return element._getSearchSuggestions('owner:fr').then(s => {
-        assert.equal(s[0].value, 'owner:fred@goog.co');
-      });
-    });
-
-    test('Autocompletes groups', done => {
-      sandbox.stub(element, 'groupSuggestions', () =>
-        Promise.resolve([
-          {text: 'ownerin:Polygerrit'},
-          {text: 'ownerin:gerrit'},
-        ])
-      );
-      element._getSearchSuggestions('ownerin:pol').then(s => {
-        assert.equal(s[0].value, 'ownerin:Polygerrit');
-        done();
-      });
-    });
-
-    test('Autocompletes projects', done => {
-      sandbox.stub(element, 'projectSuggestions', () =>
-        Promise.resolve([
-          {text: 'project:Polygerrit'},
-          {text: 'project:gerrit'},
-          {text: 'project:gerrittest'},
-        ])
-      );
-      element._getSearchSuggestions('project:pol').then(s => {
-        assert.equal(s[0].value, 'project:Polygerrit');
-        done();
-      });
-    });
-
-    test('Autocompletes simple searches', done => {
-      element._getSearchSuggestions('is:o').then(s => {
-        assert.equal(s[0].name, 'is:open');
-        assert.equal(s[0].value, 'is:open');
-        assert.equal(s[1].name, 'is:owner');
-        assert.equal(s[1].value, 'is:owner');
-        done();
-      });
-    });
-
-    test('Does not autocomplete with no match', done => {
-      element._getSearchSuggestions('asdasdasdasd').then(s => {
-        assert.equal(s.length, 0);
-        done();
-      });
-    });
-
-    test('Autocompltes without is:mergable when disabled', done => {
-      element._getSearchSuggestions('is:mergeab').then(s => {
-        assert.equal(s.length, 0);
-        done();
-      });
-    });
-  });
-
-  [
-    'API_REF_UPDATED_AND_CHANGE_REINDEX',
-    'REF_UPDATED_AND_CHANGE_REINDEX',
-  ].forEach(mergeability => {
-    suite(`mergeability as ${mergeability}`, () => {
-      setup(done => {
-        stub('gr-rest-api-interface', {
-          getConfig() {
-            return Promise.resolve({
-              index: {
-                mergeabilityComputationBehavior: mergeability,
-              },
-            });
-          },
-        });
-
-        element = fixture('basic');
-        flush(done);
-      });
-
-      test('Autocompltes with is:mergable when enabled', done => {
-        element._getSearchSuggestions('is:mergeab').then(s => {
-          assert.equal(s.length, 2);
-          assert.equal(s[0].name, 'is:mergeable');
-          assert.equal(s[0].value, 'is:mergeable');
-          assert.equal(s[1].name, '-is:mergeable');
-          assert.equal(s[1].value, '-is:mergeable');
-          done();
-        });
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
new file mode 100644
index 0000000..e5d04be
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
@@ -0,0 +1,219 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-search-bar.js';
+import '../../../scripts/util.js';
+import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+
+const basicFixture = fixtureFromElement('gr-search-bar');
+
+suite('gr-search-bar tests', () => {
+  let element;
+
+  suiteSetup(() => {
+    const kb = TestKeyboardShortcutBinder.push();
+    kb.bindShortcut(Shortcut.SEARCH, '/');
+  });
+
+  suiteTeardown(() => {
+    TestKeyboardShortcutBinder.pop();
+  });
+
+  setup(done => {
+    element = basicFixture.instantiate();
+    flush(done);
+  });
+
+  test('value is propagated to _inputVal', () => {
+    element.value = 'foo';
+    assert.equal(element._inputVal, 'foo');
+  });
+
+  const getActiveElement = () => (document.activeElement.shadowRoot ?
+    document.activeElement.shadowRoot.activeElement :
+    document.activeElement);
+
+  test('enter in search input fires event', done => {
+    element.addEventListener('handle-search', () => {
+      assert.notEqual(getActiveElement(), element.$.searchInput);
+      assert.notEqual(getActiveElement(), element.$.searchButton);
+      done();
+    });
+    element.value = 'test';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+  });
+
+  test('input blurred after commit', () => {
+    const blurSpy = sinon.spy(element.$.searchInput.$.input, 'blur');
+    element.$.searchInput.text = 'fate/stay';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isTrue(blurSpy.called);
+  });
+
+  test('empty search query does not trigger nav', () => {
+    const searchSpy = sinon.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = '';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isFalse(searchSpy.called);
+  });
+
+  test('Predefined query op with no predication doesnt trigger nav', () => {
+    const searchSpy = sinon.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'added:';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isFalse(searchSpy.called);
+  });
+
+  test('predefined predicate query triggers nav', () => {
+    const searchSpy = sinon.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'age:1week';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isTrue(searchSpy.called);
+  });
+
+  test('undefined predicate query triggers nav', () => {
+    const searchSpy = sinon.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'random:1week';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isTrue(searchSpy.called);
+  });
+
+  test('empty undefined predicate query triggers nav', () => {
+    const searchSpy = sinon.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'random:';
+    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
+        null, 'enter');
+    assert.isTrue(searchSpy.called);
+  });
+
+  test('keyboard shortcuts', () => {
+    const focusSpy = sinon.spy(element.$.searchInput, 'focus');
+    const selectAllSpy = sinon.spy(element.$.searchInput, 'selectAll');
+    MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
+    assert.isTrue(focusSpy.called);
+    assert.isTrue(selectAllSpy.called);
+  });
+
+  suite('_getSearchSuggestions', () => {
+    test('Autocompletes accounts', () => {
+      sinon.stub(element, 'accountSuggestions').callsFake(() =>
+        Promise.resolve([{text: 'owner:fred@goog.co'}])
+      );
+      return element._getSearchSuggestions('owner:fr').then(s => {
+        assert.equal(s[0].value, 'owner:fred@goog.co');
+      });
+    });
+
+    test('Autocompletes groups', done => {
+      sinon.stub(element, 'groupSuggestions').callsFake(() =>
+        Promise.resolve([
+          {text: 'ownerin:Polygerrit'},
+          {text: 'ownerin:gerrit'},
+        ])
+      );
+      element._getSearchSuggestions('ownerin:pol').then(s => {
+        assert.equal(s[0].value, 'ownerin:Polygerrit');
+        done();
+      });
+    });
+
+    test('Autocompletes projects', done => {
+      sinon.stub(element, 'projectSuggestions').callsFake(() =>
+        Promise.resolve([
+          {text: 'project:Polygerrit'},
+          {text: 'project:gerrit'},
+          {text: 'project:gerrittest'},
+        ])
+      );
+      element._getSearchSuggestions('project:pol').then(s => {
+        assert.equal(s[0].value, 'project:Polygerrit');
+        done();
+      });
+    });
+
+    test('Autocompletes simple searches', done => {
+      element._getSearchSuggestions('is:o').then(s => {
+        assert.equal(s[0].name, 'is:open');
+        assert.equal(s[0].value, 'is:open');
+        assert.equal(s[1].name, 'is:owner');
+        assert.equal(s[1].value, 'is:owner');
+        done();
+      });
+    });
+
+    test('Does not autocomplete with no match', done => {
+      element._getSearchSuggestions('asdasdasdasd').then(s => {
+        assert.equal(s.length, 0);
+        done();
+      });
+    });
+
+    test('Autocompltes without is:mergable when disabled', done => {
+      element._getSearchSuggestions('is:mergeab').then(s => {
+        assert.equal(s.length, 0);
+        done();
+      });
+    });
+  });
+
+  [
+    'API_REF_UPDATED_AND_CHANGE_REINDEX',
+    'REF_UPDATED_AND_CHANGE_REINDEX',
+  ].forEach(mergeability => {
+    suite(`mergeability as ${mergeability}`, () => {
+      setup(done => {
+        stub('gr-rest-api-interface', {
+          getConfig() {
+            return Promise.resolve({
+              index: {
+                mergeabilityComputationBehavior: mergeability,
+              },
+            });
+          },
+        });
+
+        element = basicFixture.instantiate();
+        flush(done);
+      });
+
+      test('Autocompltes with is:mergable when enabled', done => {
+        element._getSearchSuggestions('is:mergeab').then(s => {
+          assert.equal(s.length, 2);
+          assert.equal(s[0].name, 'is:mergeable');
+          assert.equal(s[0].value, 'is:mergeable');
+          assert.equal(s[1].name, '-is:mergeable');
+          assert.equal(s[1].value, '-is:mergeable');
+          done();
+        });
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
index dcece30..772b412 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
@@ -14,30 +14,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-search-bar/gr-search-bar.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-smart-search_html.js';
-import {DisplayNameBehavior} from '../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.js';
 import {GerritNav} from '../gr-navigation/gr-navigation.js';
+import {getUserName} from '../../../utils/display-name-util.js';
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
 const SELF_EXPRESSION = 'self';
 const ME_EXPRESSION = 'me';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrSmartSearch extends mixinBehaviors( [
-  DisplayNameBehavior,
-], GestureEventListeners(
+class GrSmartSearch extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-smart-search'; }
@@ -64,6 +59,14 @@
           return this._fetchAccounts.bind(this);
         },
       },
+      /**
+       * Invisible label for input element. This label is exposed to
+       * screen readers by nested element
+       */
+      label: {
+        type: String,
+        value: '',
+      },
     };
   }
 
@@ -160,7 +163,7 @@
 
   _mapAccountsHelper(accounts, predicate) {
     return accounts.map(account => {
-      const userName = this.getUserName(this._serverConfig, account);
+      const userName = getUserName(this._serverConfig, account);
       return {
         label: account.name || '',
         text: account.email ?
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js
deleted file mode 100644
index bb741ce..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.js
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles"></style>
-  <gr-search-bar
-    id="search"
-    value="{{searchQuery}}"
-    on-handle-search="_handleSearch"
-    project-suggestions="[[_projectSuggestions]]"
-    group-suggestions="[[_groupSuggestions]]"
-    account-suggestions="[[_accountSuggestions]]"
-  ></gr-search-bar>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts
new file mode 100644
index 0000000..7088937
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles"></style>
+  <gr-search-bar
+    id="search"
+    label="[[label]]"
+    value="{{searchQuery}}"
+    on-handle-search="_handleSearch"
+    project-suggestions="[[_projectSuggestions]]"
+    group-suggestions="[[_groupSuggestions]]"
+    account-suggestions="[[_accountSuggestions]]"
+  ></gr-search-bar>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
deleted file mode 100644
index 87dfaf4..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.html
+++ /dev/null
@@ -1,156 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-smart-search</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-smart-search></gr-smart-search>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-smart-search.js';
-suite('gr-smart-search tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('Autocompletes accounts', () => {
-    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-      Promise.resolve([
-        {
-          name: 'fred',
-          email: 'fred@goog.co',
-        },
-      ])
-    );
-    return element._fetchAccounts('owner', 'fr').then(s => {
-      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-    });
-  });
-
-  test('Inserts self as option when valid', () => {
-    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-      Promise.resolve([
-        {
-          name: 'fred',
-          email: 'fred@goog.co',
-        },
-      ])
-    );
-    element._fetchAccounts('owner', 's')
-        .then(s => {
-          assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-          assert.deepEqual(s[1], {text: 'owner:self'});
-        })
-        .then(() => element._fetchAccounts('owner', 'selfs'))
-        .then(s => {
-          assert.notEqual(s[0], {text: 'owner:self'});
-        });
-  });
-
-  test('Inserts me as option when valid', () => {
-    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-      Promise.resolve([
-        {
-          name: 'fred',
-          email: 'fred@goog.co',
-        },
-      ])
-    );
-    return element._fetchAccounts('owner', 'm')
-        .then(s => {
-          assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-          assert.deepEqual(s[1], {text: 'owner:me'});
-        })
-        .then(() => element._fetchAccounts('owner', 'meme'))
-        .then(s => {
-          assert.notEqual(s[0], {text: 'owner:me'});
-        });
-  });
-
-  test('Autocompletes groups', () => {
-    sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
-      Promise.resolve({
-        Polygerrit: 0,
-        gerrit: 0,
-        gerrittest: 0,
-      })
-    );
-    return element._fetchGroups('ownerin', 'pol').then(s => {
-      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
-    });
-  });
-
-  test('Autocompletes projects', () => {
-    sandbox.stub(element.$.restAPI, 'getSuggestedProjects', () =>
-      Promise.resolve({Polygerrit: 0}));
-    return element._fetchProjects('project', 'pol').then(s => {
-      assert.deepEqual(s[0], {text: 'project:Polygerrit'});
-    });
-  });
-
-  test('Autocomplete doesnt override exact matches to input', () => {
-    sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
-      Promise.resolve({
-        Polygerrit: 0,
-        gerrit: 0,
-        gerrittest: 0,
-      })
-    );
-    return element._fetchGroups('ownerin', 'gerrit').then(s => {
-      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
-      assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
-      assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
-    });
-  });
-
-  test('Autocompletes accounts with no email', () => {
-    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-      Promise.resolve([{name: 'fred'}]));
-    return element._fetchAccounts('owner', 'fr').then(s => {
-      assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
-    });
-  });
-
-  test('Autocompletes accounts with email', () => {
-    sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
-      Promise.resolve([{email: 'fred@goog.co'}]));
-    return element._fetchAccounts('owner', 'fr').then(s => {
-      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
new file mode 100644
index 0000000..dc7bf0e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
@@ -0,0 +1,136 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-smart-search.js';
+
+const basicFixture = fixtureFromElement('gr-smart-search');
+
+suite('gr-smart-search tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('Autocompletes accounts', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co',
+        },
+      ])
+    );
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+    });
+  });
+
+  test('Inserts self as option when valid', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co',
+        },
+      ])
+    );
+    element._fetchAccounts('owner', 's')
+        .then(s => {
+          assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+          assert.deepEqual(s[1], {text: 'owner:self'});
+        })
+        .then(() => element._fetchAccounts('owner', 'selfs'))
+        .then(s => {
+          assert.notEqual(s[0], {text: 'owner:self'});
+        });
+  });
+
+  test('Inserts me as option when valid', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co',
+        },
+      ])
+    );
+    return element._fetchAccounts('owner', 'm')
+        .then(s => {
+          assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+          assert.deepEqual(s[1], {text: 'owner:me'});
+        })
+        .then(() => element._fetchAccounts('owner', 'meme'))
+        .then(s => {
+          assert.notEqual(s[0], {text: 'owner:me'});
+        });
+  });
+
+  test('Autocompletes groups', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedGroups').callsFake( () =>
+      Promise.resolve({
+        Polygerrit: 0,
+        gerrit: 0,
+        gerrittest: 0,
+      })
+    );
+    return element._fetchGroups('ownerin', 'pol').then(s => {
+      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
+    });
+  });
+
+  test('Autocompletes projects', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedProjects').callsFake( () =>
+      Promise.resolve({Polygerrit: 0}));
+    return element._fetchProjects('project', 'pol').then(s => {
+      assert.deepEqual(s[0], {text: 'project:Polygerrit'});
+    });
+  });
+
+  test('Autocomplete doesnt override exact matches to input', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedGroups').callsFake( () =>
+      Promise.resolve({
+        Polygerrit: 0,
+        gerrit: 0,
+        gerrittest: 0,
+      })
+    );
+    return element._fetchGroups('ownerin', 'gerrit').then(s => {
+      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
+      assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
+      assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
+    });
+  });
+
+  test('Autocompletes accounts with no email', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+      Promise.resolve([{name: 'fred'}]));
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
+    });
+  });
+
+  test('Autocompletes accounts with email', () => {
+    sinon.stub(element.$.restAPI, 'getSuggestedAccounts').callsFake( () =>
+      Promise.resolve([{email: 'fred@goog.co'}]));
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: ''});
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.html b/polygerrit-ui/app/elements/custom-dark-theme_test.html
deleted file mode 100644
index 6ac9c20..0000000
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.html
+++ /dev/null
@@ -1,103 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-app-it_test</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="element">
-  <template>
-    <gr-app id="app"></gr-app>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../test/common-test-setup.js';
-import './gr-app.js';
-import {util} from '../scripts/util.js';
-import {pluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
-
-suite('gr-app custom dark theme tests', () => {
-  let sandbox;
-  let element;
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-reporting', {
-      appStarted: sandbox.stub(),
-    });
-    stub('gr-account-dropdown', {
-      _getTopContent: sinon.stub(),
-    });
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(null); },
-      getAccountCapabilities() { return Promise.resolve({}); },
-      getConfig() {
-        return Promise.resolve({
-          plugin: {
-            js_resource_paths: [],
-            html_resource_paths: [
-              new URL('test/plugin.html', window.location.href).toString(),
-            ],
-          },
-        });
-      },
-      getVersion() { return Promise.resolve(42); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
-
-    window.localStorage.setItem('dark-theme', 'true');
-
-    element = fixture('element');
-
-    const importSpy = sandbox.spy(
-        element.$['app-element'].$.externalStyleForAll,
-        '_import');
-    const importForThemeSpy = sandbox.spy(
-        element.$['app-element'].$.externalStyleForTheme,
-        '_import');
-    pluginLoader.awaitPluginsLoaded().then(() => {
-      Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
-          .then(() => {
-            flush(done);
-          });
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('applies the right theme', () => {
-    assert.equal(
-        util.getComputedStyleValue('--primary-text-color', element),
-        'red');
-    assert.equal(
-        util.getComputedStyleValue('--header-background-color', element),
-        'black');
-    assert.equal(
-        util.getComputedStyleValue('--footer-background-color', element),
-        'yellow');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.js b/polygerrit-ui/app/elements/custom-dark-theme_test.js
new file mode 100644
index 0000000..d4f790f
--- /dev/null
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.js
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {getComputedStyleValue} from '../utils/dom-util.js';
+import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import './gr-app.js';
+import {pluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
+import {removeTheme} from '../styles/themes/dark-theme.js';
+
+const basicFixture = fixtureFromElement('gr-app');
+
+suite('gr-app custom dark theme tests', () => {
+  let element;
+  setup(done => {
+    window.localStorage.setItem('dark-theme', 'true');
+
+    element = basicFixture.instantiate();
+    pluginLoader.loadPlugins([]);
+    pluginLoader.awaitPluginsLoaded().then(() => flush(done));
+  });
+
+  teardown(() => {
+    window.localStorage.removeItem('dark-theme');
+    removeTheme();
+    // The app sends requests to server. This can lead to
+    // unexpected gr-alert elements in document.body
+    document.body.querySelectorAll('gr-alert').forEach(grAlert => {
+      grAlert.remove();
+    });
+  });
+
+  test('should tried to load dark theme', () => {
+    assert.isTrue(
+        !!document.head.querySelector('#dark-theme')
+    );
+  });
+
+  test('applies the right theme', () => {
+    assert.equal(
+        getComputedStyleValue('--header-background-color', element)
+            .toLowerCase(),
+        '#3b3d3f');
+    assert.equal(
+        getComputedStyleValue('--footer-background-color', element)
+            .toLowerCase(),
+        '#3b3d3f');
+  });
+});
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.html b/polygerrit-ui/app/elements/custom-light-theme_test.html
deleted file mode 100644
index f8a749c..0000000
--- a/polygerrit-ui/app/elements/custom-light-theme_test.html
+++ /dev/null
@@ -1,103 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-app-it_test</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="element">
-  <template>
-    <gr-app id="app"></gr-app>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../test/common-test-setup.js';
-import './gr-app.js';
-import {util} from '../scripts/util.js';
-import {pluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
-
-suite('gr-app custom light theme tests', () => {
-  let sandbox;
-  let element;
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-reporting', {
-      appStarted: sandbox.stub(),
-    });
-    stub('gr-account-dropdown', {
-      _getTopContent: sinon.stub(),
-    });
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(null); },
-      getAccountCapabilities() { return Promise.resolve({}); },
-      getConfig() {
-        return Promise.resolve({
-          plugin: {
-            js_resource_paths: [],
-            html_resource_paths: [
-              new URL('test/plugin.html', window.location.href).toString(),
-            ],
-          },
-        });
-      },
-      getVersion() { return Promise.resolve(42); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
-
-    window.localStorage.removeItem('dark-theme');
-
-    element = fixture('element');
-
-    const importSpy = sandbox.spy(
-        element.$['app-element'].$.externalStyleForAll,
-        '_import');
-    const importForThemeSpy = sandbox.spy(
-        element.$['app-element'].$.externalStyleForTheme,
-        '_import');
-    pluginLoader.awaitPluginsLoaded().then(() => {
-      Promise.all(importSpy.returnValues.concat(importForThemeSpy.returnValues))
-          .then(() => {
-            flush(done);
-          });
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('applies the right theme', () => {
-    assert.equal(
-        util.getComputedStyleValue('--primary-text-color', element),
-        '#F00BAA');
-    assert.equal(
-        util.getComputedStyleValue('--header-background-color', element),
-        '#F01BAA');
-    assert.equal(
-        util.getComputedStyleValue('--footer-background-color', element),
-        '#F02BAA');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.js b/polygerrit-ui/app/elements/custom-light-theme_test.js
new file mode 100644
index 0000000..5c0cf28
--- /dev/null
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.js
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {getComputedStyleValue} from '../utils/dom-util.js';
+import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import './gr-app.js';
+import {pluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
+
+const basicFixture = fixtureFromElement('gr-app');
+
+suite('gr-app custom light theme tests', () => {
+  let element;
+  setup(done => {
+    window.localStorage.removeItem('dark-theme');
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({test: 'config'}); },
+      getAccount() { return Promise.resolve({}); },
+      getDiffComments() { return Promise.resolve({}); },
+      getDiffRobotComments() { return Promise.resolve({}); },
+      getDiffDrafts() { return Promise.resolve({}); },
+      _fetchSharedCacheURL() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+    pluginLoader.loadPlugins([]);
+    pluginLoader.awaitPluginsLoaded().then(() => flush(done));
+  });
+  teardown(() => {
+    // The app sends requests to server. This can lead to
+    // unexpected gr-alert elements in document.body
+    document.body.querySelectorAll('gr-alert').forEach(grAlert => {
+      grAlert.remove();
+    });
+  });
+
+  test('should not load dark theme', () => {
+    assert.isFalse(!!document.head.querySelector('#dark-theme'));
+    assert.isTrue(!!document.head.querySelector('#light-theme'));
+  });
+
+  test('applies the right theme', () => {
+    assert.equal(
+        getComputedStyleValue('--header-background-color', element)
+            .toLowerCase(),
+        '#f1f3f4');
+    assert.equal(
+        getComputedStyleValue('--footer-background-color', element)
+            .toLowerCase(),
+        'transparent');
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js
index 9beb243..d051d30 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-icon/iron-icon.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-dialog/gr-dialog.js';
@@ -29,7 +27,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrApplyFixDialog extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -126,10 +124,9 @@
         .getRobotCommentFixPreview(this.changeNum, this._patchNum, fixId)
         .then(res => {
           if (res != null) {
-            const previews = Object.keys(res).map(key => {
+            this._currentPreviews = Object.keys(res).map(key => {
               return {filepath: key, preview: res[key]};
             });
-            this._currentPreviews = previews;
           }
         })
         .catch(err => {
@@ -144,7 +141,7 @@
 
   overridePartialPrefs(prefs) {
     // generate a smaller gr-diff than fullscreen for dialog
-    return Object.assign({}, prefs, {line_length: 50});
+    return {...prefs, line_length: 50};
   }
 
   onCancel(e) {
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.js b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.js
deleted file mode 100644
index a5a6ff2..0000000
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-diff {
-      --content-width: 90vw;
-    }
-    .diffContainer {
-      padding: var(--spacing-l) 0;
-      border-bottom: 1px solid var(--border-color);
-    }
-    .file-name {
-      display: block;
-      padding: var(--spacing-s) var(--spacing-l);
-      background-color: var(--background-color-secondary);
-      border-bottom: 1px solid var(--border-color);
-    }
-    .fixActions {
-      display: flex;
-      justify-content: flex-end;
-    }
-    gr-button {
-      margin-left: var(--spacing-m);
-    }
-    .fix-picker {
-      display: flex;
-      align-items: center;
-      margin-right: var(--spacing-l);
-    }
-  </style>
-  <gr-overlay id="applyFixOverlay" with-backdrop="">
-    <gr-dialog
-      id="applyFixDialog"
-      on-confirm="_handleApplyFix"
-      confirm-label="[[_getApplyFixButtonLabel(_isApplyFixLoading)]]"
-      disabled="[[_disableApplyFixButton]]"
-      confirm-tooltip="[[_computeTooltip(change, _patchNum)]]"
-      on-cancel="onCancel"
-    >
-      <div slot="header">[[_robotId]] - [[getFixDescription(_currentFix)]]</div>
-      <div slot="main">
-        <template is="dom-repeat" items="[[_currentPreviews]]">
-          <div class="file-name">
-            <span>[[item.filepath]]</span>
-          </div>
-          <div class="diffContainer">
-            <gr-diff
-              prefs="[[overridePartialPrefs(prefs)]]"
-              change-num="[[changeNum]]"
-              path="[[item.filepath]]"
-              diff="[[item.preview]]"
-            ></gr-diff>
-          </div>
-        </template>
-      </div>
-      <div
-        slot="footer"
-        class="fix-picker"
-        hidden$="[[hasSingleFix(_fixSuggestions)]]"
-      >
-        <span
-          >Suggested fix [[addOneTo(_selectedFixIdx)]] of
-          [[_fixSuggestions.length]]</span
-        >
-        <gr-button
-          id="prevFix"
-          on-click="_onPrevFixClick"
-          disabled$="[[_noPrevFix(_selectedFixIdx)]]"
-        >
-          <iron-icon icon="gr-icons:chevron-left"></iron-icon>
-        </gr-button>
-        <gr-button
-          id="nextFix"
-          on-click="_onNextFixClick"
-          disabled$="[[_noNextFix(_selectedFixIdx, _fixSuggestions)]]"
-        >
-          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-        </gr-button>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
new file mode 100644
index 0000000..057fd01
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    gr-diff {
+      --content-width: 90vw;
+    }
+    .diffContainer {
+      padding: var(--spacing-l) 0;
+      border-bottom: 1px solid var(--border-color);
+    }
+    .file-name {
+      display: block;
+      padding: var(--spacing-s) var(--spacing-l);
+      background-color: var(--background-color-secondary);
+      border-bottom: 1px solid var(--border-color);
+    }
+    .fixActions {
+      display: flex;
+      justify-content: flex-end;
+    }
+    gr-button {
+      margin-left: var(--spacing-m);
+    }
+    .fix-picker {
+      display: flex;
+      align-items: center;
+      margin-right: var(--spacing-l);
+    }
+  </style>
+  <gr-overlay id="applyFixOverlay" with-backdrop="">
+    <gr-dialog
+      id="applyFixDialog"
+      on-confirm="_handleApplyFix"
+      confirm-label="[[_getApplyFixButtonLabel(_isApplyFixLoading)]]"
+      disabled="[[_disableApplyFixButton]]"
+      confirm-tooltip="[[_computeTooltip(change, _patchNum)]]"
+      on-cancel="onCancel"
+    >
+      <div slot="header">[[_robotId]] - [[getFixDescription(_currentFix)]]</div>
+      <div slot="main">
+        <template is="dom-repeat" items="[[_currentPreviews]]">
+          <div class="file-name">
+            <span>[[item.filepath]]</span>
+          </div>
+          <div class="diffContainer">
+            <gr-diff
+              prefs="[[overridePartialPrefs(prefs)]]"
+              change-num="[[changeNum]]"
+              path="[[item.filepath]]"
+              diff="[[item.preview]]"
+            ></gr-diff>
+          </div>
+        </template>
+      </div>
+      <div
+        slot="footer"
+        class="fix-picker"
+        hidden$="[[hasSingleFix(_fixSuggestions)]]"
+      >
+        <span
+          >Suggested fix [[addOneTo(_selectedFixIdx)]] of
+          [[_fixSuggestions.length]]</span
+        >
+        <gr-button
+          id="prevFix"
+          on-click="_onPrevFixClick"
+          disabled$="[[_noPrevFix(_selectedFixIdx)]]"
+        >
+          <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+        </gr-button>
+        <gr-button
+          id="nextFix"
+          on-click="_onNextFixClick"
+          disabled$="[[_noNextFix(_selectedFixIdx, _fixSuggestions)]]"
+        >
+          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+        </gr-button>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html
deleted file mode 100644
index 8874f71..0000000
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html
+++ /dev/null
@@ -1,323 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the 'License');
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an 'AS IS' BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<meta name='viewport' content='width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes'>
-<title>gr-apply-fix-dialog</title>
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../../test/common-test-setup.js';
-import './gr-apply-fix-dialog.js';
-void (0);
-</script>
-
-<test-fixture id='basic'>
-  <template>
-    <gr-apply-fix-dialog></gr-apply-fix-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../../test/common-test-setup.js';
-import './gr-apply-fix-dialog.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-apply-fix-dialog tests', () => {
-  let element;
-  let sandbox;
-  const ROBOT_COMMENT_WITH_TWO_FIXES = {
-    robot_id: 'robot_1',
-    fix_suggestions: [{fix_id: 'fix_1'}, {fix_id: 'fix_2'}],
-  };
-
-  const ROBOT_COMMENT_WITH_ONE_FIX = {
-    robot_id: 'robot_1',
-    fix_suggestions: [{fix_id: 'fix_1'}],
-  };
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    element.changeNum = '1';
-    element._patchNum = 2;
-    element.change = {
-      _number: '1',
-      project: 'project',
-      revisions: {
-        abcd: {_number: 1},
-        efgh: {_number: 2},
-      },
-      current_revision: 'efgh',
-    };
-    element.prefs = {
-      font_size: 12,
-      line_length: 100,
-      tab_size: 4,
-    };
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('dialog open', () => {
-    setup(() => {
-      sandbox.stub(element.$.restAPI, 'getRobotCommentFixPreview')
-          .returns(Promise.resolve({
-            f1: {
-              meta_a: {},
-              meta_b: {},
-              content: [
-                {
-                  ab: ['loqlwkqll'],
-                },
-                {
-                  b: ['qwqqsqw'],
-                },
-                {
-                  ab: ['qwqqsqw', 'qweqeqweqeq', 'qweqweq'],
-                },
-              ],
-            },
-            f2: {
-              meta_a: {},
-              meta_b: {},
-              content: [
-                {
-                  ab: ['eqweqweqwex'],
-                },
-                {
-                  b: ['zassdasd'],
-                },
-                {
-                  ab: ['zassdasd', 'dasdasda', 'asdasdad'],
-                },
-              ],
-            },
-          }));
-      sandbox.stub(element.$.applyFixOverlay, 'open')
-          .returns(Promise.resolve());
-    });
-
-    test('dialog opens fetch and sets previews', done => {
-      element.open({detail: {patchNum: 2,
-        comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
-          .then(() => {
-            assert.equal(element._currentFix.fix_id, 'fix_1');
-            assert.equal(element._currentPreviews.length, 2);
-            assert.equal(element._robotId, 'robot_1');
-            const button = element.shadowRoot.querySelector(
-                '#applyFixDialog').shadowRoot.querySelector('#confirm');
-            assert.isFalse(button.hasAttribute('disabled'));
-            assert.equal(button.getAttribute('title'), '');
-            done();
-          });
-    });
-
-    test('tooltip is hidden if apply fix is loading', done => {
-      element.open({detail: {patchNum: 2,
-        comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
-          .then(() => {
-            element._isApplyFixLoading = true;
-            const button = element.shadowRoot.querySelector(
-                '#applyFixDialog').shadowRoot.querySelector('#confirm');
-            assert.isTrue(button.hasAttribute('disabled'));
-            assert.equal(button.getAttribute('title'), '');
-            done();
-          });
-    });
-
-    test('apply fix button is disabled on older patchset', done => {
-      element.change = {
-        _number: '1',
-        project: 'project',
-        revisions: {
-          abcd: {_number: 1},
-          efgh: {_number: 2},
-        },
-        current_revision: 'abcd',
-      };
-      element.open({detail: {patchNum: 2,
-        comment: ROBOT_COMMENT_WITH_ONE_FIX}})
-          .then(() => {
-            flush(() => {
-              const button = element.shadowRoot.querySelector(
-                  '#applyFixDialog').shadowRoot.querySelector('#confirm');
-              assert.isTrue(button.hasAttribute('disabled'));
-              assert.equal(button.getAttribute('title'),
-                  'Fix can only be applied to the latest patchset');
-              done();
-            });
-          });
-    });
-  });
-
-  test('next button state updated when suggestions changed', done => {
-    sandbox.stub(element.$.restAPI, 'getRobotCommentFixPreview')
-        .returns(Promise.resolve({}));
-    sandbox.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
-
-    element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_ONE_FIX}})
-        .then(() => assert.isTrue(element.$.nextFix.disabled))
-        .then(() =>
-          element.open({detail: {patchNum: 2,
-            comment: ROBOT_COMMENT_WITH_TWO_FIXES}}))
-        .then(() => {
-          assert.isFalse(element.$.nextFix.disabled);
-          done();
-        });
-  });
-
-  test('preview endpoint throws error should reset dialog', done => {
-    sandbox.stub(window, 'fetch', (url => {
-      if (url.endsWith('/preview')) {
-        return Promise.reject(new Error('backend error'));
-      }
-      return Promise.resolve({
-        ok: true,
-        text() { return Promise.resolve(''); },
-        status: 200,
-      });
-    }));
-    const errorStub = sinon.stub();
-    document.addEventListener('network-error', errorStub);
-    element.open({detail: {patchNum: 2,
-      comment: ROBOT_COMMENT_WITH_TWO_FIXES}});
-    flush(() => {
-      assert.isTrue(errorStub.called);
-      assert.deepEqual(element._currentFix, {});
-      done();
-    });
-  });
-
-  test('apply fix button should call apply ' +
-  'and navigate to change view', done => {
-    sandbox.stub(element.$.restAPI, 'applyFixSuggestion')
-        .returns(Promise.resolve({ok: true}));
-    sandbox.stub(GerritNav, 'navigateToChange');
-    element._currentFix = {fix_id: '123'};
-
-    element._handleApplyFix().then(() => {
-      assert.isTrue(element.$.restAPI.applyFixSuggestion
-          .calledWithExactly('1', 2, '123'));
-      assert.isTrue(GerritNav.navigateToChange.calledWithExactly({
-        _number: '1',
-        project: 'project',
-        revisions: {
-          abcd: {_number: 1},
-          efgh: {_number: 2},
-        },
-        current_revision: 'efgh',
-      }, 'edit', 2));
-
-      // reset gr-apply-fix-dialog and close
-      assert.deepEqual(element._currentFix, {});
-      assert.equal(element._currentPreviews.length, 0);
-      done();
-    });
-  });
-
-  test('should not navigate to change view if incorect reponse', done => {
-    sandbox.stub(element.$.restAPI, 'applyFixSuggestion')
-        .returns(Promise.resolve({}));
-    sandbox.stub(GerritNav, 'navigateToChange');
-    element._currentFix = {fix_id: '123'};
-
-    element._handleApplyFix().then(() => {
-      assert.isTrue(element.$.restAPI.applyFixSuggestion
-          .calledWithExactly('1', 2, '123'));
-      assert.isTrue(GerritNav.navigateToChange.notCalled);
-
-      assert.equal(element._isApplyFixLoading, false);
-      done();
-    });
-  });
-
-  test('select fix forward and back of multiple suggested fixes', done => {
-    sandbox.stub(element.$.restAPI, 'getRobotCommentFixPreview')
-        .returns(Promise.resolve({
-          f1: {
-            meta_a: {},
-            meta_b: {},
-            content: [
-              {
-                ab: ['loqlwkqll'],
-              },
-              {
-                b: ['qwqqsqw'],
-              },
-              {
-                ab: ['qwqqsqw', 'qweqeqweqeq', 'qweqweq'],
-              },
-            ],
-          },
-          f2: {
-            meta_a: {},
-            meta_b: {},
-            content: [
-              {
-                ab: ['eqweqweqwex'],
-              },
-              {
-                b: ['zassdasd'],
-              },
-              {
-                ab: ['zassdasd', 'dasdasda', 'asdasdad'],
-              },
-            ],
-          },
-        }));
-    sandbox.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
-
-    element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
-        .then(() => {
-          element._onNextFixClick();
-          assert.equal(element._currentFix.fix_id, 'fix_2');
-          element._onPrevFixClick();
-          assert.equal(element._currentFix.fix_id, 'fix_1');
-          done();
-        });
-  });
-
-  test('server-error should throw for failed apply call', done => {
-    sandbox.stub(window, 'fetch', (url => {
-      if (url.endsWith('/apply')) {
-        return Promise.reject(new Error('backend error'));
-      }
-      return Promise.resolve({
-        ok: true,
-        text() { return Promise.resolve(''); },
-        status: 200,
-      });
-    }));
-    const errorStub = sinon.stub();
-    document.addEventListener('network-error', errorStub);
-    sandbox.stub(GerritNav, 'navigateToChange');
-    element._currentFix = {fix_id: '123'};
-    element._handleApplyFix();
-    flush(() => {
-      assert.isFalse(GerritNav.navigateToChange.called);
-      assert.isTrue(errorStub.called);
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js
new file mode 100644
index 0000000..b3ba637
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js
@@ -0,0 +1,299 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-apply-fix-dialog.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-apply-fix-dialog');
+
+suite('gr-apply-fix-dialog tests', () => {
+  let element;
+
+  const ROBOT_COMMENT_WITH_TWO_FIXES = {
+    robot_id: 'robot_1',
+    fix_suggestions: [{fix_id: 'fix_1'}, {fix_id: 'fix_2'}],
+  };
+
+  const ROBOT_COMMENT_WITH_ONE_FIX = {
+    robot_id: 'robot_1',
+    fix_suggestions: [{fix_id: 'fix_1'}],
+  };
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.changeNum = '1';
+    element._patchNum = 2;
+    element.change = {
+      _number: '1',
+      project: 'project',
+      revisions: {
+        abcd: {_number: 1},
+        efgh: {_number: 2},
+      },
+      current_revision: 'efgh',
+    };
+    element.prefs = {
+      font_size: 12,
+      line_length: 100,
+      tab_size: 4,
+    };
+  });
+
+  suite('dialog open', () => {
+    setup(() => {
+      sinon.stub(element.$.restAPI, 'getRobotCommentFixPreview')
+          .returns(Promise.resolve({
+            f1: {
+              meta_a: {},
+              meta_b: {},
+              content: [
+                {
+                  ab: ['loqlwkqll'],
+                },
+                {
+                  b: ['qwqqsqw'],
+                },
+                {
+                  ab: ['qwqqsqw', 'qweqeqweqeq', 'qweqweq'],
+                },
+              ],
+            },
+            f2: {
+              meta_a: {},
+              meta_b: {},
+              content: [
+                {
+                  ab: ['eqweqweqwex'],
+                },
+                {
+                  b: ['zassdasd'],
+                },
+                {
+                  ab: ['zassdasd', 'dasdasda', 'asdasdad'],
+                },
+              ],
+            },
+          }));
+      sinon.stub(element.$.applyFixOverlay, 'open')
+          .returns(Promise.resolve());
+    });
+
+    test('dialog opens fetch and sets previews', done => {
+      element.open({detail: {patchNum: 2,
+        comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
+          .then(() => {
+            assert.equal(element._currentFix.fix_id, 'fix_1');
+            assert.equal(element._currentPreviews.length, 2);
+            assert.equal(element._robotId, 'robot_1');
+            const button = element.shadowRoot.querySelector(
+                '#applyFixDialog').shadowRoot.querySelector('#confirm');
+            assert.isFalse(button.hasAttribute('disabled'));
+            assert.equal(button.getAttribute('title'), '');
+            done();
+          });
+    });
+
+    test('tooltip is hidden if apply fix is loading', done => {
+      element.open({detail: {patchNum: 2,
+        comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
+          .then(() => {
+            element._isApplyFixLoading = true;
+            const button = element.shadowRoot.querySelector(
+                '#applyFixDialog').shadowRoot.querySelector('#confirm');
+            assert.isTrue(button.hasAttribute('disabled'));
+            assert.equal(button.getAttribute('title'), '');
+            done();
+          });
+    });
+
+    test('apply fix button is disabled on older patchset', done => {
+      element.change = {
+        _number: '1',
+        project: 'project',
+        revisions: {
+          abcd: {_number: 1},
+          efgh: {_number: 2},
+        },
+        current_revision: 'abcd',
+      };
+      element.open({detail: {patchNum: 2,
+        comment: ROBOT_COMMENT_WITH_ONE_FIX}})
+          .then(() => {
+            flush(() => {
+              const button = element.shadowRoot.querySelector(
+                  '#applyFixDialog').shadowRoot.querySelector('#confirm');
+              assert.isTrue(button.hasAttribute('disabled'));
+              assert.equal(button.getAttribute('title'),
+                  'Fix can only be applied to the latest patchset');
+              done();
+            });
+          });
+    });
+  });
+
+  test('next button state updated when suggestions changed', done => {
+    sinon.stub(element.$.restAPI, 'getRobotCommentFixPreview')
+        .returns(Promise.resolve({}));
+    sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+
+    element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_ONE_FIX}})
+        .then(() => assert.isTrue(element.$.nextFix.disabled))
+        .then(() =>
+          element.open({detail: {patchNum: 2,
+            comment: ROBOT_COMMENT_WITH_TWO_FIXES}}))
+        .then(() => {
+          assert.isFalse(element.$.nextFix.disabled);
+          done();
+        });
+  });
+
+  test('preview endpoint throws error should reset dialog', done => {
+    sinon.stub(window, 'fetch').callsFake((url => {
+      if (url.endsWith('/preview')) {
+        return Promise.reject(new Error('backend error'));
+      }
+      return Promise.resolve({
+        ok: true,
+        text() { return Promise.resolve(''); },
+        status: 200,
+      });
+    }));
+    const errorStub = sinon.stub();
+    document.addEventListener('network-error', errorStub);
+    element.open({detail: {patchNum: 2,
+      comment: ROBOT_COMMENT_WITH_TWO_FIXES}});
+    flush(() => {
+      assert.isTrue(errorStub.called);
+      assert.deepEqual(element._currentFix, {});
+      done();
+    });
+  });
+
+  test('apply fix button should call apply ' +
+  'and navigate to change view', done => {
+    sinon.stub(element.$.restAPI, 'applyFixSuggestion')
+        .returns(Promise.resolve({ok: true}));
+    sinon.stub(GerritNav, 'navigateToChange');
+    element._currentFix = {fix_id: '123'};
+
+    element._handleApplyFix().then(() => {
+      assert.isTrue(element.$.restAPI.applyFixSuggestion
+          .calledWithExactly('1', 2, '123'));
+      assert.isTrue(GerritNav.navigateToChange.calledWithExactly({
+        _number: '1',
+        project: 'project',
+        revisions: {
+          abcd: {_number: 1},
+          efgh: {_number: 2},
+        },
+        current_revision: 'efgh',
+      }, 'edit', 2));
+
+      // reset gr-apply-fix-dialog and close
+      assert.deepEqual(element._currentFix, {});
+      assert.equal(element._currentPreviews.length, 0);
+      done();
+    });
+  });
+
+  test('should not navigate to change view if incorect reponse', done => {
+    sinon.stub(element.$.restAPI, 'applyFixSuggestion')
+        .returns(Promise.resolve({}));
+    sinon.stub(GerritNav, 'navigateToChange');
+    element._currentFix = {fix_id: '123'};
+
+    element._handleApplyFix().then(() => {
+      assert.isTrue(element.$.restAPI.applyFixSuggestion
+          .calledWithExactly('1', 2, '123'));
+      assert.isTrue(GerritNav.navigateToChange.notCalled);
+
+      assert.equal(element._isApplyFixLoading, false);
+      done();
+    });
+  });
+
+  test('select fix forward and back of multiple suggested fixes', done => {
+    sinon.stub(element.$.restAPI, 'getRobotCommentFixPreview')
+        .returns(Promise.resolve({
+          f1: {
+            meta_a: {},
+            meta_b: {},
+            content: [
+              {
+                ab: ['loqlwkqll'],
+              },
+              {
+                b: ['qwqqsqw'],
+              },
+              {
+                ab: ['qwqqsqw', 'qweqeqweqeq', 'qweqweq'],
+              },
+            ],
+          },
+          f2: {
+            meta_a: {},
+            meta_b: {},
+            content: [
+              {
+                ab: ['eqweqweqwex'],
+              },
+              {
+                b: ['zassdasd'],
+              },
+              {
+                ab: ['zassdasd', 'dasdasda', 'asdasdad'],
+              },
+            ],
+          },
+        }));
+    sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+
+    element.open({detail: {patchNum: 2, comment: ROBOT_COMMENT_WITH_TWO_FIXES}})
+        .then(() => {
+          element._onNextFixClick();
+          assert.equal(element._currentFix.fix_id, 'fix_2');
+          element._onPrevFixClick();
+          assert.equal(element._currentFix.fix_id, 'fix_1');
+          done();
+        });
+  });
+
+  test('server-error should throw for failed apply call', done => {
+    sinon.stub(window, 'fetch').callsFake((url => {
+      if (url.endsWith('/apply')) {
+        return Promise.reject(new Error('backend error'));
+      }
+      return Promise.resolve({
+        ok: true,
+        text() { return Promise.resolve(''); },
+        status: 200,
+      });
+    }));
+    const errorStub = sinon.stub();
+    document.addEventListener('network-error', errorStub);
+    sinon.stub(GerritNav, 'navigateToChange');
+    element._currentFix = {fix_id: '123'};
+    element._handleApplyFix();
+    flush(() => {
+      assert.isFalse(GerritNav.navigateToChange.called);
+      assert.isTrue(errorStub.called);
+      done();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock_test.js
deleted file mode 100644
index 77c72d4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api-mock_test.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-
-class CommentApiMock extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get is() { return 'comment-api-mock'; }
-
-  static get properties() {
-    return {
-      _changeComments: Object,
-    };
-  }
-
-  loadComments() {
-    return this._reloadComments();
-  }
-
-  /**
-   * For the purposes of the mock, _reloadDrafts is not included because its
-   * response is the same type as reloadComments, just makes less API
-   * requests. Since this is for test purposes/mocked data anyway, keep this
-   * file simpler by just using _reloadComments here instead.
-   */
-  _reloadDraftsWithCallback(e) {
-    return this._reloadComments().then(() => e.detail.resolve());
-  }
-
-  _reloadComments() {
-    return this.$.commentAPI.loadAll(this._changeNum)
-        .then(comments => {
-          this._changeComments = this.$.commentAPI._changeComments;
-        });
-  }
-}
-
-customElements.define(CommentApiMock.is, CommentApiMock);
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
index 3f7da5a..a2cc067 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
@@ -14,16 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-comment-api_html.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
-import {util} from '../../../scripts/util.js';
+import {parseDate} from '../../../utils/date-util.js';
+import {
+  getParentIndex,
+  isMergeParent,
+  patchNumEquals,
+} from '../../../utils/patch-set-util.js';
 
 const PARENT = 'PARENT';
 
@@ -39,20 +40,33 @@
  */
 class ChangeComments {
   constructor(comments, robotComments, drafts, changeNum) {
-    // TODO(taoalpha): replace these with exported methods from patchset behavior
-    this._patchNumEquals =
-      PatchSetBehavior.patchNumEquals;
-    this._isMergeParent =
-      PatchSetBehavior.isMergeParent;
-    this._getParentIndex =
-      PatchSetBehavior.getParentIndex;
-
-    this._comments = comments || {};
-    this._robotComments = robotComments || {};
-    this._drafts = drafts || {};
+    this._comments = this._addPath(comments);
+    this._robotComments = this._addPath(robotComments);
+    this._drafts = this._addPath(drafts);
     this._changeNum = changeNum;
   }
 
+  /**
+   * Add path info to every comment as CommentInfo returned
+   * from server does not have that.
+   *
+   * TODO(taoalpha): should consider changing BE to send path
+   * back within CommentInfo
+   *
+   * @param {Object} - map between file path and comments
+   */
+  _addPath(comments = {}) {
+    const updatedComments = {};
+    for (const filePath of Object.keys(comments)) {
+      const allCommentsForPath = comments[filePath] || [];
+      if (allCommentsForPath.length) {
+        updatedComments[filePath] = allCommentsForPath
+            .map(comment => { return {...comment, path: filePath}; });
+      }
+    }
+    return updatedComments;
+  }
+
   get comments() {
     return this._comments;
   }
@@ -65,6 +79,17 @@
     return this._robotComments;
   }
 
+  findCommentById(commentId) {
+    const findComment = comments => {
+      let comment;
+      for (const path of Object.keys(comments)) {
+        comment = comment || comments[path].find(c => c.id === commentId);
+      }
+      return comment;
+    };
+    return findComment(this._comments) || findComment(this._robotComments);
+  }
+
   /**
    * Get an object mapping file paths to a boolean representing whether that
    * path contains diff comments in the given patch set (including drafts and
@@ -160,19 +185,17 @@
     const paths = this.getPaths();
     const publishedComments = {};
     for (const path of Object.keys(paths)) {
-      let commentsToAdd = this.getAllCommentsForPath(path, opt_patchNum);
-      if (opt_includeDrafts) {
-        const drafts = this.getAllDraftsForPath(path, opt_patchNum)
-            .map(d => Object.assign({__draft: true}, d));
-        commentsToAdd = commentsToAdd.concat(drafts);
-      }
-      publishedComments[path] = commentsToAdd;
+      publishedComments[path] = this.getAllCommentsForPath(
+          path,
+          opt_patchNum,
+          opt_includeDrafts
+      );
     }
     return publishedComments;
   }
 
   /**
-   * Gets all the comments and robot comments for the given change.
+   * Gets all the drafts for the given change.
    *
    * @param {number=} opt_patchNum
    * @return {!Object}
@@ -189,6 +212,9 @@
   /**
    * Get the comments (robot comments) for a path and optional patch num.
    *
+   * This method will always return a new shallow copy of all comments,
+   * so manipulation on one copy won't affect other copies.
+   *
    * @param {!string} path
    * @param {number=} opt_patchNum
    * @param {boolean=} opt_includeDrafts
@@ -200,14 +226,15 @@
     const robotComments = this._robotComments[path] || [];
     let allComments = comments.concat(robotComments);
     if (opt_includeDrafts) {
-      const drafts = this.getAllDraftsForPath(path)
-          .map(d => Object.assign({__draft: true}, d));
+      const drafts = this.getAllDraftsForPath(path);
       allComments = allComments.concat(drafts);
     }
-    if (!opt_patchNum) { return allComments; }
-    return (allComments || []).filter(c =>
-      this._patchNumEquals(c.patch_set, opt_patchNum)
-    );
+    if (opt_patchNum) {
+      allComments = allComments.filter(c =>
+        patchNumEquals(c.patch_set, opt_patchNum)
+      );
+    }
+    return allComments.map(c => { return {...c}; });
   }
 
   /**
@@ -215,7 +242,7 @@
    *
    * // TODO(taoalpha): maybe merge in *ForPath
    *
-   * @param {!{path: string, oldPath?: string, patchNum?: number}} file
+   * @param {!{path: string, basePath?: string, patchNum?: number}} file
    * @param {boolean=} opt_includeDrafts
    * @return {!Array}
    */
@@ -224,10 +251,10 @@
         file.path, file.patchNum, opt_includeDrafts
     );
 
-    if (file.oldPath) {
+    if (file.basePath) {
       allComments = allComments.concat(
           this.getAllCommentsForPath(
-              file.oldPath, file.patchNum, opt_includeDrafts
+              file.basePath, file.patchNum, opt_includeDrafts
           )
       );
     }
@@ -238,17 +265,22 @@
   /**
    * Get the drafts for a path and optional patch num.
    *
+   * This will return a shallow copy of all drafts every time,
+   * so changes on any copy will not affect other copies.
+   *
    * @param {!string} path
    * @param {number=} opt_patchNum
    * @return {!Array}
    */
   getAllDraftsForPath(path,
       opt_patchNum) {
-    const comments = this._drafts[path] || [];
-    if (!opt_patchNum) { return comments; }
-    return (comments || []).filter(c =>
-      this._patchNumEquals(c.patch_set, opt_patchNum)
-    );
+    let comments = this._drafts[path] || [];
+    if (opt_patchNum) {
+      comments = comments.filter(c =>
+        patchNumEquals(c.patch_set, opt_patchNum)
+      );
+    }
+    return comments.map(c => { return {...c, __draft: true}; });
   }
 
   /**
@@ -256,14 +288,14 @@
    *
    * // TODO(taoalpha): maybe merge in *ForPath
    *
-   * @param {!{path: string, oldPath?: string, patchNum?: number}} file
+   * @param {!{path: string, basePath?: string, patchNum?: number}} file
    * @return {!Array}
    */
   getAllDraftsForFile(file) {
     let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
-    if (file.oldPath) {
+    if (file.basePath) {
       allDrafts = allDrafts.concat(
-          this.getAllDraftsForPath(file.oldPath, file.patchNum)
+          this.getAllDraftsForPath(file.basePath, file.patchNum)
       );
     }
     return allDrafts;
@@ -298,7 +330,8 @@
 
     drafts.forEach(d => { d.__draft = true; });
 
-    const all = comments.concat(drafts).concat(robotComments);
+    const all = comments.concat(drafts).concat(robotComments)
+        .map(c => { return {...c}; });
 
     const baseComments = all.filter(c =>
       this._isInBaseOfPatchRange(c, patchRange));
@@ -324,7 +357,7 @@
    *
    * // TODO(taoalpha): maybe merge *ForPath so find all comments in one pass
    *
-   * @param {!{path: string, oldPath?: string, patchNum?: number}} file
+   * @param {!{path: string, basePath?: string, patchNum?: number}} file
    * @param {!Gerrit.PatchRange} patchRange The patch-range object containing patchNum
    *     and basePatchNum properties to represent the range.
    * @param {Object=} opt_projectConfig Optional project config object to
@@ -335,13 +368,13 @@
     const comments = this.getCommentsBySideForPath(
         file.path, patchRange, opt_projectConfig
     );
-    if (file.oldPath) {
-      const commentsForOldPath = this.getCommentsBySideForPath(
-          file.oldPath, patchRange, opt_projectConfig
+    if (file.basePath) {
+      const commentsForBasePath = this.getCommentsBySideForPath(
+          file.basePath, patchRange, opt_projectConfig
       );
       // merge in the left and right
-      comments.left = comments.left.concat(commentsForOldPath.left);
-      comments.right = comments.right.concat(commentsForOldPath.right);
+      comments.left = comments.left.concat(commentsForBasePath.left);
+      comments.right = comments.right.concat(commentsForBasePath.right);
     }
     return comments;
   }
@@ -358,7 +391,7 @@
     for (const file of Object.keys(comments)) {
       const commentsForFile = [];
       for (const comment of comments[file]) {
-        commentsForFile.push(Object.assign({__path: file}, comment));
+        commentsForFile.push({__path: file, ...comment});
       }
       commentArr = commentArr.concat(commentsForFile);
     }
@@ -376,7 +409,7 @@
   /**
    * Computes a string counting the number of commens in a given file.
    *
-   * @param {{path: string, oldPath?: string, patchNum?: number}} file
+   * @param {{path: string, basePath?: string, patchNum?: number}} file
    * @return {number}
    */
   computeCommentCount(file) {
@@ -391,7 +424,7 @@
    * Computes a string counting the number of draft comments in the entire
    * change, optionally filtered by path and/or patchNum.
    *
-   * @param {?{path: string, oldPath?: string, patchNum?: number}} file
+   * @param {?{path: string, basePath?: string, patchNum?: number}} file
    * @return {number}
    */
   computeDraftCount(file) {
@@ -405,7 +438,7 @@
   /**
    * Computes a number of unresolved comment threads in a given file and path.
    *
-   * @param {{path: string, oldPath?: string, patchNum?: number}} file
+   * @param {{path: string, basePath?: string, patchNum?: number}} file
    * @return {number}
    */
   computeUnresolvedNum(file) {
@@ -443,7 +476,7 @@
         .sort(
             (c1, c2) => {
               const dateDiff =
-                  util.parseDate(c1.updated) - util.parseDate(c2.updated);
+                  parseDate(c1.updated) - parseDate(c2.updated);
               if (dateDiff) {
                 return dateDiff;
               }
@@ -503,23 +536,20 @@
   // appears on a specific parent then only show the comment if the parent
   // index of the comment matches that of the range.
     if (comment.parent && comment.side === PARENT) {
-      return this._isMergeParent(range.basePatchNum) &&
-        comment.parent === this._getParentIndex(range.basePatchNum);
+      return isMergeParent(range.basePatchNum) &&
+        comment.parent === getParentIndex(range.basePatchNum);
     }
 
     // If the base of the range is the parent of the patch:
     if (range.basePatchNum === PARENT &&
       comment.side === PARENT &&
-      this._patchNumEquals(comment.patch_set, range.patchNum)) {
+      patchNumEquals(comment.patch_set, range.patchNum)) {
       return true;
     }
     // If the base of the range is not the parent of the patch:
-    if (range.basePatchNum !== PARENT &&
-      comment.side !== PARENT &&
-      this._patchNumEquals(comment.patch_set, range.basePatchNum)) {
-      return true;
-    }
-    return false;
+    return range.basePatchNum !== PARENT &&
+        comment.side !== PARENT &&
+        patchNumEquals(comment.patch_set, range.basePatchNum);
   }
 
   /**
@@ -533,7 +563,7 @@
   _isInRevisionOfPatchRange(comment,
       range) {
     return comment.side !== PARENT &&
-      this._patchNumEquals(comment.patch_set, range.patchNum);
+      patchNumEquals(comment.patch_set, range.patchNum);
   }
 
   /**
@@ -549,14 +579,14 @@
   }
 }
 
+export const _testOnly_findCommentById = new ChangeComments().findCommentById;
+
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrCommentApi extends mixinBehaviors( [
-  PatchSetBehavior,
-], GestureEventListeners(
+class GrCommentApi extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-comment-api'; }
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.js
deleted file mode 100644
index 8aa0835..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
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
new file mode 100644
index 0000000..91d8b41
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_html.ts
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
deleted file mode 100644
index 29262e3..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
+++ /dev/null
@@ -1,755 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-comment-api</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-comment-api></gr-comment-api>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-comment-api.js';
-suite('gr-comment-api tests', () => {
-  const PARENT = 'PARENT';
-
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('loads logged-out', () => {
-    const changeNum = 1234;
-
-    sandbox.stub(element.$.restAPI, 'getLoggedIn')
-        .returns(Promise.resolve(false));
-    sandbox.stub(element.$.restAPI, 'getDiffComments')
-        .returns(Promise.resolve({
-          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-        }));
-    sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
-        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-    sandbox.stub(element.$.restAPI, 'getDiffDrafts')
-        .returns(Promise.resolve({}));
-
-    return element.loadAll(changeNum).then(() => {
-      assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
-          changeNum));
-      assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
-          changeNum));
-      assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
-          changeNum));
-      assert.isOk(element._changeComments._comments);
-      assert.isOk(element._changeComments._robotComments);
-      assert.deepEqual(element._changeComments._drafts, {});
-    });
-  });
-
-  test('loads logged-in', () => {
-    const changeNum = 1234;
-
-    sandbox.stub(element.$.restAPI, 'getLoggedIn')
-        .returns(Promise.resolve(true));
-    sandbox.stub(element.$.restAPI, 'getDiffComments')
-        .returns(Promise.resolve({
-          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-        }));
-    sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
-        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-    sandbox.stub(element.$.restAPI, 'getDiffDrafts')
-        .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
-
-    return element.loadAll(changeNum).then(() => {
-      assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
-          changeNum));
-      assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
-          changeNum));
-      assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
-          changeNum));
-      assert.isOk(element._changeComments._comments);
-      assert.isOk(element._changeComments._robotComments);
-      assert.notDeepEqual(element._changeComments._drafts, {});
-    });
-  });
-
-  suite('reloadDrafts', () => {
-    let commentStub;
-    let robotCommentStub;
-    let draftStub;
-    setup(() => {
-      commentStub = sandbox.stub(element.$.restAPI, 'getDiffComments')
-          .returns(Promise.resolve({}));
-      robotCommentStub = sandbox.stub(element.$.restAPI,
-          'getDiffRobotComments').returns(Promise.resolve({}));
-      draftStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts')
-          .returns(Promise.resolve({}));
-    });
-
-    test('without loadAll first', done => {
-      assert.isNotOk(element._changeComments);
-      sandbox.spy(element, 'loadAll');
-      element.reloadDrafts().then(() => {
-        assert.isTrue(element.loadAll.called);
-        assert.isOk(element._changeComments);
-        assert.equal(commentStub.callCount, 1);
-        assert.equal(robotCommentStub.callCount, 1);
-        assert.equal(draftStub.callCount, 1);
-        done();
-      });
-    });
-
-    test('with loadAll first', done => {
-      assert.isNotOk(element._changeComments);
-      element.loadAll()
-          .then(() => {
-            assert.isOk(element._changeComments);
-            assert.equal(commentStub.callCount, 1);
-            assert.equal(robotCommentStub.callCount, 1);
-            assert.equal(draftStub.callCount, 1);
-            return element.reloadDrafts();
-          })
-          .then(() => {
-            assert.isOk(element._changeComments);
-            assert.equal(commentStub.callCount, 1);
-            assert.equal(robotCommentStub.callCount, 1);
-            assert.equal(draftStub.callCount, 2);
-            done();
-          });
-    });
-  });
-
-  suite('_changeComment methods', () => {
-    setup(done => {
-      const changeNum = 1234;
-      stub('gr-rest-api-interface', {
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-      });
-      element.loadAll(changeNum).then(() => {
-        done();
-      });
-    });
-
-    test('_isInBaseOfPatchRange', () => {
-      const comment = {patch_set: 1};
-      const patchRange = {basePatchNum: 1, patchNum: 2};
-      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
-          patchRange));
-
-      patchRange.basePatchNum = PARENT;
-      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
-          patchRange));
-
-      comment.side = PARENT;
-      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
-          patchRange));
-
-      comment.patch_set = 2;
-      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
-          patchRange));
-
-      patchRange.basePatchNum = -2;
-      comment.side = PARENT;
-      comment.parent = 1;
-      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
-          patchRange));
-
-      comment.parent = 2;
-      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
-          patchRange));
-    });
-
-    test('_isInRevisionOfPatchRange', () => {
-      const comment = {patch_set: 123};
-      const patchRange = {basePatchNum: 122, patchNum: 124};
-      assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
-          comment, patchRange));
-
-      patchRange.patchNum = 123;
-      assert.isTrue(element._changeComments._isInRevisionOfPatchRange(
-          comment, patchRange));
-
-      comment.side = PARENT;
-      assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
-          comment, patchRange));
-    });
-
-    test('_isInPatchRange', () => {
-      const patchRange1 = {basePatchNum: 122, patchNum: 124};
-      const patchRange2 = {basePatchNum: 123, patchNum: 125};
-      const patchRange3 = {basePatchNum: 124, patchNum: 125};
-
-      const isInBasePatchStub = sandbox.stub(element._changeComments,
-          '_isInBaseOfPatchRange');
-      const isInRevisionPatchStub = sandbox.stub(element._changeComments,
-          '_isInRevisionOfPatchRange');
-
-      isInBasePatchStub.withArgs({}, patchRange1).returns(true);
-      isInBasePatchStub.withArgs({}, patchRange2).returns(false);
-      isInBasePatchStub.withArgs({}, patchRange3).returns(false);
-
-      isInRevisionPatchStub.withArgs({}, patchRange1).returns(false);
-      isInRevisionPatchStub.withArgs({}, patchRange2).returns(true);
-      isInRevisionPatchStub.withArgs({}, patchRange3).returns(false);
-
-      assert.isTrue(element._changeComments._isInPatchRange({}, patchRange1));
-      assert.isTrue(element._changeComments._isInPatchRange({}, patchRange2));
-      assert.isFalse(element._changeComments._isInPatchRange({},
-          patchRange3));
-    });
-
-    suite('comment ranges and paths', () => {
-      function makeTime(mins) {
-        return `2013-02-26 15:0${mins}:43.986000000`;
-      }
-
-      setup(() => {
-        element._changeComments._drafts = {
-          'file/one': [
-            {
-              id: 11,
-              patch_set: 2,
-              side: PARENT,
-              line: 1,
-              updated: makeTime(3),
-            },
-            {
-              id: 12,
-              in_reply_to: 2,
-              patch_set: 2,
-              line: 1,
-              updated: makeTime(3),
-            },
-          ],
-          'file/two': [
-            {
-              id: 5,
-              patch_set: 3,
-              line: 1,
-              updated: makeTime(3),
-            },
-          ],
-        };
-        element._changeComments._robotComments = {
-          'file/one': [
-            {
-              id: 1,
-              patch_set: 2,
-              side: PARENT,
-              line: 1,
-              updated: makeTime(1),
-              range: {
-                start_line: 1,
-                start_character: 2,
-                end_line: 2,
-                end_character: 2,
-              },
-            }, {
-              id: 2,
-              in_reply_to: 4,
-              patch_set: 2,
-              unresolved: true,
-              line: 1,
-              updated: makeTime(2),
-            },
-          ],
-        };
-        element._changeComments._comments = {
-          'file/one': [
-            {id: 3, patch_set: 2, side: PARENT, line: 2, updated: makeTime(1)},
-            {id: 4, patch_set: 2, line: 1, updated: makeTime(1)},
-          ],
-          'file/two': [
-            {id: 5, patch_set: 2, line: 2, updated: makeTime(1)},
-            {id: 6, patch_set: 3, line: 2, updated: makeTime(1)},
-          ],
-          'file/three': [
-            {
-              id: 7,
-              patch_set: 2,
-              side: PARENT,
-              unresolved: true,
-              line: 1,
-              updated: makeTime(1),
-            },
-            {id: 8, patch_set: 3, line: 1, updated: makeTime(1)},
-          ],
-          'file/four': [
-            {id: 9, patch_set: 5, side: PARENT, line: 1, updated: makeTime(1)},
-            {id: 10, patch_set: 5, line: 1, updated: makeTime(1)},
-          ],
-        };
-      });
-
-      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('getCommentsBySideForPath', () => {
-        const patchRange = {basePatchNum: 1, patchNum: 3};
-        let path = 'file/one';
-        let comments = element._changeComments.getCommentsBySideForPath(path,
-            patchRange);
-        assert.equal(comments.meta.changeNum, 1234);
-        assert.equal(comments.left.length, 0);
-        assert.equal(comments.right.length, 0);
-
-        path = 'file/two';
-        comments = element._changeComments.getCommentsBySideForPath(path,
-            patchRange);
-        assert.equal(comments.left.length, 0);
-        assert.equal(comments.right.length, 2);
-
-        patchRange.basePatchNum = 2;
-        comments = element._changeComments.getCommentsBySideForPath(path,
-            patchRange);
-        assert.equal(comments.left.length, 1);
-        assert.equal(comments.right.length, 2);
-
-        patchRange.basePatchNum = PARENT;
-        path = 'file/three';
-        comments = element._changeComments.getCommentsBySideForPath(path,
-            patchRange);
-        assert.equal(comments.left.length, 0);
-        assert.equal(comments.right.length, 1);
-      });
-
-      test('getAllCommentsForPath', () => {
-        let path = 'file/one';
-        let comments = element._changeComments.getAllCommentsForPath(path);
-        assert.deepEqual(comments.length, 4);
-        path = 'file/two';
-        comments = element._changeComments.getAllCommentsForPath(path, 2);
-        assert.deepEqual(comments.length, 1);
-      });
-
-      test('getAllDraftsForPath', () => {
-        const path = 'file/one';
-        const drafts = element._changeComments.getAllDraftsForPath(path);
-        assert.deepEqual(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', () => {
-        element._changeComments._drafts = {};
-        element._changeComments._robotComments = {};
-        element._changeComments._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,
-          }],
-        };
-        assert.equal(
-            element._changeComments.computeUnresolvedNum(1, 'path'), 0);
-      });
-
-      test('computeCommentCount', () => {
-        assert.equal(element._changeComments
-            .computeCommentCount({
-              patchNum: 2,
-              path: 'file/one',
-            }), 4);
-        assert.equal(element._changeComments
-            .computeCommentCount({
-              patchNum: 1,
-              path: 'file/one',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeCommentCount({
-              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 = [
-          {
-            comments: [
-              {
-                id: 1,
-                patch_set: 2,
-                side: 'PARENT',
-                line: 1,
-                updated: '2013-02-26 15:01:43.986000000',
-                range: {
-                  start_line: 1,
-                  start_character: 2,
-                  end_line: 2,
-                  end_character: 2,
-                },
-                __path: 'file/one',
-              },
-            ],
-            commentSide: 'PARENT',
-            patchNum: 2,
-            path: 'file/one',
-            line: 1,
-            rootId: 1,
-          }, {
-            comments: [
-              {
-                id: 3,
-                patch_set: 2,
-                side: 'PARENT',
-                line: 2,
-                __path: 'file/one',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            commentSide: 'PARENT',
-            patchNum: 2,
-            path: 'file/one',
-            line: 2,
-            rootId: 3,
-          }, {
-            comments: [
-              {
-                id: 4,
-                patch_set: 2,
-                line: 1,
-                __path: 'file/one',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-              {
-                id: 2,
-                in_reply_to: 4,
-                patch_set: 2,
-                unresolved: true,
-                line: 1,
-                __path: 'file/one',
-                updated: '2013-02-26 15:02:43.986000000',
-              },
-              {
-                id: 12,
-                in_reply_to: 2,
-                patch_set: 2,
-                line: 1,
-                __path: 'file/one',
-                __draft: true,
-                updated: '2013-02-26 15:03:43.986000000',
-              },
-            ],
-            patchNum: 2,
-            path: 'file/one',
-            line: 1,
-            rootId: 4,
-          }, {
-            comments: [
-              {
-                id: 5,
-                patch_set: 2,
-                line: 2,
-                __path: 'file/two',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            patchNum: 2,
-            path: 'file/two',
-            line: 2,
-            rootId: 5,
-          }, {
-            comments: [
-              {
-                id: 6,
-                patch_set: 3,
-                line: 2,
-                __path: 'file/two',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            patchNum: 3,
-            path: 'file/two',
-            line: 2,
-            rootId: 6,
-          }, {
-            comments: [
-              {
-                id: 7,
-                patch_set: 2,
-                side: 'PARENT',
-                unresolved: true,
-                line: 1,
-                __path: 'file/three',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            commentSide: 'PARENT',
-            patchNum: 2,
-            path: 'file/three',
-            line: 1,
-            rootId: 7,
-          }, {
-            comments: [
-              {
-                id: 8,
-                patch_set: 3,
-                line: 1,
-                __path: 'file/three',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            patchNum: 3,
-            path: 'file/three',
-            line: 1,
-            rootId: 8,
-          }, {
-            comments: [
-              {
-                id: 9,
-                patch_set: 5,
-                side: 'PARENT',
-                line: 1,
-                __path: 'file/four',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            commentSide: 'PARENT',
-            patchNum: 5,
-            path: 'file/four',
-            line: 1,
-            rootId: 9,
-          }, {
-            comments: [
-              {
-                id: 10,
-                patch_set: 5,
-                line: 1,
-                __path: 'file/four',
-                updated: '2013-02-26 15:01:43.986000000',
-              },
-            ],
-            rootId: 10,
-            patchNum: 5,
-            path: 'file/four',
-            line: 1,
-          }, {
-            comments: [
-              {
-                id: 5,
-                patch_set: 3,
-                line: 1,
-                __path: 'file/two',
-                __draft: true,
-                updated: '2013-02-26 15:03:43.986000000',
-              },
-            ],
-            rootId: 5,
-            patchNum: 3,
-            path: 'file/two',
-            line: 1,
-          }, {
-            comments: [
-              {
-                id: 11,
-                patch_set: 2,
-                side: 'PARENT',
-                line: 1,
-                __path: 'file/one',
-                __draft: true,
-                updated: '2013-02-26 15:03:43.986000000',
-              },
-            ],
-            rootId: 11,
-            commentSide: 'PARENT',
-            patchNum: 2,
-            path: 'file/one',
-            line: 1,
-          },
-        ];
-        const threads = element._changeComments.getAllThreadsForChange();
-        assert.deepEqual(threads, expectedThreads);
-      });
-
-      test('getCommentsForThreadGroup', () => {
-        let expectedComments = [
-          {
-            __path: 'file/one',
-            id: 4,
-            patch_set: 2,
-            line: 1,
-            updated: '2013-02-26 15:01:43.986000000',
-          },
-          {
-            __path: 'file/one',
-            id: 2,
-            in_reply_to: 4,
-            patch_set: 2,
-            unresolved: true,
-            line: 1,
-            updated: '2013-02-26 15:02:43.986000000',
-          },
-          {
-            __path: 'file/one',
-            __draft: true,
-            id: 12,
-            in_reply_to: 2,
-            patch_set: 2,
-            line: 1,
-            updated: '2013-02-26 15:03:43.986000000',
-          },
-        ];
-        assert.deepEqual(element._changeComments.getCommentsForThread(4),
-            expectedComments);
-
-        expectedComments = [{
-          id: 11,
-          patch_set: 2,
-          side: 'PARENT',
-          line: 1,
-          __path: 'file/one',
-          __draft: true,
-          updated: '2013-02-26 15:03:43.986000000',
-        }];
-
-        assert.deepEqual(element._changeComments.getCommentsForThread(11),
-            expectedComments);
-
-        assert.deepEqual(element._changeComments.getCommentsForThread(1000),
-            null);
-      });
-    });
-  });
-});
-</script>
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
new file mode 100644
index 0000000..0a7c3b5
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -0,0 +1,745 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-comment-api.js';
+
+const basicFixture = fixtureFromElement('gr-comment-api');
+
+suite('gr-comment-api tests', () => {
+  const PARENT = 'PARENT';
+
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('loads logged-out', () => {
+    const changeNum = 1234;
+
+    sinon.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(false));
+    sinon.stub(element.$.restAPI, 'getDiffComments')
+        .returns(Promise.resolve({
+          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
+        }));
+    sinon.stub(element.$.restAPI, 'getDiffRobotComments')
+        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+    sinon.stub(element.$.restAPI, 'getDiffDrafts')
+        .returns(Promise.resolve({}));
+
+    return element.loadAll(changeNum).then(() => {
+      assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
+          changeNum));
+      assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
+          changeNum));
+      assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
+          changeNum));
+      assert.isOk(element._changeComments._comments);
+      assert.isOk(element._changeComments._robotComments);
+      assert.deepEqual(element._changeComments._drafts, {});
+    });
+  });
+
+  test('loads logged-in', () => {
+    const changeNum = 1234;
+
+    sinon.stub(element.$.restAPI, 'getLoggedIn')
+        .returns(Promise.resolve(true));
+    sinon.stub(element.$.restAPI, 'getDiffComments')
+        .returns(Promise.resolve({
+          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
+        }));
+    sinon.stub(element.$.restAPI, 'getDiffRobotComments')
+        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+    sinon.stub(element.$.restAPI, 'getDiffDrafts')
+        .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
+
+    return element.loadAll(changeNum).then(() => {
+      assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
+          changeNum));
+      assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
+          changeNum));
+      assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
+          changeNum));
+      assert.isOk(element._changeComments._comments);
+      assert.isOk(element._changeComments._robotComments);
+      assert.notDeepEqual(element._changeComments._drafts, {});
+    });
+  });
+
+  suite('reloadDrafts', () => {
+    let commentStub;
+    let robotCommentStub;
+    let draftStub;
+    setup(() => {
+      commentStub = sinon.stub(element.$.restAPI, 'getDiffComments')
+          .returns(Promise.resolve({}));
+      robotCommentStub = sinon.stub(element.$.restAPI,
+          'getDiffRobotComments').returns(Promise.resolve({}));
+      draftStub = sinon.stub(element.$.restAPI, 'getDiffDrafts')
+          .returns(Promise.resolve({}));
+    });
+
+    test('without loadAll first', done => {
+      assert.isNotOk(element._changeComments);
+      sinon.spy(element, 'loadAll');
+      element.reloadDrafts().then(() => {
+        assert.isTrue(element.loadAll.called);
+        assert.isOk(element._changeComments);
+        assert.equal(commentStub.callCount, 1);
+        assert.equal(robotCommentStub.callCount, 1);
+        assert.equal(draftStub.callCount, 1);
+        done();
+      });
+    });
+
+    test('with loadAll first', done => {
+      assert.isNotOk(element._changeComments);
+      element.loadAll()
+          .then(() => {
+            assert.isOk(element._changeComments);
+            assert.equal(commentStub.callCount, 1);
+            assert.equal(robotCommentStub.callCount, 1);
+            assert.equal(draftStub.callCount, 1);
+            return element.reloadDrafts();
+          })
+          .then(() => {
+            assert.isOk(element._changeComments);
+            assert.equal(commentStub.callCount, 1);
+            assert.equal(robotCommentStub.callCount, 1);
+            assert.equal(draftStub.callCount, 2);
+            done();
+          });
+    });
+  });
+
+  suite('_changeComment methods', () => {
+    setup(done => {
+      const changeNum = 1234;
+      stub('gr-rest-api-interface', {
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+      });
+      element.loadAll(changeNum).then(() => {
+        done();
+      });
+    });
+
+    test('_isInBaseOfPatchRange', () => {
+      const comment = {patch_set: 1};
+      const patchRange = {basePatchNum: 1, patchNum: 2};
+      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      patchRange.basePatchNum = PARENT;
+      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      comment.side = PARENT;
+      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      comment.patch_set = 2;
+      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      patchRange.basePatchNum = -2;
+      comment.side = PARENT;
+      comment.parent = 1;
+      assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+
+      comment.parent = 2;
+      assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+          patchRange));
+    });
+
+    test('_isInRevisionOfPatchRange', () => {
+      const comment = {patch_set: 123};
+      const patchRange = {basePatchNum: 122, patchNum: 124};
+      assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
+          comment, patchRange));
+
+      patchRange.patchNum = 123;
+      assert.isTrue(element._changeComments._isInRevisionOfPatchRange(
+          comment, patchRange));
+
+      comment.side = PARENT;
+      assert.isFalse(element._changeComments._isInRevisionOfPatchRange(
+          comment, patchRange));
+    });
+
+    test('_isInPatchRange', () => {
+      const patchRange1 = {basePatchNum: 122, patchNum: 124};
+      const patchRange2 = {basePatchNum: 123, patchNum: 125};
+      const patchRange3 = {basePatchNum: 124, patchNum: 125};
+
+      const isInBasePatchStub = sinon.stub(element._changeComments,
+          '_isInBaseOfPatchRange');
+      const isInRevisionPatchStub = sinon.stub(element._changeComments,
+          '_isInRevisionOfPatchRange');
+
+      isInBasePatchStub.withArgs({}, patchRange1).returns(true);
+      isInBasePatchStub.withArgs({}, patchRange2).returns(false);
+      isInBasePatchStub.withArgs({}, patchRange3).returns(false);
+
+      isInRevisionPatchStub.withArgs({}, patchRange1).returns(false);
+      isInRevisionPatchStub.withArgs({}, patchRange2).returns(true);
+      isInRevisionPatchStub.withArgs({}, patchRange3).returns(false);
+
+      assert.isTrue(element._changeComments._isInPatchRange({}, patchRange1));
+      assert.isTrue(element._changeComments._isInPatchRange({}, patchRange2));
+      assert.isFalse(element._changeComments._isInPatchRange({},
+          patchRange3));
+    });
+
+    suite('comment ranges and paths', () => {
+      function makeTime(mins) {
+        return `2013-02-26 15:0${mins}:43.986000000`;
+      }
+
+      setup(() => {
+        element._changeComments._drafts = {
+          'file/one': [
+            {
+              id: 11,
+              patch_set: 2,
+              side: PARENT,
+              line: 1,
+              updated: makeTime(3),
+            },
+            {
+              id: 12,
+              in_reply_to: 2,
+              patch_set: 2,
+              line: 1,
+              updated: makeTime(3),
+            },
+          ],
+          'file/two': [
+            {
+              id: 5,
+              patch_set: 3,
+              line: 1,
+              updated: makeTime(3),
+            },
+          ],
+        };
+        element._changeComments._robotComments = {
+          'file/one': [
+            {
+              id: 1,
+              patch_set: 2,
+              side: PARENT,
+              line: 1,
+              updated: makeTime(1),
+              range: {
+                start_line: 1,
+                start_character: 2,
+                end_line: 2,
+                end_character: 2,
+              },
+            }, {
+              id: 2,
+              in_reply_to: 4,
+              patch_set: 2,
+              unresolved: true,
+              line: 1,
+              updated: makeTime(2),
+            },
+          ],
+        };
+        element._changeComments._comments = {
+          'file/one': [
+            {id: 3, patch_set: 2, side: PARENT, line: 2, updated: makeTime(1)},
+            {id: 4, patch_set: 2, line: 1, updated: makeTime(1)},
+          ],
+          'file/two': [
+            {id: 5, patch_set: 2, line: 2, updated: makeTime(1)},
+            {id: 6, patch_set: 3, line: 2, updated: makeTime(1)},
+          ],
+          'file/three': [
+            {
+              id: 7,
+              patch_set: 2,
+              side: PARENT,
+              unresolved: true,
+              line: 1,
+              updated: makeTime(1),
+            },
+            {id: 8, patch_set: 3, line: 1, updated: makeTime(1)},
+          ],
+          'file/four': [
+            {id: 9, patch_set: 5, side: PARENT, line: 1, updated: makeTime(1)},
+            {id: 10, patch_set: 5, line: 1, updated: makeTime(1)},
+          ],
+        };
+      });
+
+      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('getCommentsBySideForPath', () => {
+        const patchRange = {basePatchNum: 1, patchNum: 3};
+        let path = 'file/one';
+        let comments = element._changeComments.getCommentsBySideForPath(path,
+            patchRange);
+        assert.equal(comments.meta.changeNum, 1234);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 0);
+
+        path = 'file/two';
+        comments = element._changeComments.getCommentsBySideForPath(path,
+            patchRange);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 2);
+
+        patchRange.basePatchNum = 2;
+        comments = element._changeComments.getCommentsBySideForPath(path,
+            patchRange);
+        assert.equal(comments.left.length, 1);
+        assert.equal(comments.right.length, 2);
+
+        patchRange.basePatchNum = PARENT;
+        path = 'file/three';
+        comments = element._changeComments.getCommentsBySideForPath(path,
+            patchRange);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.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);
+        const aCopyOfDrafts = element._changeComments
+            .getAllDraftsForPath(path);
+        assert.deepEqual(drafts, aCopyOfDrafts);
+        assert.notEqual(drafts[0], aCopyOfDrafts[0]);
+      });
+
+      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', () => {
+        element._changeComments._drafts = {};
+        element._changeComments._robotComments = {};
+        element._changeComments._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,
+          }],
+        };
+        assert.equal(
+            element._changeComments.computeUnresolvedNum(1, 'path'), 0);
+      });
+
+      test('computeCommentCount', () => {
+        assert.equal(element._changeComments
+            .computeCommentCount({
+              patchNum: 2,
+              path: 'file/one',
+            }), 4);
+        assert.equal(element._changeComments
+            .computeCommentCount({
+              patchNum: 1,
+              path: 'file/one',
+            }), 0);
+        assert.equal(element._changeComments
+            .computeCommentCount({
+              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 = [
+          {
+            comments: [
+              {
+                id: 1,
+                patch_set: 2,
+                side: 'PARENT',
+                line: 1,
+                updated: '2013-02-26 15:01:43.986000000',
+                range: {
+                  start_line: 1,
+                  start_character: 2,
+                  end_line: 2,
+                  end_character: 2,
+                },
+                __path: 'file/one',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/one',
+            line: 1,
+            rootId: 1,
+          }, {
+            comments: [
+              {
+                id: 3,
+                patch_set: 2,
+                side: 'PARENT',
+                line: 2,
+                __path: 'file/one',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/one',
+            line: 2,
+            rootId: 3,
+          }, {
+            comments: [
+              {
+                id: 4,
+                patch_set: 2,
+                line: 1,
+                __path: 'file/one',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+              {
+                id: 2,
+                in_reply_to: 4,
+                patch_set: 2,
+                unresolved: true,
+                line: 1,
+                __path: 'file/one',
+                updated: '2013-02-26 15:02:43.986000000',
+              },
+              {
+                id: 12,
+                in_reply_to: 2,
+                patch_set: 2,
+                line: 1,
+                __path: 'file/one',
+                __draft: true,
+                updated: '2013-02-26 15:03:43.986000000',
+              },
+            ],
+            patchNum: 2,
+            path: 'file/one',
+            line: 1,
+            rootId: 4,
+          }, {
+            comments: [
+              {
+                id: 5,
+                patch_set: 2,
+                line: 2,
+                __path: 'file/two',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            patchNum: 2,
+            path: 'file/two',
+            line: 2,
+            rootId: 5,
+          }, {
+            comments: [
+              {
+                id: 6,
+                patch_set: 3,
+                line: 2,
+                __path: 'file/two',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            patchNum: 3,
+            path: 'file/two',
+            line: 2,
+            rootId: 6,
+          }, {
+            comments: [
+              {
+                id: 7,
+                patch_set: 2,
+                side: 'PARENT',
+                unresolved: true,
+                line: 1,
+                __path: 'file/three',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/three',
+            line: 1,
+            rootId: 7,
+          }, {
+            comments: [
+              {
+                id: 8,
+                patch_set: 3,
+                line: 1,
+                __path: 'file/three',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            patchNum: 3,
+            path: 'file/three',
+            line: 1,
+            rootId: 8,
+          }, {
+            comments: [
+              {
+                id: 9,
+                patch_set: 5,
+                side: 'PARENT',
+                line: 1,
+                __path: 'file/four',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            commentSide: 'PARENT',
+            patchNum: 5,
+            path: 'file/four',
+            line: 1,
+            rootId: 9,
+          }, {
+            comments: [
+              {
+                id: 10,
+                patch_set: 5,
+                line: 1,
+                __path: 'file/four',
+                updated: '2013-02-26 15:01:43.986000000',
+              },
+            ],
+            rootId: 10,
+            patchNum: 5,
+            path: 'file/four',
+            line: 1,
+          }, {
+            comments: [
+              {
+                id: 5,
+                patch_set: 3,
+                line: 1,
+                __path: 'file/two',
+                __draft: true,
+                updated: '2013-02-26 15:03:43.986000000',
+              },
+            ],
+            rootId: 5,
+            patchNum: 3,
+            path: 'file/two',
+            line: 1,
+          }, {
+            comments: [
+              {
+                id: 11,
+                patch_set: 2,
+                side: 'PARENT',
+                line: 1,
+                __path: 'file/one',
+                __draft: true,
+                updated: '2013-02-26 15:03:43.986000000',
+              },
+            ],
+            rootId: 11,
+            commentSide: 'PARENT',
+            patchNum: 2,
+            path: 'file/one',
+            line: 1,
+          },
+        ];
+        const threads = element._changeComments.getAllThreadsForChange();
+        assert.deepEqual(threads, expectedThreads);
+      });
+
+      test('getCommentsForThreadGroup', () => {
+        let expectedComments = [
+          {
+            __path: 'file/one',
+            id: 4,
+            patch_set: 2,
+            line: 1,
+            updated: '2013-02-26 15:01:43.986000000',
+          },
+          {
+            __path: 'file/one',
+            id: 2,
+            in_reply_to: 4,
+            patch_set: 2,
+            unresolved: true,
+            line: 1,
+            updated: '2013-02-26 15:02:43.986000000',
+          },
+          {
+            __path: 'file/one',
+            __draft: true,
+            id: 12,
+            in_reply_to: 2,
+            patch_set: 2,
+            line: 1,
+            updated: '2013-02-26 15:03:43.986000000',
+          },
+        ];
+        assert.deepEqual(element._changeComments.getCommentsForThread(4),
+            expectedComments);
+
+        expectedComments = [{
+          id: 11,
+          patch_set: 2,
+          side: 'PARENT',
+          line: 1,
+          __path: 'file/one',
+          __draft: true,
+          updated: '2013-02-26 15:03:43.986000000',
+        }];
+
+        assert.deepEqual(element._changeComments.getCommentsForThread(11),
+            expectedComments);
+
+        assert.deepEqual(element._changeComments.getCommentsForThread(1000),
+            null);
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
index cdd6d8f..86537e8 100644
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
@@ -29,7 +27,7 @@
   [CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
 ]);
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrCoverageLayer extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.js
deleted file mode 100644
index 3ed33d1..0000000
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.ts b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.ts
new file mode 100644
index 0000000..1489006
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.ts
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
deleted file mode 100644
index b80c56f3..0000000
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.html
+++ /dev/null
@@ -1,138 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-coverage-layer</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-coverage-layer></gr-coverage-layer>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../gr-diff/gr-diff-line.js';
-import '../../../test/common-test-setup.js';
-import './gr-coverage-layer.js';
-suite('gr-coverage-layer', () => {
-  let element;
-
-  setup(() => {
-    const initialCoverageRanges = [
-      {
-        type: 'COVERED',
-        side: 'right',
-        code_range: {
-          start_line: 1,
-          end_line: 2,
-        },
-      },
-      {
-        type: 'NOT_COVERED',
-        side: 'right',
-        code_range: {
-          start_line: 3,
-          end_line: 4,
-        },
-      },
-      {
-        type: 'PARTIALLY_COVERED',
-        side: 'right',
-        code_range: {
-          start_line: 5,
-          end_line: 6,
-        },
-      },
-      {
-        type: 'NOT_INSTRUMENTED',
-        side: 'right',
-        code_range: {
-          start_line: 8,
-          end_line: 9,
-        },
-      },
-    ];
-
-    element = fixture('basic');
-    element.coverageRanges = initialCoverageRanges;
-    element.side = 'right';
-  });
-
-  suite('annotate', () => {
-    function createLine(lineNumber) {
-      const lineEl = document.createElement('div');
-      lineEl.setAttribute('data-side', 'right');
-      lineEl.setAttribute('data-value', lineNumber);
-      lineEl.className = 'right';
-      return lineEl;
-    }
-
-    function checkLine(lineNumber, className, opt_negated) {
-      const line = createLine(lineNumber);
-      element.annotate(undefined, line, undefined);
-      let contains = line.classList.contains(className);
-      if (opt_negated) contains = !contains;
-      assert.isTrue(contains);
-    }
-
-    test('line 1-2 are covered', () => {
-      checkLine(1, 'COVERED');
-      checkLine(2, 'COVERED');
-    });
-
-    test('line 3-4 are not covered', () => {
-      checkLine(3, 'NOT_COVERED');
-      checkLine(4, 'NOT_COVERED');
-    });
-
-    test('line 5-6 are partially covered', () => {
-      checkLine(5, 'PARTIALLY_COVERED');
-      checkLine(6, 'PARTIALLY_COVERED');
-    });
-
-    test('line 7 is implicitly not instrumented', () => {
-      checkLine(7, 'COVERED', true);
-      checkLine(7, 'NOT_COVERED', true);
-      checkLine(7, 'PARTIALLY_COVERED', true);
-      checkLine(7, 'NOT_INSTRUMENTED', true);
-    });
-
-    test('line 8-9 are not instrumented', () => {
-      checkLine(8, 'NOT_INSTRUMENTED');
-      checkLine(9, 'NOT_INSTRUMENTED');
-    });
-
-    test('coverage correct, if annotate is called out of order', () => {
-      checkLine(8, 'NOT_INSTRUMENTED');
-      checkLine(1, 'COVERED');
-      checkLine(5, 'PARTIALLY_COVERED');
-      checkLine(3, 'NOT_COVERED');
-      checkLine(6, 'PARTIALLY_COVERED');
-      checkLine(4, 'NOT_COVERED');
-      checkLine(9, 'NOT_INSTRUMENTED');
-      checkLine(2, 'COVERED');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.js
new file mode 100644
index 0000000..e886e61
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.js
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-diff/gr-diff-line.js';
+import './gr-coverage-layer.js';
+
+const basicFixture = fixtureFromElement('gr-coverage-layer');
+
+suite('gr-coverage-layer', () => {
+  let element;
+
+  setup(() => {
+    const initialCoverageRanges = [
+      {
+        type: 'COVERED',
+        side: 'right',
+        code_range: {
+          start_line: 1,
+          end_line: 2,
+        },
+      },
+      {
+        type: 'NOT_COVERED',
+        side: 'right',
+        code_range: {
+          start_line: 3,
+          end_line: 4,
+        },
+      },
+      {
+        type: 'PARTIALLY_COVERED',
+        side: 'right',
+        code_range: {
+          start_line: 5,
+          end_line: 6,
+        },
+      },
+      {
+        type: 'NOT_INSTRUMENTED',
+        side: 'right',
+        code_range: {
+          start_line: 8,
+          end_line: 9,
+        },
+      },
+    ];
+
+    element = basicFixture.instantiate();
+    element.coverageRanges = initialCoverageRanges;
+    element.side = 'right';
+  });
+
+  suite('annotate', () => {
+    function createLine(lineNumber) {
+      const lineEl = document.createElement('div');
+      lineEl.setAttribute('data-side', 'right');
+      lineEl.setAttribute('data-value', lineNumber);
+      lineEl.className = 'right';
+      return lineEl;
+    }
+
+    function checkLine(lineNumber, className, opt_negated) {
+      const line = createLine(lineNumber);
+      element.annotate(undefined, line, undefined);
+      let contains = line.classList.contains(className);
+      if (opt_negated) contains = !contains;
+      assert.isTrue(contains);
+    }
+
+    test('line 1-2 are covered', () => {
+      checkLine(1, 'COVERED');
+      checkLine(2, 'COVERED');
+    });
+
+    test('line 3-4 are not covered', () => {
+      checkLine(3, 'NOT_COVERED');
+      checkLine(4, 'NOT_COVERED');
+    });
+
+    test('line 5-6 are partially covered', () => {
+      checkLine(5, 'PARTIALLY_COVERED');
+      checkLine(6, 'PARTIALLY_COVERED');
+    });
+
+    test('line 7 is implicitly not instrumented', () => {
+      checkLine(7, 'COVERED', true);
+      checkLine(7, 'NOT_COVERED', true);
+      checkLine(7, 'PARTIALLY_COVERED', true);
+      checkLine(7, 'NOT_INSTRUMENTED', true);
+    });
+
+    test('line 8-9 are not instrumented', () => {
+      checkLine(8, 'NOT_INSTRUMENTED');
+      checkLine(9, 'NOT_INSTRUMENTED');
+    });
+
+    test('coverage correct, if annotate is called out of order', () => {
+      checkLine(8, 'NOT_INSTRUMENTED');
+      checkLine(1, 'COVERED');
+      checkLine(5, 'PARTIALLY_COVERED');
+      checkLine(3, 'NOT_COVERED');
+      checkLine(6, 'PARTIALLY_COVERED');
+      checkLine(4, 'NOT_COVERED');
+      checkLine(9, 'NOT_INSTRUMENTED');
+      checkLine(2, 'COVERED');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
index a65fdca..9a56797 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
@@ -15,14 +15,14 @@
  * limitations under the License.
  */
 
-import {GrDiffBuilder} from './gr-diff-builder.js';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
 
 /** @constructor */
 export function GrDiffBuilderBinary(diff, prefs, outputEl) {
-  GrDiffBuilder.call(this, diff, prefs, outputEl);
+  GrDiffBuilderUnified.call(this, diff, prefs, outputEl, []);
 }
 
-GrDiffBuilderBinary.prototype = Object.create(GrDiffBuilder.prototype);
+GrDiffBuilderBinary.prototype = Object.create(GrDiffBuilderUnified.prototype);
 GrDiffBuilderBinary.prototype.constructor = GrDiffBuilderBinary;
 
 // This method definition is a no-op to satisfy the parent type.
@@ -30,12 +30,15 @@
 
 GrDiffBuilderBinary.prototype.buildSectionElement = function() {
   const section = this._createElement('tbody', 'binary-diff');
-  const row = this._createElement('tr');
-  const cell = this._createElement('td');
-  const label = this._createElement('label');
-  label.textContent = 'Difference in binary files';
-  cell.appendChild(label);
-  row.appendChild(cell);
-  section.appendChild(row);
+  const fileRow = this._createRow(section, {
+    beforeNumber: 'FILE',
+    afterNumber: 'FILE',
+    type: 'both',
+    text: '',
+  });
+  const contentTd = fileRow.querySelector('td.both.file');
+  contentTd.textContent = ' Difference in binary files';
+
+  section.appendChild(fileRow);
   return section;
-};
+};
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
index b38543c..ef75da0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../gr-coverage-layer/gr-coverage-layer.js';
 import '../gr-diff-processor/gr-diff-processor.js';
 import '../../shared/gr-hovercard/gr-hovercard.js';
@@ -46,7 +44,7 @@
 const COMMIT_MSG_LINE_LENGTH = 72;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrDiffBuilderElement extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -115,6 +113,14 @@
     };
   }
 
+  /** @override */
+  detached() {
+    super.detached();
+    if (this._builder) {
+      this._builder.clear();
+    }
+  }
+
   get diffElement() {
     return this.queryEffectiveChildren('#diffTable');
   }
@@ -146,6 +152,9 @@
     // Stop the processor if it's running.
     this.cancel();
 
+    if (this._builder) {
+      this._builder.clear();
+    }
     this._builder = this._getDiffBuilder(this.diff, prefs);
 
     this.$.processor.context = prefs.context;
@@ -213,8 +222,8 @@
       null;
   }
 
-  getContentByLine(lineNumber, opt_side, opt_root) {
-    return this._builder.getContentByLine(lineNumber, opt_side, opt_root);
+  getContentTdByLine(lineNumber, opt_side, opt_root) {
+    return this._builder.getContentTdByLine(lineNumber, opt_side, opt_root);
   }
 
   _getDiffRowByChild(child) {
@@ -224,14 +233,14 @@
     return child;
   }
 
-  getContentByLineEl(lineEl) {
+  getContentTdByLineEl(lineEl) {
     if (!lineEl) return;
     const line = lineEl.getAttribute('data-value');
     const side = this.getSideByLineEl(lineEl);
     // Performance optimization because we already have an element in the
     // correct row
     const row = dom(this._getDiffRowByChild(lineEl));
-    return this.getContentByLine(line, side, row);
+    return this.getContentTdByLine(line, side, row);
   }
 
   getLineElByNumber(lineNumber, opt_side) {
@@ -303,7 +312,7 @@
       return;
     }
 
-    const localPrefs = Object.assign({}, prefs);
+    const localPrefs = {...prefs};
     if (this.path === COMMIT_MSG_PATH) {
       // override line_length for commit msg the same way as
       // in gr-diff
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.js
deleted file mode 100644
index 4d6b890..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-  <gr-ranged-comment-layer
-    id="rangeLayer"
-    comment-ranges="[[commentRanges]]"
-  ></gr-ranged-comment-layer>
-  <gr-coverage-layer
-    id="coverageLayerLeft"
-    coverage-ranges="[[_leftCoverageRanges]]"
-    side="left"
-  ></gr-coverage-layer>
-  <gr-coverage-layer
-    id="coverageLayerRight"
-    coverage-ranges="[[_rightCoverageRanges]]"
-    side="right"
-  ></gr-coverage-layer>
-  <gr-diff-processor id="processor" groups="{{_groups}}"></gr-diff-processor>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts
new file mode 100644
index 0000000..573f559
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <div class="contentWrapper">
+    <slot></slot>
+  </div>
+  <gr-ranged-comment-layer
+    id="rangeLayer"
+    comment-ranges="[[commentRanges]]"
+  ></gr-ranged-comment-layer>
+  <gr-coverage-layer
+    id="coverageLayerLeft"
+    coverage-ranges="[[_leftCoverageRanges]]"
+    side="left"
+  ></gr-coverage-layer>
+  <gr-coverage-layer
+    id="coverageLayerRight"
+    coverage-ranges="[[_rightCoverageRanges]]"
+    side="right"
+  ></gr-coverage-layer>
+  <gr-diff-processor id="processor" groups="{{_groups}}"></gr-diff-processor>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html
deleted file mode 100644
index e5847c4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.html
+++ /dev/null
@@ -1,1233 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-diff-builder</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template is="dom-template">
-    <gr-diff-builder>
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-  </template>
-</test-fixture>
-
-<test-fixture id="div-with-text">
-  <template>
-    <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-  </template>
-</test-fixture>
-
-<test-fixture id="mock-diff">
-  <template>
-    <gr-diff-builder view-mode="SIDE_BY_SIDE">
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../gr-diff/gr-diff-group.js';
-import './gr-diff-builder.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {getMockDiffResponse} from '../../../test/mock-diff-response.js';
-import './gr-diff-builder-element.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
-import {GrDiffBuilder} from './gr-diff-builder.js';
-
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
-
-suite('gr-diff-builder tests', () => {
-  let prefs;
-  let element;
-  let builder;
-  let sandbox;
-  const LINE_FEED_HTML = '<span class="style-scope gr-diff br"></span>';
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      getProjectConfig() { return Promise.resolve({}); },
-    });
-    sandbox.stub(BaseUrlBehavior, 'getBaseUrl').returns('/r');
-    prefs = {
-      line_length: 10,
-      show_tabs: true,
-      tab_size: 4,
-    };
-    builder = new GrDiffBuilder({content: []}, prefs);
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('_createElement classStr applies all classes', () => {
-    const node = builder._createElement('div', 'test classes');
-    assert.isTrue(node.classList.contains('gr-diff'));
-    assert.isTrue(node.classList.contains('test'));
-    assert.isTrue(node.classList.contains('classes'));
-  });
-
-  test('context control buttons', () => {
-    // Create 10 lines.
-    const lines = [];
-    for (let i = 0; i < 10; i++) {
-      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-      line.beforeNumber = i + 1;
-      line.afterNumber = i + 1;
-      line.text = 'lorem upsum';
-      lines.push(line);
-    }
-
-    const contextLine = {
-      contextGroups: [new GrDiffGroup(GrDiffGroup.Type.BOTH, lines)],
-    };
-
-    const section = {};
-    // Does not include +10 buttons when there are fewer than 11 lines.
-    let td = builder._createContextControl(section, contextLine);
-    let buttons = td.querySelectorAll('gr-button.showContext');
-
-    assert.equal(buttons.length, 1);
-    assert.equal(dom(buttons[0]).textContent, 'Show 10 common lines');
-
-    // Add another line.
-    const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-    line.text = 'lorem upsum';
-    line.beforeNumber = 11;
-    line.afterNumber = 11;
-    contextLine.contextGroups[0].addLine(line);
-
-    // Includes +10 buttons when there are at least 11 lines.
-    td = builder._createContextControl(section, contextLine);
-    buttons = td.querySelectorAll('gr-button.showContext');
-
-    assert.equal(buttons.length, 3);
-    assert.equal(dom(buttons[0]).textContent, '+10 above');
-    assert.equal(dom(buttons[1]).textContent, 'Show 11 common lines');
-    assert.equal(dom(buttons[2]).textContent, '+10 below');
-  });
-
-  test('newlines 1', () => {
-    let text = 'abcdef';
-
-    assert.equal(builder._formatText(text, 4, 10).innerHTML, text);
-    text = 'a'.repeat(20);
-    assert.equal(builder._formatText(text, 4, 10).innerHTML,
-        'a'.repeat(10) +
-        LINE_FEED_HTML +
-        'a'.repeat(10));
-  });
-
-  test('newlines 2', () => {
-    const text = '<span class="thumbsup">👍</span>';
-    assert.equal(builder._formatText(text, 4, 10).innerHTML,
-        '&lt;span clas' +
-        LINE_FEED_HTML +
-        's="thumbsu' +
-        LINE_FEED_HTML +
-        'p"&gt;👍&lt;/span' +
-        LINE_FEED_HTML +
-        '&gt;');
-  });
-
-  test('newlines 3', () => {
-    const text = '01234\t56789';
-    assert.equal(builder._formatText(text, 4, 10).innerHTML,
-        '01234' + builder._getTabWrapper(3).outerHTML + '56' +
-        LINE_FEED_HTML +
-        '789');
-  });
-
-  test('newlines 4', () => {
-    const text = '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍';
-    assert.equal(builder._formatText(text, 4, 20).innerHTML,
-        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
-        LINE_FEED_HTML +
-        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
-        LINE_FEED_HTML +
-        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍');
-  });
-
-  test('line_length ignored if line_wrapping is true', () => {
-    builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, text);
-  });
-
-  test('line_length applied if line_wrapping is false', () => {
-    builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + LINE_FEED_HTML + 'a';
-    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE]
-      .forEach(mode => {
-        test(`line_length used for regular files under ${mode}`, () => {
-          element.path = '/a.txt';
-          element.viewMode = mode;
-          builder = element._getDiffBuilder(
-              {}, {tab_size: 4, line_length: 50}
-          );
-          assert.equal(builder._prefs.line_length, 50);
-        });
-
-        test(`line_length ignored for commit msg under ${mode}`, () => {
-          element.path = '/COMMIT_MSG';
-          element.viewMode = mode;
-          builder = element._getDiffBuilder(
-              {}, {tab_size: 4, line_length: 50}
-          );
-          assert.equal(builder._prefs.line_length, 72);
-        });
-      });
-
-  test('_createTextEl linewrap with tabs', () => {
-    const text = '\t'.repeat(7) + '!';
-    const line = {text, highlights: []};
-    const el = builder._createTextEl(undefined, line);
-    assert.equal(el.innerText, text);
-    // With line length 10 and tab size 2, there should be a line break
-    // after every two tabs.
-    const newlineEl = el.querySelector('.contentText > .br');
-    assert.isOk(newlineEl);
-    assert.equal(
-        el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
-        newlineEl);
-  });
-
-  test('text length with tabs and unicode', () => {
-    function expectTextLength(text, tabSize, expected) {
-      // Formatting to |expected| columns should not introduce line breaks.
-      const result = builder._formatText(text, tabSize, expected);
-      assert.isNotOk(result.querySelector('.contentText > .br'),
-          `  Expected the result of: \n` +
-          `      _formatText(${text}', ${tabSize}, ${expected})\n` +
-          `  to not contain a br. But the actual result HTML was:\n` +
-          `      '${result.innerHTML}'\nwhereupon`);
-
-      // Increasing the line limit should produce the same markup.
-      assert.equal(builder._formatText(text, tabSize, Infinity).innerHTML,
-          result.innerHTML);
-      assert.equal(builder._formatText(text, tabSize, expected + 1).innerHTML,
-          result.innerHTML);
-
-      // Decreasing the line limit should introduce line breaks.
-      if (expected > 0) {
-        const tooSmall = builder._formatText(text, tabSize, expected - 1);
-        assert.isOk(tooSmall.querySelector('.contentText > .br'),
-            `  Expected the result of: \n` +
-            `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
-            `  to contain a br. But the actual result HTML was:\n` +
-            `      '${tooSmall.innerHTML}'\nwhereupon`);
-      }
-    }
-    expectTextLength('12345', 4, 5);
-    expectTextLength('\t\t12', 4, 10);
-    expectTextLength('abc💢123', 4, 7);
-    expectTextLength('abc\t', 8, 8);
-    expectTextLength('abc\t\t', 10, 20);
-    expectTextLength('', 10, 0);
-    expectTextLength('', 10, 0);
-    // 17 Thai combining chars.
-    expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
-    expectTextLength('abc\tde', 10, 12);
-    expectTextLength('abc\tde\t', 10, 20);
-    expectTextLength('\t\t\t\t\t', 20, 100);
-  });
-
-  test('tab wrapper insertion', () => {
-    const html = 'abc\tdef';
-    const tabSize = builder._prefs.tab_size;
-    const wrapper = builder._getTabWrapper(tabSize - 3);
-    assert.ok(wrapper);
-    assert.equal(wrapper.innerText, '\t');
-    assert.equal(
-        builder._formatText(html, tabSize, Infinity).innerHTML,
-        'abc' + wrapper.outerHTML + 'def');
-  });
-
-  test('tab wrapper style', () => {
-    const pattern = new RegExp('^<span class="style-scope gr-diff tab" ' +
-      'style="(?:-moz-)?tab-size: (\\d+);">\\t<\\/span>$');
-
-    for (const size of [1, 3, 8, 55]) {
-      const html = builder._getTabWrapper(size).outerHTML;
-      expect(html).to.match(pattern);
-      assert.equal(html.match(pattern)[1], size);
-    }
-  });
-
-  test('_handlePreferenceError called with invalid preference', () => {
-    sandbox.stub(element, '_handlePreferenceError');
-    const prefs = {tab_size: 0};
-    element._getDiffBuilder(element.diff, prefs);
-    assert.isTrue(element._handlePreferenceError.lastCall
-        .calledWithExactly('tab size'));
-  });
-
-  test('_handlePreferenceError triggers alert and javascript error', () => {
-    const errorStub = sinon.stub();
-    element.addEventListener('show-alert', errorStub);
-    assert.throws(element._handlePreferenceError.bind(element, 'tab size'));
-    assert.equal(errorStub.lastCall.args[0].detail.message,
-        `The value of the 'tab size' user preference is invalid. ` +
-      `Fix in diff preferences`);
-  });
-
-  suite('_isTotal', () => {
-    test('is total for add', () => {
-      const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-      for (let idx = 0; idx < 10; idx++) {
-        group.addLine(new GrDiffLine(GrDiffLine.Type.ADD));
-      }
-      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
-    });
-
-    test('is total for remove', () => {
-      const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-      for (let idx = 0; idx < 10; idx++) {
-        group.addLine(new GrDiffLine(GrDiffLine.Type.REMOVE));
-      }
-      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
-    });
-
-    test('not total for empty', () => {
-      const group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
-      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
-    });
-
-    test('not total for non-delta', () => {
-      const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-      for (let idx = 0; idx < 10; idx++) {
-        group.addLine(new GrDiffLine(GrDiffLine.Type.BOTH));
-      }
-      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
-    });
-  });
-
-  suite('intraline differences', () => {
-    let el;
-    let str;
-    let annotateElementSpy;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    function slice(str, start, end) {
-      return Array.from(str).slice(start, end)
-          .join('');
-    }
-
-    setup(() => {
-      el = fixture('div-with-text');
-      str = el.textContent;
-      annotateElementSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      layer = document.createElement('gr-diff-builder')
-          ._createIntralineLayer();
-    });
-
-    test('annotate no highlights', () => {
-      const line = {
-        text: str,
-        highlights: [],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      // The content is unchanged.
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(str, el.childNodes[0].textContent);
-    });
-
-    test('annotate with highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-          {startIndex: 18, endIndex: 22},
-        ],
-      };
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12, 18);
-      const str3 = slice(str, 18, 22);
-      const str4 = slice(str, 22);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 5);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-
-      assert.notInstanceOf(el.childNodes[3], Text);
-      assert.equal(el.childNodes[3].textContent, str3);
-
-      assert.instanceOf(el.childNodes[4], Text);
-      assert.equal(el.childNodes[4].textContent, str4);
-    });
-
-    test('annotate without endIndex', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28},
-        ],
-      };
-
-      const str0 = slice(str, 0, 28);
-      const str1 = slice(str, 28);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-
-    test('annotate ignores empty highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28, endIndex: 28},
-        ],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-    });
-
-    test('annotate handles unicode', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 3);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-    });
-
-    test('annotate handles unicode w/o endIndex', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-  });
-
-  suite('tab indicators', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = fixture('basic');
-      element._showTabs = true;
-      layer = element._createTabIndicatorLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sandbox.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no tabs', () => {
-      const str = 'lorem ipsum no tabs';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sandbox.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates tab at beginning', () => {
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sandbox.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTabs = false;
-
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sandbox.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates multiple in beginning', () => {
-      const str = '\t\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sandbox.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 2);
-
-      let args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-
-      args = annotateElementStub.getCalls()[1].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 1, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('annotates intermediate tabs', () => {
-      const str = 'lorem\tupsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sandbox.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 5, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-  });
-
-  suite('layers', () => {
-    let element;
-    let initialLayersCount;
-    let withLayerCount;
-    setup(() => {
-      const layers = [];
-      element = fixture('basic');
-      element.layers = layers;
-      element._showTrailingWhitespace = true;
-      element._setupAnnotationLayers();
-      initialLayersCount = element._layers.length;
-    });
-
-    test('no layers', () => {
-      element._setupAnnotationLayers();
-      assert.equal(element._layers.length, initialLayersCount);
-    });
-
-    suite('with layers', () => {
-      const layers = [{}, {}];
-      setup(() => {
-        element = fixture('basic');
-        element.layers = layers;
-        element._showTrailingWhitespace = true;
-        element._setupAnnotationLayers();
-        withLayerCount = element._layers.length;
-      });
-      test('with layers', () => {
-        element._setupAnnotationLayers();
-        assert.equal(element._layers.length, withLayerCount);
-        assert.equal(initialLayersCount + layers.length,
-            withLayerCount);
-      });
-    });
-  });
-
-  suite('trailing whitespace', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = fixture('basic');
-      element._showTrailingWhitespace = true;
-      layer = element._createTrailingWhitespaceLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sandbox.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no trailing whitespace', () => {
-      const str = 'lorem ipsum blah blah';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sandbox.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates trailing spaces', () => {
-      const str = 'lorem ipsum   ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sandbox.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates trailing tabs', () => {
-      const str = 'lorem ipsum\t\t\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sandbox.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates mixed trailing whitespace', () => {
-      const str = 'lorem ipsum\t \t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sandbox.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('unicode preceding trailing whitespace', () => {
-      const str = '💢\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sandbox.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 1);
-      assert.equal(annotateElementStub.lastCall.args[2], 1);
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTrailingWhitespace = false;
-      const str = 'lorem upsum\t \t ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sandbox.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-  });
-
-  suite('rendering text, images and binary files', () => {
-    let processStub;
-    let keyLocations;
-    let prefs;
-    let content;
-
-    setup(() => {
-      element = fixture('basic');
-      element.viewMode = 'SIDE_BY_SIDE';
-      processStub = sandbox.stub(element.$.processor, 'process')
-          .returns(Promise.resolve());
-      keyLocations = {left: {}, right: {}};
-      prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-    });
-
-    test('text', () => {
-      element.diff = {content};
-      return element.render(keyLocations, prefs).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isFalse(processStub.lastCall.args[1]);
-      });
-    });
-
-    test('image', () => {
-      element.diff = {content, binary: true};
-      element.isImageDiff = true;
-      return element.render(keyLocations, prefs).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isTrue(processStub.lastCall.args[1]);
-      });
-    });
-
-    test('binary', () => {
-      element.diff = {content, binary: true};
-      return element.render(keyLocations, prefs).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isTrue(processStub.lastCall.args[1]);
-      });
-    });
-  });
-
-  suite('rendering', () => {
-    let content;
-    let outputEl;
-    let keyLocations;
-
-    setup(done => {
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      content = [
-        {
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        },
-        {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        },
-      ];
-      element = fixture('basic');
-      outputEl = element.queryEffectiveChildren('#diffTable');
-      keyLocations = {left: {}, right: {}};
-      sandbox.stub(element, '_getDiffBuilder', () => {
-        const builder = new GrDiffBuilder({content}, prefs, outputEl);
-        sandbox.stub(builder, 'addColumns');
-        builder.buildSectionElement = function(group) {
-          const section = document.createElement('stub');
-          section.textContent = group.lines
-              .reduce((acc, line) => acc + line.text, '');
-          return section;
-        };
-        return builder;
-      });
-      element.diff = {content};
-      element.render(keyLocations, prefs).then(done);
-    });
-
-    test('addColumns is called', done => {
-      element.render(keyLocations, {}).then(done);
-      assert.isTrue(element._builder.addColumns.called);
-    });
-
-    test('getSectionsByLineRange one line', () => {
-      const section = outputEl.querySelector('stub:nth-of-type(2)');
-      const sections = element._builder.getSectionsByLineRange(1, 1, 'left');
-      assert.equal(sections.length, 1);
-      assert.strictEqual(sections[0], section);
-    });
-
-    test('getSectionsByLineRange over diff', () => {
-      const section = [
-        outputEl.querySelector('stub:nth-of-type(2)'),
-        outputEl.querySelector('stub:nth-of-type(3)'),
-      ];
-      const sections = element._builder.getSectionsByLineRange(1, 2, 'left');
-      assert.equal(sections.length, 2);
-      assert.strictEqual(sections[0], section[0]);
-      assert.strictEqual(sections[1], section[1]);
-    });
-
-    test('render-start and render-content are fired', done => {
-      const dispatchEventStub = sandbox.stub(element, 'dispatchEvent');
-      element.render(keyLocations, {}).then(() => {
-        const firedEventTypes = dispatchEventStub.getCalls()
-            .map(c => c.args[0].type);
-        assert.include(firedEventTypes, 'render-start');
-        assert.include(firedEventTypes, 'render-content');
-        done();
-      });
-    });
-
-    test('cancel', () => {
-      const processorCancelStub = sandbox.stub(element.$.processor, 'cancel');
-      element.cancel();
-      assert.isTrue(processorCancelStub.called);
-    });
-  });
-
-  suite('mock-diff', () => {
-    let element;
-    let builder;
-    let diff;
-    let prefs;
-    let keyLocations;
-
-    setup(done => {
-      element = fixture('mock-diff');
-      diff = getMockDiffResponse();
-      element.diff = diff;
-
-      prefs = {
-        line_length: 80,
-        show_tabs: true,
-        tab_size: 4,
-      };
-      keyLocations = {left: {}, right: {}};
-
-      element.render(keyLocations, prefs).then(() => {
-        builder = element._builder;
-        done();
-      });
-    });
-
-    test('aria-labels on added line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.right')[5];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(deltaLineNumberButton.getAttribute('aria-label'), '5 added');
-    });
-
-    test('aria-labels on removed line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.left')[10];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(
-          deltaLineNumberButton.getAttribute('aria-label'), '10 removed');
-    });
-
-    test('getContentByLine', () => {
-      let actual;
-
-      actual = builder.getContentByLine(2, 'left');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(2, 'right');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(5, 'left');
-      assert.equal(actual.textContent, diff.content[2].ab[0]);
-
-      actual = builder.getContentByLine(5, 'right');
-      assert.equal(actual.textContent, diff.content[1].b[0]);
-    });
-
-    test('getContentByLineEl works both with button and td', () => {
-      const diffRow = element.diffElement.querySelectorAll('tr.diff-row')[2];
-
-      const lineNumTdLeft = diffRow.querySelector('td.lineNum.left');
-      const lineNumButtonLeft = lineNumTdLeft.querySelector('button');
-      const contentLeft = diffRow.querySelectorAll('.contentText')[0];
-
-      const lineNumTdRight = diffRow.querySelector('td.lineNum.right');
-      const lineNumButtonRight = lineNumTdRight.querySelector('button');
-      const contentRight = diffRow.querySelectorAll('.contentText')[1];
-
-      assert.equal(element.getContentByLineEl(lineNumTdLeft), contentLeft);
-      assert.equal(element.getContentByLineEl(lineNumButtonLeft), contentLeft);
-      assert.equal(element.getContentByLineEl(lineNumTdRight), contentRight);
-      assert.equal(
-          element.getContentByLineEl(lineNumButtonRight), contentRight);
-    });
-
-    test('findLinesByRange', () => {
-      const lines = [];
-      const elems = [];
-      const start = 6;
-      const end = 10;
-      const count = end - start + 1;
-
-      builder.findLinesByRange(start, end, 'right', lines, elems);
-
-      assert.equal(lines.length, count);
-      assert.equal(elems.length, count);
-
-      for (let i = 0; i < 5; i++) {
-        assert.instanceOf(lines[i], GrDiffLine);
-        assert.equal(lines[i].afterNumber, start + i);
-        assert.instanceOf(elems[i], HTMLElement);
-        assert.equal(lines[i].text, elems[i].textContent);
-      }
-    });
-
-    test('_renderContentByRange', () => {
-      const spy = sandbox.spy(builder, '_createTextEl');
-      const start = 9;
-      const end = 14;
-      const count = end - start + 1;
-
-      builder._renderContentByRange(start, end, 'left');
-
-      assert.equal(spy.callCount, count);
-      spy.getCalls().forEach((call, i) => {
-        assert.equal(call.args[1].beforeNumber, start + i);
-      });
-    });
-
-    test('_renderContentByRange notexistent elements', () => {
-      const spy = sandbox.spy(builder, '_createTextEl');
-
-      sandbox.stub(builder, 'findLinesByRange',
-          (s, e, d, lines, elements) => {
-            // Add a line and a corresponding element.
-            lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
-            const tr = document.createElement('tr');
-            const td = document.createElement('td');
-            const el = document.createElement('div');
-            tr.appendChild(td);
-            td.appendChild(el);
-            elements.push(el);
-
-            // Add 2 lines without corresponding elements.
-            lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
-            lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
-          });
-
-      builder._renderContentByRange(1, 10, 'left');
-      // Should be called only once because only one line had a corresponding
-      // element.
-      assert.equal(spy.callCount, 1);
-    });
-
-    test('_getLineNumberEl side-by-side left', () => {
-      const contentEl = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('left'));
-    });
-
-    test('_getLineNumberEl side-by-side right', () => {
-      const contentEl = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('right'));
-    });
-
-    test('_getLineNumberEl unified left', done => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      element.render(keyLocations, prefs).then(() => {
-        builder = element._builder;
-
-        const contentEl = builder.getContentByLine(5, 'left',
-            element.$.diffTable);
-        const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
-        assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-        assert.isTrue(lineNumberEl.classList.contains('left'));
-        done();
-      });
-    });
-
-    test('_getLineNumberEl unified right', done => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      element.render(keyLocations, prefs).then(() => {
-        builder = element._builder;
-
-        const contentEl = builder.getContentByLine(5, 'right',
-            element.$.diffTable);
-        const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
-        assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-        assert.isTrue(lineNumberEl.classList.contains('right'));
-        done();
-      });
-    });
-
-    test('_getNextContentOnSide side-by-side left', () => {
-      const startElem = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const expectedStartString = diff.content[2].ab[0];
-      const expectedNextString = diff.content[2].ab[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder._getNextContentOnSide(startElem,
-          'left');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('_getNextContentOnSide side-by-side right', () => {
-      const startElem = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const expectedStartString = diff.content[1].b[0];
-      const expectedNextString = diff.content[1].b[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder._getNextContentOnSide(startElem,
-          'right');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('_getNextContentOnSide unified left', done => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      element.render(keyLocations, prefs).then(() => {
-        builder = element._builder;
-
-        const startElem = builder.getContentByLine(5, 'left',
-            element.$.diffTable);
-        const expectedStartString = diff.content[2].ab[0];
-        const expectedNextString = diff.content[2].ab[1];
-        assert.equal(startElem.textContent, expectedStartString);
-
-        const nextElem = builder._getNextContentOnSide(startElem,
-            'left');
-        assert.equal(nextElem.textContent, expectedNextString);
-
-        done();
-      });
-    });
-
-    test('_getNextContentOnSide unified right', done => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      element.render(keyLocations, prefs).then(() => {
-        builder = element._builder;
-
-        const startElem = builder.getContentByLine(5, 'right',
-            element.$.diffTable);
-        const expectedStartString = diff.content[1].b[0];
-        const expectedNextString = diff.content[1].b[1];
-        assert.equal(startElem.textContent, expectedStartString);
-
-        const nextElem = builder._getNextContentOnSide(startElem,
-            'right');
-        assert.equal(nextElem.textContent, expectedNextString);
-
-        done();
-      });
-    });
-
-    test('escaping HTML', () => {
-      let input = '<script>alert("XSS");<' + '/script>';
-      let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
-      let result = builder._formatText(input, 1, Infinity).innerHTML;
-      assert.equal(result, expected);
-
-      input = '& < > " \' / `';
-      expected = '&amp; &lt; &gt; " \' / `';
-      result = builder._formatText(input, 1, Infinity).innerHTML;
-      assert.equal(result, expected);
-    });
-  });
-
-  suite('blame', () => {
-    let mockBlame;
-
-    setup(() => {
-      mockBlame = [
-        {id: 'commit 1', ranges: [{start: 1, end: 2}, {start: 10, end: 16}]},
-        {id: 'commit 2', ranges: [{start: 4, end: 10}, {start: 17, end: 32}]},
-      ];
-    });
-
-    test('setBlame attempts to render each blamed line', () => {
-      const getBlameStub = sandbox.stub(builder, '_getBlameByLineNum')
-          .returns(null);
-      builder.setBlame(mockBlame);
-      assert.equal(getBlameStub.callCount, 32);
-    });
-
-    test('_getBlameCommitForBaseLine', () => {
-      builder.setBlame(mockBlame);
-      assert.isOk(builder._getBlameCommitForBaseLine(1));
-      assert.equal(builder._getBlameCommitForBaseLine(1).id, 'commit 1');
-
-      assert.isOk(builder._getBlameCommitForBaseLine(11));
-      assert.equal(builder._getBlameCommitForBaseLine(11).id, 'commit 1');
-
-      assert.isOk(builder._getBlameCommitForBaseLine(32));
-      assert.equal(builder._getBlameCommitForBaseLine(32).id, 'commit 2');
-
-      assert.isNull(builder._getBlameCommitForBaseLine(33));
-    });
-
-    test('_getBlameCommitForBaseLine w/o blame returns null', () => {
-      assert.isNull(builder._getBlameCommitForBaseLine(1));
-      assert.isNull(builder._getBlameCommitForBaseLine(11));
-      assert.isNull(builder._getBlameCommitForBaseLine(31));
-    });
-
-    test('_createBlameCell', () => {
-      const mocbBlameCell = document.createElement('span');
-      const getBlameStub = sinon.stub(builder, '_getBlameForBaseLine')
-          .returns(mocbBlameCell);
-      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-
-      const result = builder._createBlameCell(line);
-
-      assert.isTrue(getBlameStub.calledWithExactly(3));
-      assert.equal(result.getAttribute('data-line-number'), '3');
-      assert.equal(result.firstChild, mocbBlameCell);
-    });
-
-    test('_getBlameForBaseLine', () => {
-      const mockCommit = {
-        time: 1576105200,
-        id: 1234567890,
-        author: 'Clark Kent',
-        commit_msg: 'Testing Commit',
-        ranges: [1],
-      };
-      const blameNode = builder._getBlameForBaseLine(1, mockCommit);
-
-      const authors = blameNode.getElementsByClassName('blameAuthor');
-      assert.equal(authors.length, 1);
-      assert.equal(authors[0].innerText, ' Clark');
-
-      const date = (new Date(mockCommit.time * 1000)).toLocaleDateString();
-      flush();
-      const cards = blameNode.getElementsByClassName('blameHoverCard');
-      assert.equal(cards.length, 1);
-      assert.equal(cards[0].innerHTML,
-          `Commit 1234567890<br>Author: Clark Kent<br>Date: ${date}`
-        + '<br><br>Testing Commit'
-      );
-
-      const url = blameNode.getElementsByClassName('blameDate');
-      assert.equal(url[0].getAttribute('href'), '/r/q/1234567890');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
new file mode 100644
index 0000000..0ac7dac
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -0,0 +1,1235 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-diff/gr-diff-group.js';
+import './gr-diff-builder.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import './gr-diff-builder-element.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
+import {GrDiffBuilder} from './gr-diff-builder.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+    <gr-diff-builder>
+      <table id="diffTable"></table>
+    </gr-diff-builder>
+`);
+
+const divWithTextFixture = fixtureFromTemplate(html`
+<div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+`);
+
+const mockDiffFixture = fixtureFromTemplate(html`
+<gr-diff-builder view-mode="SIDE_BY_SIDE">
+      <table id="diffTable"></table>
+    </gr-diff-builder>
+`);
+
+const DiffViewMode = {
+  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+  UNIFIED: 'UNIFIED_DIFF',
+};
+
+suite('gr-diff-builder tests', () => {
+  let prefs;
+  let element;
+  let builder;
+
+  const LINE_FEED_HTML = '<span class="style-scope gr-diff br"></span>';
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      getProjectConfig() { return Promise.resolve({}); },
+    });
+    stubBaseUrl('/r');
+    prefs = {
+      line_length: 10,
+      show_tabs: true,
+      tab_size: 4,
+    };
+    builder = new GrDiffBuilder({content: []}, prefs);
+  });
+
+  test('_createElement classStr applies all classes', () => {
+    const node = builder._createElement('div', 'test classes');
+    assert.isTrue(node.classList.contains('gr-diff'));
+    assert.isTrue(node.classList.contains('test'));
+    assert.isTrue(node.classList.contains('classes'));
+  });
+
+  suite('context control', () => {
+    function createContextGroups(options) {
+      const offset = options.offset || 0;
+      const numLines = options.count || 10;
+      const lines = [];
+      for (let i = 0; i < numLines; i++) {
+        const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+        line.beforeNumber = offset + i + 1;
+        line.afterNumber = offset + i + 1;
+        line.text = 'lorem upsum';
+        lines.push(line);
+      }
+
+      return [new GrDiffGroup(GrDiffGroup.Type.BOTH, lines)];
+    }
+
+    test('no +10 buttons for 10 or less lines', () => {
+      const contextGroups = createContextGroups({count: 10});
+      const td = builder._createContextControl({}, contextGroups);
+      const buttons = td.querySelectorAll('gr-button.showContext');
+
+      assert.equal(buttons.length, 1);
+      assert.equal(dom(buttons[0]).textContent, 'Show 10 common lines');
+    });
+
+    test('context control at the top', () => {
+      const contextGroups = createContextGroups({offset: 0, count: 20});
+      builder._numLinesLeft = 50;
+      const td = builder._createContextControl({}, contextGroups);
+      const buttons = td.querySelectorAll('gr-button.showContext');
+
+      assert.equal(buttons.length, 2);
+      assert.equal(dom(buttons[0]).textContent, 'Show 20 common lines');
+      assert.equal(dom(buttons[1]).textContent, '+10 below');
+    });
+
+    test('context control in the middle', () => {
+      const contextGroups = createContextGroups({offset: 10, count: 20});
+      builder._numLinesLeft = 50;
+      const td = builder._createContextControl({}, contextGroups);
+      const buttons = td.querySelectorAll('gr-button.showContext');
+
+      assert.equal(buttons.length, 3);
+      assert.equal(dom(buttons[0]).textContent, '+10 above');
+      assert.equal(dom(buttons[1]).textContent, 'Show 20 common lines');
+      assert.equal(dom(buttons[2]).textContent, '+10 below');
+    });
+
+    test('context control at the top', () => {
+      const contextGroups = createContextGroups({offset: 30, count: 20});
+      builder._numLinesLeft = 50;
+      const td = builder._createContextControl({}, contextGroups);
+      const buttons = td.querySelectorAll('gr-button.showContext');
+
+      assert.equal(buttons.length, 2);
+      assert.equal(dom(buttons[0]).textContent, '+10 above');
+      assert.equal(dom(buttons[1]).textContent, 'Show 20 common lines');
+    });
+  });
+
+  test('newlines 1', () => {
+    let text = 'abcdef';
+
+    assert.equal(builder._formatText(text, 4, 10).innerHTML, text);
+    text = 'a'.repeat(20);
+    assert.equal(builder._formatText(text, 4, 10).innerHTML,
+        'a'.repeat(10) +
+        LINE_FEED_HTML +
+        'a'.repeat(10));
+  });
+
+  test('newlines 2', () => {
+    const text = '<span class="thumbsup">👍</span>';
+    assert.equal(builder._formatText(text, 4, 10).innerHTML,
+        '&lt;span clas' +
+        LINE_FEED_HTML +
+        's="thumbsu' +
+        LINE_FEED_HTML +
+        'p"&gt;👍&lt;/span' +
+        LINE_FEED_HTML +
+        '&gt;');
+  });
+
+  test('newlines 3', () => {
+    const text = '01234\t56789';
+    assert.equal(builder._formatText(text, 4, 10).innerHTML,
+        '01234' + builder._getTabWrapper(3).outerHTML + '56' +
+        LINE_FEED_HTML +
+        '789');
+  });
+
+  test('newlines 4', () => {
+    const text = '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍';
+    assert.equal(builder._formatText(text, 4, 20).innerHTML,
+        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
+        LINE_FEED_HTML +
+        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
+        LINE_FEED_HTML +
+        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍');
+  });
+
+  test('line_length ignored if line_wrapping is true', () => {
+    builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
+    const text = 'a'.repeat(51);
+
+    const line = {text, highlights: []};
+    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
+    assert.equal(result, text);
+  });
+
+  test('line_length applied if line_wrapping is false', () => {
+    builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
+    const text = 'a'.repeat(51);
+
+    const line = {text, highlights: []};
+    const expected = 'a'.repeat(50) + LINE_FEED_HTML + 'a';
+    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE]
+      .forEach(mode => {
+        test(`line_length used for regular files under ${mode}`, () => {
+          element.path = '/a.txt';
+          element.viewMode = mode;
+          builder = element._getDiffBuilder(
+              {}, {tab_size: 4, line_length: 50}
+          );
+          assert.equal(builder._prefs.line_length, 50);
+        });
+
+        test(`line_length ignored for commit msg under ${mode}`, () => {
+          element.path = '/COMMIT_MSG';
+          element.viewMode = mode;
+          builder = element._getDiffBuilder(
+              {}, {tab_size: 4, line_length: 50}
+          );
+          assert.equal(builder._prefs.line_length, 72);
+        });
+      });
+
+  test('_createTextEl linewrap with tabs', () => {
+    const text = '\t'.repeat(7) + '!';
+    const line = {text, highlights: []};
+    const el = builder._createTextEl(undefined, line);
+    assert.equal(el.innerText, text);
+    // With line length 10 and tab size 2, there should be a line break
+    // after every two tabs.
+    const newlineEl = el.querySelector('.contentText > .br');
+    assert.isOk(newlineEl);
+    assert.equal(
+        el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
+        newlineEl);
+  });
+
+  test('text length with tabs and unicode', () => {
+    function expectTextLength(text, tabSize, expected) {
+      // Formatting to |expected| columns should not introduce line breaks.
+      const result = builder._formatText(text, tabSize, expected);
+      assert.isNotOk(result.querySelector('.contentText > .br'),
+          `  Expected the result of: \n` +
+          `      _formatText(${text}', ${tabSize}, ${expected})\n` +
+          `  to not contain a br. But the actual result HTML was:\n` +
+          `      '${result.innerHTML}'\nwhereupon`);
+
+      // Increasing the line limit should produce the same markup.
+      assert.equal(builder._formatText(text, tabSize, Infinity).innerHTML,
+          result.innerHTML);
+      assert.equal(builder._formatText(text, tabSize, expected + 1).innerHTML,
+          result.innerHTML);
+
+      // Decreasing the line limit should introduce line breaks.
+      if (expected > 0) {
+        const tooSmall = builder._formatText(text, tabSize, expected - 1);
+        assert.isOk(tooSmall.querySelector('.contentText > .br'),
+            `  Expected the result of: \n` +
+            `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
+            `  to contain a br. But the actual result HTML was:\n` +
+            `      '${tooSmall.innerHTML}'\nwhereupon`);
+      }
+    }
+    expectTextLength('12345', 4, 5);
+    expectTextLength('\t\t12', 4, 10);
+    expectTextLength('abc💢123', 4, 7);
+    expectTextLength('abc\t', 8, 8);
+    expectTextLength('abc\t\t', 10, 20);
+    expectTextLength('', 10, 0);
+    // 17 Thai combining chars.
+    expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
+    expectTextLength('abc\tde', 10, 12);
+    expectTextLength('abc\tde\t', 10, 20);
+    expectTextLength('\t\t\t\t\t', 20, 100);
+  });
+
+  test('tab wrapper insertion', () => {
+    const html = 'abc\tdef';
+    const tabSize = builder._prefs.tab_size;
+    const wrapper = builder._getTabWrapper(tabSize - 3);
+    assert.ok(wrapper);
+    assert.equal(wrapper.innerText, '\t');
+    assert.equal(
+        builder._formatText(html, tabSize, Infinity).innerHTML,
+        'abc' + wrapper.outerHTML + 'def');
+  });
+
+  test('tab wrapper style', () => {
+    const pattern = new RegExp('^<span class="style-scope gr-diff tab" ' +
+      'style="(?:-moz-)?tab-size: (\\d+);">\\t<\\/span>$');
+
+    for (const size of [1, 3, 8, 55]) {
+      const html = builder._getTabWrapper(size).outerHTML;
+      expect(html).to.match(pattern);
+      assert.equal(html.match(pattern)[1], size);
+    }
+  });
+
+  test('_handlePreferenceError called with invalid preference', () => {
+    sinon.stub(element, '_handlePreferenceError');
+    const prefs = {tab_size: 0};
+    element._getDiffBuilder(element.diff, prefs);
+    assert.isTrue(element._handlePreferenceError.lastCall
+        .calledWithExactly('tab size'));
+  });
+
+  test('_handlePreferenceError triggers alert and javascript error', () => {
+    const errorStub = sinon.stub();
+    element.addEventListener('show-alert', errorStub);
+    assert.throws(element._handlePreferenceError.bind(element, 'tab size'));
+    assert.equal(errorStub.lastCall.args[0].detail.message,
+        `The value of the 'tab size' user preference is invalid. ` +
+      `Fix in diff preferences`);
+  });
+
+  suite('_isTotal', () => {
+    test('is total for add', () => {
+      const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+      for (let idx = 0; idx < 10; idx++) {
+        group.addLine(new GrDiffLine(GrDiffLine.Type.ADD));
+      }
+      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
+    });
+
+    test('is total for remove', () => {
+      const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+      for (let idx = 0; idx < 10; idx++) {
+        group.addLine(new GrDiffLine(GrDiffLine.Type.REMOVE));
+      }
+      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
+    });
+
+    test('not total for empty', () => {
+      const group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
+    });
+
+    test('not total for non-delta', () => {
+      const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+      for (let idx = 0; idx < 10; idx++) {
+        group.addLine(new GrDiffLine(GrDiffLine.Type.BOTH));
+      }
+      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
+    });
+  });
+
+  suite('intraline differences', () => {
+    let el;
+    let str;
+    let annotateElementSpy;
+    let layer;
+    const lineNumberEl = document.createElement('td');
+
+    function slice(str, start, end) {
+      return Array.from(str).slice(start, end)
+          .join('');
+    }
+
+    setup(() => {
+      el = divWithTextFixture.instantiate();
+      str = el.textContent;
+      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+      layer = document.createElement('gr-diff-builder')
+          ._createIntralineLayer();
+    });
+
+    test('annotate no highlights', () => {
+      const line = {
+        text: str,
+        highlights: [],
+      };
+
+      layer.annotate(el, lineNumberEl, line);
+
+      // The content is unchanged.
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(str, el.childNodes[0].textContent);
+    });
+
+    test('annotate with highlights', () => {
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 6, endIndex: 12},
+          {startIndex: 18, endIndex: 22},
+        ],
+      };
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12, 18);
+      const str3 = slice(str, 18, 22);
+      const str4 = slice(str, 22);
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 5);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+
+      assert.notInstanceOf(el.childNodes[3], Text);
+      assert.equal(el.childNodes[3].textContent, str3);
+
+      assert.instanceOf(el.childNodes[4], Text);
+      assert.equal(el.childNodes[4].textContent, str4);
+    });
+
+    test('annotate without endIndex', () => {
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 28},
+        ],
+      };
+
+      const str0 = slice(str, 0, 28);
+      const str1 = slice(str, 28);
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+
+    test('annotate ignores empty highlights', () => {
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 28, endIndex: 28},
+        ],
+      };
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+    });
+
+    test('annotate handles unicode', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 6, endIndex: 12},
+        ],
+      };
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12);
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 3);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+    });
+
+    test('annotate handles unicode w/o endIndex', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+
+      const line = {
+        text: str,
+        highlights: [
+          {startIndex: 6},
+        ],
+      };
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6);
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+  });
+
+  suite('tab indicators', () => {
+    let element;
+    let layer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element._showTabs = true;
+      layer = element._createTabIndicatorLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const line = {text: ''};
+      const el = document.createElement('div');
+      const annotateElementStub =
+          sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no tabs', () => {
+      const str = 'lorem ipsum no tabs';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates tab at beginning', () => {
+      const str = '\tlorem upsum';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('does not annotate when disabled', () => {
+      element._showTabs = false;
+
+      const str = '\tlorem upsum';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates multiple in beginning', () => {
+      const str = '\t\tlorem upsum';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.equal(annotateElementStub.callCount, 2);
+
+      let args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+
+      args = annotateElementStub.getCalls()[1].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 1, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('annotates intermediate tabs', () => {
+      const str = 'lorem\tupsum';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, line);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 5, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+  });
+
+  suite('layers', () => {
+    let element;
+    let initialLayersCount;
+    let withLayerCount;
+    setup(() => {
+      const layers = [];
+      element = basicFixture.instantiate();
+      element.layers = layers;
+      element._showTrailingWhitespace = true;
+      element._setupAnnotationLayers();
+      initialLayersCount = element._layers.length;
+    });
+
+    test('no layers', () => {
+      element._setupAnnotationLayers();
+      assert.equal(element._layers.length, initialLayersCount);
+    });
+
+    suite('with layers', () => {
+      const layers = [{}, {}];
+      setup(() => {
+        element = basicFixture.instantiate();
+        element.layers = layers;
+        element._showTrailingWhitespace = true;
+        element._setupAnnotationLayers();
+        withLayerCount = element._layers.length;
+      });
+      test('with layers', () => {
+        element._setupAnnotationLayers();
+        assert.equal(element._layers.length, withLayerCount);
+        assert.equal(initialLayersCount + layers.length,
+            withLayerCount);
+      });
+    });
+  });
+
+  suite('trailing whitespace', () => {
+    let element;
+    let layer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element._showTrailingWhitespace = true;
+      layer = element._createTrailingWhitespaceLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const line = {text: ''};
+      const el = document.createElement('div');
+      const annotateElementStub =
+          sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no trailing whitespace', () => {
+      const str = 'lorem ipsum blah blah';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates trailing spaces', () => {
+      const str = 'lorem ipsum   ';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates trailing tabs', () => {
+      const str = 'lorem ipsum\t\t\t';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates mixed trailing whitespace', () => {
+      const str = 'lorem ipsum\t \t';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('unicode preceding trailing whitespace', () => {
+      const str = '💢\t';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 1);
+      assert.equal(annotateElementStub.lastCall.args[2], 1);
+    });
+
+    test('does not annotate when disabled', () => {
+      element._showTrailingWhitespace = false;
+      const str = 'lorem upsum\t \t ';
+      const line = {text: str};
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub =
+          sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, line);
+      assert.isFalse(annotateElementStub.called);
+    });
+  });
+
+  suite('rendering text, images and binary files', () => {
+    let processStub;
+    let keyLocations;
+    let prefs;
+    let content;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.viewMode = 'SIDE_BY_SIDE';
+      processStub = sinon.stub(element.$.processor, 'process')
+          .returns(Promise.resolve());
+      keyLocations = {left: {}, right: {}};
+      prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [{
+        a: ['all work and no play make andybons a dull boy'],
+        b: ['elgoog elgoog elgoog'],
+      }, {
+        ab: [
+          'Non eram nescius, Brute, cum, quae summis ingeniis ',
+          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+        ],
+      }];
+    });
+
+    test('text', () => {
+      element.diff = {content};
+      return element.render(keyLocations, prefs).then(() => {
+        assert.isTrue(processStub.calledOnce);
+        assert.isFalse(processStub.lastCall.args[1]);
+      });
+    });
+
+    test('image', () => {
+      element.diff = {content, binary: true};
+      element.isImageDiff = true;
+      return element.render(keyLocations, prefs).then(() => {
+        assert.isTrue(processStub.calledOnce);
+        assert.isTrue(processStub.lastCall.args[1]);
+      });
+    });
+
+    test('binary', () => {
+      element.diff = {content, binary: true};
+      return element.render(keyLocations, prefs).then(() => {
+        assert.isTrue(processStub.calledOnce);
+        assert.isTrue(processStub.lastCall.args[1]);
+      });
+    });
+  });
+
+  suite('rendering', () => {
+    let content;
+    let outputEl;
+    let keyLocations;
+
+    setup(done => {
+      const prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      element = basicFixture.instantiate();
+      outputEl = element.queryEffectiveChildren('#diffTable');
+      keyLocations = {left: {}, right: {}};
+      sinon.stub(element, '_getDiffBuilder').callsFake(() => {
+        const builder = new GrDiffBuilder({content}, prefs, outputEl);
+        sinon.stub(builder, 'addColumns');
+        builder.buildSectionElement = function(group) {
+          const section = document.createElement('stub');
+          section.textContent = group.lines
+              .reduce((acc, line) => acc + line.text, '');
+          return section;
+        };
+        return builder;
+      });
+      element.diff = {content};
+      element.render(keyLocations, prefs).then(done);
+    });
+
+    test('addColumns is called', done => {
+      element.render(keyLocations, {}).then(done);
+      assert.isTrue(element._builder.addColumns.called);
+    });
+
+    test('getSectionsByLineRange one line', () => {
+      const section = outputEl.querySelector('stub:nth-of-type(2)');
+      const sections = element._builder.getSectionsByLineRange(1, 1, 'left');
+      assert.equal(sections.length, 1);
+      assert.strictEqual(sections[0], section);
+    });
+
+    test('getSectionsByLineRange over diff', () => {
+      const section = [
+        outputEl.querySelector('stub:nth-of-type(2)'),
+        outputEl.querySelector('stub:nth-of-type(3)'),
+      ];
+      const sections = element._builder.getSectionsByLineRange(1, 2, 'left');
+      assert.equal(sections.length, 2);
+      assert.strictEqual(sections[0], section[0]);
+      assert.strictEqual(sections[1], section[1]);
+    });
+
+    test('render-start and render-content are fired', done => {
+      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+      element.render(keyLocations, {}).then(() => {
+        const firedEventTypes = dispatchEventStub.getCalls()
+            .map(c => c.args[0].type);
+        assert.include(firedEventTypes, 'render-start');
+        assert.include(firedEventTypes, 'render-content');
+        done();
+      });
+    });
+
+    test('cancel', () => {
+      const processorCancelStub = sinon.stub(element.$.processor, 'cancel');
+      element.cancel();
+      assert.isTrue(processorCancelStub.called);
+    });
+  });
+
+  suite('mock-diff', () => {
+    let element;
+    let builder;
+    let diff;
+    let prefs;
+    let keyLocations;
+
+    setup(done => {
+      element = mockDiffFixture.instantiate();
+      diff = getMockDiffResponse();
+      element.diff = diff;
+
+      prefs = {
+        line_length: 80,
+        show_tabs: true,
+        tab_size: 4,
+      };
+      keyLocations = {left: {}, right: {}};
+
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+        done();
+      });
+    });
+
+    test('aria-labels on added line numbers', () => {
+      const deltaLineNumberButton = element.diffElement.querySelectorAll(
+          '.lineNumButton.right')[5];
+
+      assert.isOk(deltaLineNumberButton);
+      assert.equal(deltaLineNumberButton.getAttribute('aria-label'), '5 added');
+    });
+
+    test('aria-labels on removed line numbers', () => {
+      const deltaLineNumberButton = element.diffElement.querySelectorAll(
+          '.lineNumButton.left')[10];
+
+      assert.isOk(deltaLineNumberButton);
+      assert.equal(
+          deltaLineNumberButton.getAttribute('aria-label'), '10 removed');
+    });
+
+    test('getContentByLine', () => {
+      let actual;
+
+      actual = builder.getContentByLine(2, 'left');
+      assert.equal(actual.textContent, diff.content[0].ab[1]);
+
+      actual = builder.getContentByLine(2, 'right');
+      assert.equal(actual.textContent, diff.content[0].ab[1]);
+
+      actual = builder.getContentByLine(5, 'left');
+      assert.equal(actual.textContent, diff.content[2].ab[0]);
+
+      actual = builder.getContentByLine(5, 'right');
+      assert.equal(actual.textContent, diff.content[1].b[0]);
+    });
+
+    test('getContentTdByLineEl works both with button and td', () => {
+      const diffRow = element.diffElement.querySelectorAll('tr.diff-row')[2];
+
+      const lineNumTdLeft = diffRow.querySelector('td.lineNum.left');
+      const lineNumButtonLeft = lineNumTdLeft.querySelector('button');
+      const contentTdLeft = diffRow.querySelectorAll('.content')[0];
+
+      const lineNumTdRight = diffRow.querySelector('td.lineNum.right');
+      const lineNumButtonRight = lineNumTdRight.querySelector('button');
+      const contentTdRight = diffRow.querySelectorAll('.content')[1];
+
+      assert.equal(element.getContentTdByLineEl(lineNumTdLeft), contentTdLeft);
+      assert.equal(
+          element.getContentTdByLineEl(lineNumButtonLeft), contentTdLeft);
+      assert.equal(
+          element.getContentTdByLineEl(lineNumTdRight), contentTdRight);
+      assert.equal(
+          element.getContentTdByLineEl(lineNumButtonRight), contentTdRight);
+    });
+
+    test('findLinesByRange', () => {
+      const lines = [];
+      const elems = [];
+      const start = 6;
+      const end = 10;
+      const count = end - start + 1;
+
+      builder.findLinesByRange(start, end, 'right', lines, elems);
+
+      assert.equal(lines.length, count);
+      assert.equal(elems.length, count);
+
+      for (let i = 0; i < 5; i++) {
+        assert.instanceOf(lines[i], GrDiffLine);
+        assert.equal(lines[i].afterNumber, start + i);
+        assert.instanceOf(elems[i], HTMLElement);
+        assert.equal(lines[i].text, elems[i].textContent);
+      }
+    });
+
+    test('_renderContentByRange', () => {
+      const spy = sinon.spy(builder, '_createTextEl');
+      const start = 9;
+      const end = 14;
+      const count = end - start + 1;
+
+      builder._renderContentByRange(start, end, 'left');
+
+      assert.equal(spy.callCount, count);
+      spy.getCalls().forEach((call, i) => {
+        assert.equal(call.args[1].beforeNumber, start + i);
+      });
+    });
+
+    test('_renderContentByRange notexistent elements', () => {
+      const spy = sinon.spy(builder, '_createTextEl');
+
+      sinon.stub(builder, 'findLinesByRange').callsFake(
+          (s, e, d, lines, elements) => {
+            // Add a line and a corresponding element.
+            lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+            const tr = document.createElement('tr');
+            const td = document.createElement('td');
+            const el = document.createElement('div');
+            tr.appendChild(td);
+            td.appendChild(el);
+            elements.push(el);
+
+            // Add 2 lines without corresponding elements.
+            lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+            lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+          });
+
+      builder._renderContentByRange(1, 10, 'left');
+      // Should be called only once because only one line had a corresponding
+      // element.
+      assert.equal(spy.callCount, 1);
+    });
+
+    test('_getLineNumberEl side-by-side left', () => {
+      const contentEl = builder.getContentByLine(5, 'left',
+          element.$.diffTable);
+      const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
+      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl.classList.contains('left'));
+    });
+
+    test('_getLineNumberEl side-by-side right', () => {
+      const contentEl = builder.getContentByLine(5, 'right',
+          element.$.diffTable);
+      const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
+      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl.classList.contains('right'));
+    });
+
+    test('_getLineNumberEl unified left', done => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+
+        const contentEl = builder.getContentByLine(5, 'left',
+            element.$.diffTable);
+        const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
+        assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl.classList.contains('left'));
+        done();
+      });
+    });
+
+    test('_getLineNumberEl unified right', done => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+
+        const contentEl = builder.getContentByLine(5, 'right',
+            element.$.diffTable);
+        const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
+        assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl.classList.contains('right'));
+        done();
+      });
+    });
+
+    test('_getNextContentOnSide side-by-side left', () => {
+      const startElem = builder.getContentByLine(5, 'left',
+          element.$.diffTable);
+      const expectedStartString = diff.content[2].ab[0];
+      const expectedNextString = diff.content[2].ab[1];
+      assert.equal(startElem.textContent, expectedStartString);
+
+      const nextElem = builder._getNextContentOnSide(startElem,
+          'left');
+      assert.equal(nextElem.textContent, expectedNextString);
+    });
+
+    test('_getNextContentOnSide side-by-side right', () => {
+      const startElem = builder.getContentByLine(5, 'right',
+          element.$.diffTable);
+      const expectedStartString = diff.content[1].b[0];
+      const expectedNextString = diff.content[1].b[1];
+      assert.equal(startElem.textContent, expectedStartString);
+
+      const nextElem = builder._getNextContentOnSide(startElem,
+          'right');
+      assert.equal(nextElem.textContent, expectedNextString);
+    });
+
+    test('_getNextContentOnSide unified left', done => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+
+        const startElem = builder.getContentByLine(5, 'left',
+            element.$.diffTable);
+        const expectedStartString = diff.content[2].ab[0];
+        const expectedNextString = diff.content[2].ab[1];
+        assert.equal(startElem.textContent, expectedStartString);
+
+        const nextElem = builder._getNextContentOnSide(startElem,
+            'left');
+        assert.equal(nextElem.textContent, expectedNextString);
+
+        done();
+      });
+    });
+
+    test('_getNextContentOnSide unified right', done => {
+      // Re-render as unified:
+      element.viewMode = 'UNIFIED_DIFF';
+      element.render(keyLocations, prefs).then(() => {
+        builder = element._builder;
+
+        const startElem = builder.getContentByLine(5, 'right',
+            element.$.diffTable);
+        const expectedStartString = diff.content[1].b[0];
+        const expectedNextString = diff.content[1].b[1];
+        assert.equal(startElem.textContent, expectedStartString);
+
+        const nextElem = builder._getNextContentOnSide(startElem,
+            'right');
+        assert.equal(nextElem.textContent, expectedNextString);
+
+        done();
+      });
+    });
+
+    test('escaping HTML', () => {
+      let input = '<script>alert("XSS");<' + '/script>';
+      let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
+      let result = builder._formatText(input, 1, Infinity).innerHTML;
+      assert.equal(result, expected);
+
+      input = '& < > " \' / `';
+      expected = '&amp; &lt; &gt; " \' / `';
+      result = builder._formatText(input, 1, Infinity).innerHTML;
+      assert.equal(result, expected);
+    });
+  });
+
+  suite('blame', () => {
+    let mockBlame;
+
+    setup(() => {
+      mockBlame = [
+        {id: 'commit 1', ranges: [{start: 1, end: 2}, {start: 10, end: 16}]},
+        {id: 'commit 2', ranges: [{start: 4, end: 10}, {start: 17, end: 32}]},
+      ];
+    });
+
+    test('setBlame attempts to render each blamed line', () => {
+      const getBlameStub = sinon.stub(builder, '_getBlameByLineNum')
+          .returns(null);
+      builder.setBlame(mockBlame);
+      assert.equal(getBlameStub.callCount, 32);
+    });
+
+    test('_getBlameCommitForBaseLine', () => {
+      builder.setBlame(mockBlame);
+      assert.isOk(builder._getBlameCommitForBaseLine(1));
+      assert.equal(builder._getBlameCommitForBaseLine(1).id, 'commit 1');
+
+      assert.isOk(builder._getBlameCommitForBaseLine(11));
+      assert.equal(builder._getBlameCommitForBaseLine(11).id, 'commit 1');
+
+      assert.isOk(builder._getBlameCommitForBaseLine(32));
+      assert.equal(builder._getBlameCommitForBaseLine(32).id, 'commit 2');
+
+      assert.isNull(builder._getBlameCommitForBaseLine(33));
+    });
+
+    test('_getBlameCommitForBaseLine w/o blame returns null', () => {
+      assert.isNull(builder._getBlameCommitForBaseLine(1));
+      assert.isNull(builder._getBlameCommitForBaseLine(11));
+      assert.isNull(builder._getBlameCommitForBaseLine(31));
+    });
+
+    test('_createBlameCell', () => {
+      const mocbBlameCell = document.createElement('span');
+      const getBlameStub = sinon.stub(builder, '_getBlameForBaseLine')
+          .returns(mocbBlameCell);
+      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+
+      const result = builder._createBlameCell(line.beforeNumber);
+
+      assert.isTrue(getBlameStub.calledWithExactly(3));
+      assert.equal(result.getAttribute('data-line-number'), '3');
+      assert.equal(result.firstChild, mocbBlameCell);
+    });
+
+    test('_getBlameForBaseLine', () => {
+      const mockCommit = {
+        time: 1576105200,
+        id: 1234567890,
+        author: 'Clark Kent',
+        commit_msg: 'Testing Commit',
+        ranges: [1],
+      };
+      const blameNode = builder._getBlameForBaseLine(1, mockCommit);
+
+      const authors = blameNode.getElementsByClassName('blameAuthor');
+      assert.equal(authors.length, 1);
+      assert.equal(authors[0].innerText, ' Clark');
+
+      const date = (new Date(mockCommit.time * 1000)).toLocaleDateString();
+      flush();
+      const cards = blameNode.getElementsByClassName('blameHoverCard');
+      assert.equal(cards.length, 1);
+      assert.equal(cards[0].innerHTML,
+          `Commit 1234567890<br>Author: Clark Kent<br>Date: ${date}`
+        + '<br><br>Testing Commit'
+      );
+
+      const url = blameNode.getElementsByClassName('blameDate');
+      assert.equal(url[0].getAttribute('href'), '/r/q/1234567890');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index 1fc0d4f..6983af0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -16,6 +16,7 @@
  */
 
 import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 
 // MIME types for images we allow showing. Do not include SVG, it can contain
 // arbitrary JavaScript.
@@ -52,7 +53,7 @@
   // column limit.
   td.setAttribute('colspan', '4');
   const endpoint = this._createElement('gr-endpoint-decorator');
-  const endpointDomApi = Polymer.dom(endpoint);
+  const endpointDomApi = dom(endpoint);
   endpointDomApi.setAttribute('name', 'image-diff');
   endpointDomApi.appendChild(
       this._createEndpointParam('baseImage', this._baseImage));
@@ -106,7 +107,7 @@
 
 GrDiffBuilderImage.prototype._updateImageLabel = function(section, className,
     image) {
-  const label = Polymer.dom(section)
+  const label = dom(section)
       .querySelector('.' + className + ' span.label');
   this._setLabelText(label, image);
 };
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index 8b73936..87a5283 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -16,6 +16,7 @@
  */
 
 import {GrDiffBuilder} from './gr-diff-builder.js';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 
 /** @constructor */
 export function GrDiffBuilderSideBySide(diff, prefs, outputEl, layers) {
@@ -36,6 +37,12 @@
   if (group.ignoredWhitespaceOnly) {
     sectionEl.classList.add('ignoredWhitespaceOnly');
   }
+  if (group.type === GrDiffGroup.Type.CONTEXT_CONTROL) {
+    sectionEl.appendChild(
+        this._createContextRow(sectionEl, group.contextGroups));
+    return sectionEl;
+  }
+
   const pairs = group.getSideBySidePairs();
   for (let i = 0; i < pairs.length; i++) {
     sectionEl.appendChild(this._createRow(sectionEl, pairs[i].left,
@@ -79,7 +86,7 @@
   row.setAttribute('right-type', rightLine.type);
   row.tabIndex = -1;
 
-  row.appendChild(this._createBlameCell(leftLine));
+  row.appendChild(this._createBlameCell(leftLine.beforeNumber));
 
   this._appendPair(section, row, leftLine, leftLine.beforeNumber,
       GrDiffBuilder.Side.LEFT);
@@ -92,13 +99,23 @@
     lineNumber, side) {
   const lineNumberEl = this._createLineEl(line, lineNumber, line.type, side);
   row.appendChild(lineNumberEl);
-  const action = this._createContextControl(section, line);
-  if (action) {
-    row.appendChild(action);
-  } else {
-    const textEl = this._createTextEl(lineNumberEl, line, side);
-    row.appendChild(textEl);
-  }
+  row.appendChild(this._createTextEl(lineNumberEl, line, side));
+};
+
+GrDiffBuilderSideBySide.prototype._createContextRow = function(section,
+    contextGroups) {
+  const row = this._createElement('tr');
+  row.classList.add('diff-row', 'side-by-side');
+  row.setAttribute('left-type', GrDiffGroup.Type.CONTEXT_CONTROL);
+  row.setAttribute('right-type', GrDiffGroup.Type.CONTEXT_CONTROL);
+  row.tabIndex = -1;
+
+  row.appendChild(this._createBlameCell(0));
+  row.appendChild(this._createElement('td', 'contextLineNum'));
+  row.appendChild(this._createContextControl(section, contextGroups));
+  row.appendChild(this._createElement('td', 'contextLineNum'));
+  row.appendChild(this._createContextControl(section, contextGroups));
+  return row;
 };
 
 GrDiffBuilderSideBySide.prototype._getNextContentOnSide = function(
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index 8163176..f2ee2a1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -16,6 +16,7 @@
  */
 import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
 import {GrDiffBuilder} from './gr-diff-builder.js';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 
 export function GrDiffBuilderUnified(diff, prefs, outputEl, layers) {
   GrDiffBuilder.call(this, diff, prefs, outputEl, layers);
@@ -35,6 +36,11 @@
   if (group.ignoredWhitespaceOnly) {
     sectionEl.classList.add('ignoredWhitespaceOnly');
   }
+  if (group.type === GrDiffGroup.Type.CONTEXT_CONTROL) {
+    sectionEl.appendChild(
+        this._createContextRow(sectionEl, group.contextGroups));
+    return sectionEl;
+  }
 
   for (let i = 0; i < group.lines.length; ++i) {
     const line = group.lines[i];
@@ -76,22 +82,26 @@
   const row = this._createElement('tr', line.type);
   row.classList.add('diff-row', 'unified');
   row.tabIndex = -1;
-  row.appendChild(this._createBlameCell(line));
-
+  row.appendChild(this._createBlameCell(line.beforeNumber));
   let lineNumberEl = this._createLineEl(line, line.beforeNumber,
       GrDiffLine.Type.REMOVE, 'left');
   row.appendChild(lineNumberEl);
   lineNumberEl = this._createLineEl(line, line.afterNumber,
       GrDiffLine.Type.ADD, 'right');
   row.appendChild(lineNumberEl);
+  row.appendChild(this._createTextEl(lineNumberEl, line));
+  return row;
+};
 
-  const action = this._createContextControl(section, line);
-  if (action) {
-    row.appendChild(action);
-  } else {
-    const textEl = this._createTextEl(lineNumberEl, line);
-    row.appendChild(textEl);
-  }
+GrDiffBuilderUnified.prototype._createContextRow = function(section,
+    contextGroups) {
+  const row = this._createElement('tr', GrDiffGroup.Type.CONTEXT_CONTROL);
+  row.classList.add('diff-row', 'unified');
+  row.tabIndex = -1;
+  row.appendChild(this._createBlameCell(0));
+  row.appendChild(this._createElement('td', 'contextLineNum'));
+  row.appendChild(this._createElement('td', 'contextLineNum'));
+  row.appendChild(this._createContextControl(section, contextGroups));
   return row;
 };
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html
deleted file mode 100644
index 2d26667..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.html
+++ /dev/null
@@ -1,205 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>GrDiffBuilderUnified</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../gr-diff/gr-diff-group.js';
-import './gr-diff-builder.js';
-import './gr-diff-builder-unified.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
-
-suite('GrDiffBuilderUnified tests', () => {
-  let prefs;
-  let outputEl;
-  let diffBuilder;
-
-  setup(()=> {
-    prefs = {
-      line_length: 10,
-      show_tabs: true,
-      tab_size: 4,
-    };
-    outputEl = document.createElement('div');
-    diffBuilder = new GrDiffBuilderUnified({}, prefs, outputEl, []);
-  });
-
-  suite('buildSectionElement for BOTH group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLine.Type.BOTH, 1, 2),
-        new GrDiffLine(GrDiffLine.Type.BOTH, 2, 3),
-        new GrDiffLine(GrDiffLine.Type.BOTH, 3, 4),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World";';
-      lines[2].text = '  return True';
-
-      group = new GrDiffGroup(GrDiffGroup.Type.BOTH, lines);
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('both'));
-    });
-
-    test('creates each unchanged row once', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 3);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[0].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[1].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.left').textContent,
-          lines[2].beforeNumber);
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-    });
-  });
-
-  suite('buildSectionElement for DELTA group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLine.Type.REMOVE, 1),
-        new GrDiffLine(GrDiffLine.Type.REMOVE, 2),
-        new GrDiffLine(GrDiffLine.Type.ADD, 2),
-        new GrDiffLine(GrDiffLine.Type.ADD, 3),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      lines[2].text = 'def hello_universe()';
-      lines[3].text = '  print "Hello Universe"';
-
-      group = new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('delta'));
-    });
-
-    test('creates the section with class if ignoredWhitespaceOnly', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
-    });
-
-    test('creates the section with class if dueToRebase', () => {
-      group.dueToRebase = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
-    });
-
-    test('creates first the removed and then the added rows', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 4);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[3].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[3].querySelector('.content').textContent, lines[3].text);
-    });
-
-    test('creates only the added rows if only ignored whitespace', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 2);
-
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[3].text);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
new file mode 100644
index 0000000..7346bf7
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
@@ -0,0 +1,194 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-diff/gr-diff-group.js';
+import './gr-diff-builder.js';
+import './gr-diff-builder-unified.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
+
+suite('GrDiffBuilderUnified tests', () => {
+  let prefs;
+  let outputEl;
+  let diffBuilder;
+
+  setup(()=> {
+    prefs = {
+      line_length: 10,
+      show_tabs: true,
+      tab_size: 4,
+    };
+    outputEl = document.createElement('div');
+    diffBuilder = new GrDiffBuilderUnified({}, prefs, outputEl, []);
+  });
+
+  suite('buildSectionElement for BOTH group', () => {
+    let lines;
+    let group;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLine.Type.BOTH, 1, 2),
+        new GrDiffLine(GrDiffLine.Type.BOTH, 2, 3),
+        new GrDiffLine(GrDiffLine.Type.BOTH, 3, 4),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World";';
+      lines[2].text = '  return True';
+
+      group = new GrDiffGroup(GrDiffGroup.Type.BOTH, lines);
+    });
+
+    test('creates the section', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('both'));
+    });
+
+    test('creates each unchanged row once', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 3);
+
+      assert.equal(
+          rowEls[0].querySelector('.lineNum.left').textContent,
+          lines[0].beforeNumber);
+      assert.equal(
+          rowEls[0].querySelector('.lineNum.right').textContent,
+          lines[0].afterNumber);
+      assert.equal(
+          rowEls[0].querySelector('.content').textContent, lines[0].text);
+
+      assert.equal(
+          rowEls[1].querySelector('.lineNum.left').textContent,
+          lines[1].beforeNumber);
+      assert.equal(
+          rowEls[1].querySelector('.lineNum.right').textContent,
+          lines[1].afterNumber);
+      assert.equal(
+          rowEls[1].querySelector('.content').textContent, lines[1].text);
+
+      assert.equal(
+          rowEls[2].querySelector('.lineNum.left').textContent,
+          lines[2].beforeNumber);
+      assert.equal(
+          rowEls[2].querySelector('.lineNum.right').textContent,
+          lines[2].afterNumber);
+      assert.equal(
+          rowEls[2].querySelector('.content').textContent, lines[2].text);
+    });
+  });
+
+  suite('buildSectionElement for DELTA group', () => {
+    let lines;
+    let group;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLine.Type.REMOVE, 1),
+        new GrDiffLine(GrDiffLine.Type.REMOVE, 2),
+        new GrDiffLine(GrDiffLine.Type.ADD, 2),
+        new GrDiffLine(GrDiffLine.Type.ADD, 3),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      lines[2].text = 'def hello_universe()';
+      lines[3].text = '  print "Hello Universe"';
+
+      group = new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
+    });
+
+    test('creates the section', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('delta'));
+    });
+
+    test('creates the section with class if ignoredWhitespaceOnly', () => {
+      group.ignoredWhitespaceOnly = true;
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
+    });
+
+    test('creates the section with class if dueToRebase', () => {
+      group.dueToRebase = true;
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
+    });
+
+    test('creates first the removed and then the added rows', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 4);
+
+      assert.equal(
+          rowEls[0].querySelector('.lineNum.left').textContent,
+          lines[0].beforeNumber);
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
+      assert.equal(
+          rowEls[0].querySelector('.content').textContent, lines[0].text);
+
+      assert.equal(
+          rowEls[1].querySelector('.lineNum.left').textContent,
+          lines[1].beforeNumber);
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
+      assert.equal(
+          rowEls[1].querySelector('.content').textContent, lines[1].text);
+
+      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
+      assert.equal(
+          rowEls[2].querySelector('.lineNum.right').textContent,
+          lines[2].afterNumber);
+      assert.equal(
+          rowEls[2].querySelector('.content').textContent, lines[2].text);
+
+      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
+      assert.equal(
+          rowEls[3].querySelector('.lineNum.right').textContent,
+          lines[3].afterNumber);
+      assert.equal(
+          rowEls[3].querySelector('.content').textContent, lines[3].text);
+    });
+
+    test('creates only the added rows if only ignored whitespace', () => {
+      group.ignoredWhitespaceOnly = true;
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 2);
+
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
+      assert.equal(
+          rowEls[0].querySelector('.lineNum.right').textContent,
+          lines[2].afterNumber);
+      assert.equal(
+          rowEls[0].querySelector('.content').textContent, lines[2].text);
+
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
+      assert.equal(
+          rowEls[1].querySelector('.lineNum.right').textContent,
+          lines[3].afterNumber);
+      assert.equal(
+          rowEls[1].querySelector('.content').textContent, lines[3].text);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index 948002a..e6d8ddb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -14,9 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 
 /**
  * In JS, unicode code points above 0xFFFF occupy two elements of a string.
@@ -42,6 +43,11 @@
 
 export function GrDiffBuilder(diff, prefs, outputEl, layers) {
   this._diff = diff;
+  this._numLinesLeft = this._diff.content ? this._diff.content.reduce(
+      (sum, chunk) => {
+        const left = chunk.a || chunk.ab;
+        return sum + (left ? left.length : 0);
+      }, 0) : 0;
   this._prefs = prefs;
   this._outputEl = outputEl;
   this.groups = [];
@@ -57,13 +63,22 @@
     throw Error('Invalid line length from preferences.');
   }
 
+  this._layerUpdateListener = this._handleLayerUpdate.bind(this);
   for (const layer of this.layers) {
     if (layer.addListener) {
-      layer.addListener(this._handleLayerUpdate.bind(this));
+      layer.addListener(this._layerUpdateListener);
     }
   }
 }
 
+GrDiffBuilder.prototype.clear = function() {
+  for (const layer of this.layers) {
+    if (layer.removeListener) {
+      layer.removeListener(this._layerUpdateListener);
+    }
+  }
+};
+
 GrDiffBuilder.GroupType = {
   ADDED: 'b',
   BOTH: 'ab',
@@ -141,12 +156,18 @@
   return groups;
 };
 
-GrDiffBuilder.prototype.getContentByLine = function(lineNumber, opt_side,
-    opt_root) {
-  const root = Polymer.dom(opt_root || this._outputEl);
+GrDiffBuilder.prototype.getContentTdByLine = function(
+    lineNumber, opt_side, opt_root) {
+  const root = dom(opt_root || this._outputEl);
   const sideSelector = opt_side ? ('.' + opt_side) : '';
   return root.querySelector('td.lineNum[data-value="' + lineNumber +
-      '"]' + sideSelector + ' ~ td.content .contentText');
+    '"]' + sideSelector + ' ~ td.content');
+};
+
+GrDiffBuilder.prototype.getContentByLine = function(
+    lineNumber, opt_side, opt_root) {
+  return this.getContentTdByLine(lineNumber, opt_side, opt_root)
+      .querySelector('.contentText');
 };
 
 /**
@@ -219,36 +240,41 @@
       group => group.element);
 };
 
-GrDiffBuilder.prototype._createContextControl = function(section, line) {
-  if (!line.contextGroups) return null;
+GrDiffBuilder.prototype._createContextControl = function(
+    section, contextGroups) {
+  if (!contextGroups) return null;
 
-  const numLines =
-      line.contextGroups[line.contextGroups.length - 1].lineRange.left.end -
-      line.contextGroups[0].lineRange.left.start + 1;
+  const leftStart = contextGroups[0].lineRange.left.start;
+  const leftEnd =
+      contextGroups[contextGroups.length - 1].lineRange.left.end;
+
+  const numLines = leftEnd - leftStart + 1;
 
   if (numLines === 0) return null;
 
   const td = this._createElement('td');
   const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
 
-  if (showPartialLinks) {
+  if (showPartialLinks && leftStart > 1) {
     td.appendChild(this._createContextButton(
-        GrDiffBuilder.ContextButtonType.ABOVE, section, line, numLines));
+        GrDiffBuilder.ContextButtonType.ABOVE, section, contextGroups,
+        numLines));
   }
 
   td.appendChild(this._createContextButton(
-      GrDiffBuilder.ContextButtonType.ALL, section, line, numLines));
+      GrDiffBuilder.ContextButtonType.ALL, section, contextGroups, numLines));
 
-  if (showPartialLinks) {
+  if (showPartialLinks && leftEnd < this._numLinesLeft) {
     td.appendChild(this._createContextButton(
-        GrDiffBuilder.ContextButtonType.BELOW, section, line, numLines));
+        GrDiffBuilder.ContextButtonType.BELOW, section, contextGroups,
+        numLines));
   }
 
   return td;
 };
 
-GrDiffBuilder.prototype._createContextButton = function(type, section, line,
-    numLines) {
+GrDiffBuilder.prototype._createContextButton = function(
+    type, section, contextGroups, numLines) {
   const context = PARTIAL_CONTEXT_AMOUNT;
 
   const button = this._createElement('gr-button', 'showContext');
@@ -260,23 +286,23 @@
   if (type === GrDiffBuilder.ContextButtonType.ALL) {
     const icon = this._createElement('iron-icon', 'showContext');
     icon.setAttribute('icon', 'gr-icons:unfold-more');
-    Polymer.dom(button).appendChild(icon);
+    dom(button).appendChild(icon);
 
     text = 'Show ' + numLines + ' common line';
     if (numLines > 1) { text += 's'; }
-    groups.push(...line.contextGroups);
+    groups.push(...contextGroups);
   } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
     text = '+' + context + ' above';
-    groups = GrDiffGroup.hideInContextControl(line.contextGroups,
-        context, numLines);
+    groups = GrDiffGroup.hideInContextControl(
+        contextGroups, context, numLines);
   } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
     text = '+' + context + ' below';
-    groups = GrDiffGroup.hideInContextControl(line.contextGroups,
-        0, numLines - context);
+    groups = GrDiffGroup.hideInContextControl(
+        contextGroups, 0, numLines - context);
   }
   const textSpan = this._createElement('span', 'showContext');
-  Polymer.dom(textSpan).textContent = text;
-  Polymer.dom(button).appendChild(textSpan);
+  dom(textSpan).textContent = text;
+  dom(button).appendChild(textSpan);
 
   button.addEventListener('tap', e => {
     e.detail = {
@@ -296,21 +322,21 @@
   if (line.type === GrDiffLine.Type.BLANK) {
     return td;
   }
-  if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
-    td.classList.add('contextLineNum');
-    return td;
-  }
-
   if (line.type === GrDiffLine.Type.BOTH || line.type === type) {
-    const button = this._createElement('button');
-    button.tabIndex = -1;
-    td.appendChild(button);
-
     // Both td and button need a number of classes/attributes for various
     // selectors to work.
     this._decorateLineEl(td, number, side);
     td.classList.add('lineNum');
+
+    if (this._prefs.show_file_comment_button === false && number === 'FILE') {
+      return td;
+    }
+
+    const button = this._createElement('button');
+    td.appendChild(button);
+    button.tabIndex = -1;
     this._decorateLineEl(button, number, side);
+
     button.classList.add('lineNumButton');
 
     button.textContent = number === 'FILE' ? 'File' : number;
@@ -350,22 +376,26 @@
   }
   td.classList.add(line.type);
 
-  const lineLimit =
-      !this._prefs.line_wrapping ? this._prefs.line_length : Infinity;
+  if (line.beforeNumber !== 'FILE') {
+    const lineLimit =
+        !this._prefs.line_wrapping ? this._prefs.line_length : Infinity;
+    const contentText =
+        this._formatText(line.text, this._prefs.tab_size, lineLimit);
 
-  const contentText =
-      this._formatText(line.text, this._prefs.tab_size, lineLimit);
-  if (opt_side) {
-    contentText.setAttribute('data-side', opt_side);
-  }
-
-  for (const layer of this.layers) {
-    if (typeof layer.annotate == 'function') {
-      layer.annotate(contentText, lineNumberEl, line);
+    if (opt_side) {
+      contentText.setAttribute('data-side', opt_side);
     }
-  }
 
-  td.appendChild(contentText);
+    for (const layer of this.layers) {
+      if (typeof layer.annotate == 'function') {
+        layer.annotate(contentText, lineNumberEl, line);
+      }
+    }
+
+    td.appendChild(contentText);
+  } else {
+    td.classList.add('file');
+  }
 
   return td;
 };
@@ -534,7 +564,7 @@
  * @return {HTMLTableDataCellElement}
  */
 GrDiffBuilder.prototype._getBlameByLineNum = function(lineNum) {
-  const root = Polymer.dom(this._outputEl);
+  const root = dom(this._outputEl);
   return root.querySelector(`td.blame[data-line-number="${lineNum}"]`);
 };
 
@@ -580,8 +610,7 @@
 
   const shaNode = this._createElement('a', 'blameDate');
   shaNode.innerText = `${date}`;
-  shaNode.setAttribute('href',
-      `${BaseUrlBehavior.getBaseUrl()}/q/${commit.id}`);
+  shaNode.setAttribute('href', `${getBaseUrl()}/q/${commit.id}`);
   blameNode.appendChild(shaNode);
 
   const shortName = commit.author.split(' ')[0];
@@ -607,14 +636,14 @@
  * Create a blame cell for the given base line. Blame information will be
  * included in the cell if available.
  *
- * @param {GrDiffLine} line
+ * @param {number} lineNumber
  * @return {HTMLTableDataCellElement}
  */
-GrDiffBuilder.prototype._createBlameCell = function(line) {
+GrDiffBuilder.prototype._createBlameCell = function(lineNumber) {
   const blameTd = this._createElement('td', 'blame');
-  blameTd.setAttribute('data-line-number', line.beforeNumber);
-  if (line.beforeNumber) {
-    const content = this._getBlameForBaseLine(line.beforeNumber);
+  blameTd.setAttribute('data-line-number', lineNumber);
+  if (lineNumber) {
+    const content = this._getBlameForBaseLine(lineNumber);
     if (content) {
       blameTd.appendChild(content);
     }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index 0b9ae5b..9d68ba3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 
 import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
@@ -23,6 +22,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-diff-cursor_html.js';
+import {ScrollMode} from '../../../constants/constants.js';
 
 const DiffSides = {
   LEFT: 'left',
@@ -34,15 +34,10 @@
   UNIFIED: 'UNIFIED_DIFF',
 };
 
-const ScrollBehavior = {
-  KEEP_VISIBLE: 'keep-visible',
-  NEVER: 'never',
-};
-
 const LEFT_SIDE_CLASS = 'target-side-left';
 const RIGHT_SIDE_CLASS = 'target-side-right';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrDiffCursor extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
   static get template() { return htmlTemplate; }
@@ -93,9 +88,9 @@
        * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
        * the viewport.
        */
-      _scrollBehavior: {
+      _scrollMode: {
         type: String,
-        value: ScrollBehavior.KEEP_VISIBLE,
+        value: ScrollMode.KEEP_VISIBLE,
       },
 
       _focusOnMove: {
@@ -207,9 +202,10 @@
     }
   }
 
-  moveToNextChunk(opt_clipToTop) {
+  moveToNextChunk(opt_clipToTop, opt_navigateToNextFile) {
     this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
-        target => target.parentNode.scrollHeight, opt_clipToTop);
+        target => target.parentNode.scrollHeight, opt_clipToTop,
+        opt_navigateToNextFile);
     this._fixSide();
   }
 
@@ -294,10 +290,9 @@
   reInitCursor() {
     if (!this.diffRow) {
       // does not scroll during init unless requested
-      const scrollingBehaviorForInit = this.initialLineNumber ?
-        ScrollBehavior.KEEP_VISIBLE :
-        ScrollBehavior.NEVER;
-      this._scrollBehavior = scrollingBehaviorForInit;
+      this._scrollMode = this.initialLineNumber ?
+        ScrollMode.KEEP_VISIBLE :
+        ScrollMode.NEVER;
       if (this.initialLineNumber) {
         this.moveToLineNumber(this.initialLineNumber, this.side);
         this.initialLineNumber = null;
@@ -309,17 +304,22 @@
   }
 
   reInit() {
-    this._scrollBehavior = ScrollBehavior.KEEP_VISIBLE;
+    this._scrollMode = ScrollMode.KEEP_VISIBLE;
   }
 
   _handleWindowScroll() {
     if (this._preventAutoScrollOnManualScroll) {
-      this._scrollBehavior = ScrollBehavior.NEVER;
+      this._scrollMode = ScrollMode.NEVER;
       this._focusOnMove = false;
       this._preventAutoScrollOnManualScroll = false;
     }
   }
 
+  reInitAndUpdateStops() {
+    this.reInit();
+    this._updateStops();
+  }
+
   handleDiffUpdate() {
     this._updateStops();
     this.reInitCursor();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.js
deleted file mode 100644
index 1ac47f6..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <gr-cursor-manager
-    id="cursorManager"
-    scroll-behavior="[[_scrollBehavior]]"
-    cursor-target-class="target-row"
-    focus-on-move="[[_focusOnMove]]"
-    target="{{diffRow}}"
-    scroll-top-margin="[[scrollTopMargin]]"
-  ></gr-cursor-manager>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
new file mode 100644
index 0000000..712a93d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <gr-cursor-manager
+    id="cursorManager"
+    scroll-mode="[[_scrollMode]]"
+    cursor-target-class="target-row"
+    focus-on-move="[[_focusOnMove]]"
+    target="{{diffRow}}"
+    scroll-top-margin="[[scrollTopMargin]]"
+  ></gr-cursor-manager>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
deleted file mode 100644
index 77e5179..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ /dev/null
@@ -1,430 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-diff-cursor</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff></gr-diff>
-    <gr-diff-cursor></gr-diff-cursor>
-    <gr-rest-api-interface></gr-rest-api-interface>
-  </template>
-</test-fixture>
-
-<test-fixture id="empty">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../gr-diff/gr-diff.js';
-import './gr-diff-cursor.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {getMockDiffResponse} from '../../../test/mock-diff-response.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-diff-cursor tests', () => {
-  let sandbox;
-  let cursorElement;
-  let diffElement;
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-
-    const fixtureElems = fixture('basic');
-    diffElement = fixtureElems[0];
-    cursorElement = fixtureElems[1];
-    const restAPI = fixtureElems[2];
-
-    // Register the diff with the cursor.
-    cursorElement.push('diffs', diffElement);
-
-    diffElement.loggedIn = false;
-    diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
-    diffElement.comments = {
-      left: [],
-      right: [],
-      meta: {patchRange: undefined},
-    };
-    const setupDone = () => {
-      cursorElement._updateStops();
-      cursorElement.moveToFirstChunk();
-      diffElement.removeEventListener('render', setupDone);
-      done();
-    };
-    diffElement.addEventListener('render', setupDone);
-
-    restAPI.getDiffPreferences().then(prefs => {
-      diffElement.prefs = prefs;
-      diffElement.diff = getMockDiffResponse();
-    });
-  });
-
-  teardown(() => sandbox.restore());
-
-  test('diff cursor functionality (side-by-side)', () => {
-    // The cursor has been initialized to the first delta.
-    assert.isOk(cursorElement.diffRow);
-
-    const firstDeltaRow = diffElement.shadowRoot
-        .querySelector('.section.delta .diff-row');
-    assert.equal(cursorElement.diffRow, firstDeltaRow);
-
-    cursorElement.moveDown();
-
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
-
-    cursorElement.moveUp();
-
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
-    assert.equal(cursorElement.diffRow, firstDeltaRow);
-  });
-
-  test('moveToLastChunk', () => {
-    const chunks = Array.from(dom(diffElement.root).querySelectorAll(
-        '.section.delta'));
-    assert.isAbove(chunks.length, 1);
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
-
-    cursorElement.moveToLastChunk();
-
-    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement),
-        chunks.length - 1);
-  });
-
-  test('cursor scroll behavior', () => {
-    assert.equal(cursorElement._scrollBehavior, 'keep-visible');
-
-    cursorElement._handleDiffRenderStart();
-    assert.isTrue(cursorElement._focusOnMove);
-
-    cursorElement._handleWindowScroll();
-    assert.equal(cursorElement._scrollBehavior, 'never');
-    assert.isFalse(cursorElement._focusOnMove);
-
-    cursorElement._handleDiffRenderContent();
-    assert.isTrue(cursorElement._focusOnMove);
-
-    cursorElement.reInitCursor();
-    assert.equal(cursorElement._scrollBehavior, 'keep-visible');
-  });
-
-  test('moves to selected line', () => {
-    const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
-
-    cursorElement._handleDiffLineSelected(
-        new CustomEvent('line-selected', {
-          detail: {number: '123', side: 'right', path: 'some/file'},
-        }));
-
-    assert.isTrue(moveToNumStub.called);
-    assert.equal(moveToNumStub.lastCall.args[0], '123');
-    assert.equal(moveToNumStub.lastCall.args[1], 'right');
-    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
-  });
-
-  suite('unified diff', () => {
-    setup(done => {
-      // We must allow the diff to re-render after setting the viewMode.
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursorElement.reInitCursor();
-        done();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.viewMode = 'UNIFIED_DIFF';
-    });
-
-    test('diff cursor functionality (unified)', () => {
-      // The cursor has been initialized to the first delta.
-      assert.isOk(cursorElement.diffRow);
-
-      let firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
-
-      firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
-
-      cursorElement.moveDown();
-
-      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-      assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
-
-      cursorElement.moveUp();
-
-      assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
-    });
-  });
-
-  test('cursor side functionality', () => {
-    // The side only applies to side-by-side mode, which should be the default
-    // mode.
-    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
-
-    const firstDeltaSection = diffElement.shadowRoot
-        .querySelector('.section.delta');
-    const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
-
-    // Because the first delta in this diff is on the right, it should be set
-    // to the right side.
-    assert.equal(cursorElement.side, 'right');
-    assert.equal(cursorElement.diffRow, firstDeltaRow);
-    const firstIndex = cursorElement.$.cursorManager.index;
-
-    // Move the side to the left. Because this delta only has a right side, we
-    // should be moved up to the previous line where there is content on the
-    // right. The previous row is part of the previous section.
-    cursorElement.moveLeft();
-
-    assert.equal(cursorElement.side, 'left');
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.equal(cursorElement.$.cursorManager.index, firstIndex - 1);
-    assert.equal(cursorElement.diffRow.parentElement,
-        firstDeltaSection.previousSibling);
-
-    // If we move down, we should skip everything in the first delta because
-    // we are on the left side and the first delta has no content on the left.
-    cursorElement.moveDown();
-
-    assert.equal(cursorElement.side, 'left');
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.isTrue(cursorElement.$.cursorManager.index > firstIndex);
-    assert.equal(cursorElement.diffRow.parentElement,
-        firstDeltaSection.nextSibling);
-  });
-
-  test('chunk skip functionality', () => {
-    const chunks = dom(diffElement.root).querySelectorAll(
-        '.section.delta');
-    const indexOfChunk = function(chunk) {
-      return Array.prototype.indexOf.call(chunks, chunk);
-    };
-
-    // We should be initialized to the first chunk. Since this chunk only has
-    // content on the right side, our side should be right.
-    let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
-    assert.equal(currentIndex, 0);
-    assert.equal(cursorElement.side, 'right');
-
-    // Move to the next chunk.
-    cursorElement.moveToNextChunk();
-
-    // Since this chunk only has content on the left side. we should have been
-    // automatically mvoed over.
-    const previousIndex = currentIndex;
-    currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
-    assert.equal(currentIndex, previousIndex + 1);
-    assert.equal(cursorElement.side, 'left');
-  });
-
-  test('initialLineNumber not provided', done => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
-    const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk',
-        () => { scrollBehaviorDuringMove = cursorElement._scrollBehavior; });
-
-    function renderHandler() {
-      diffElement.removeEventListener('render', renderHandler);
-      cursorElement.reInitCursor();
-      assert.isFalse(moveToNumStub.called);
-      assert.isTrue(moveToChunkStub.called);
-      assert.equal(scrollBehaviorDuringMove, 'never');
-      assert.equal(cursorElement._scrollBehavior, 'keep-visible');
-      done();
-    }
-    diffElement.addEventListener('render', renderHandler);
-    diffElement._diffChanged(getMockDiffResponse());
-  });
-
-  test('initialLineNumber provided', done => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber',
-        () => { scrollBehaviorDuringMove = cursorElement._scrollBehavior; });
-    const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk');
-    function renderHandler() {
-      diffElement.removeEventListener('render', renderHandler);
-      cursorElement.reInitCursor();
-      assert.isFalse(moveToChunkStub.called);
-      assert.isTrue(moveToNumStub.called);
-      assert.equal(moveToNumStub.lastCall.args[0], 10);
-      assert.equal(moveToNumStub.lastCall.args[1], 'right');
-      assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-      assert.equal(cursorElement._scrollBehavior, 'keep-visible');
-      done();
-    }
-    diffElement.addEventListener('render', renderHandler);
-    cursorElement.initialLineNumber = 10;
-    cursorElement.side = 'right';
-
-    diffElement._diffChanged(getMockDiffResponse());
-  });
-
-  test('getTargetDiffElement', () => {
-    cursorElement.initialLineNumber = 1;
-    assert.isTrue(!!cursorElement.diffRow);
-    assert.equal(
-        cursorElement.getTargetDiffElement(),
-        diffElement
-    );
-  });
-
-  suite('createCommentInPlace', () => {
-    setup(() => {
-      diffElement.loggedIn = true;
-    });
-
-    test('adds new draft for selected line on the left', done => {
-      cursorElement.moveToLineNumber(2, 'left');
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side, patchNum} = e.detail;
-        assert.equal(lineNum, 2);
-        assert.equal(range, undefined);
-        assert.equal(patchNum, 1);
-        assert.equal(side, 'left');
-        done();
-      });
-      cursorElement.createCommentInPlace();
-    });
-
-    test('adds draft for selected line on the right', done => {
-      cursorElement.moveToLineNumber(4, 'right');
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side, patchNum} = e.detail;
-        assert.equal(lineNum, 4);
-        assert.equal(range, undefined);
-        assert.equal(patchNum, 2);
-        assert.equal(side, 'right');
-        done();
-      });
-      cursorElement.createCommentInPlace();
-    });
-
-    test('createCommentInPlace creates comment for range if selected', done => {
-      const someRange = {
-        start_line: 2,
-        start_character: 3,
-        end_line: 6,
-        end_character: 1,
-      };
-      diffElement.$.highlights.selectedRange = {
-        side: 'right',
-        range: someRange,
-      };
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side, patchNum} = e.detail;
-        assert.equal(lineNum, 6);
-        assert.equal(range, someRange);
-        assert.equal(patchNum, 2);
-        assert.equal(side, 'right');
-        done();
-      });
-      cursorElement.createCommentInPlace();
-    });
-
-    test('createCommentInPlace ignores call if nothing is selected', () => {
-      const createRangeCommentStub = sandbox.stub(diffElement,
-          'createRangeComment');
-      const addDraftAtLineStub = sandbox.stub(diffElement, 'addDraftAtLine');
-      cursorElement.diffRow = undefined;
-      cursorElement.createCommentInPlace();
-      assert.isFalse(createRangeCommentStub.called);
-      assert.isFalse(addDraftAtLineStub.called);
-    });
-  });
-
-  test('getAddress', () => {
-    // It should initialize to the first chunk: line 5 of the revision.
-    assert.deepEqual(cursorElement.getAddress(),
-        {leftSide: false, number: 5});
-
-    // Revision line 4 is up.
-    cursorElement.moveUp();
-    assert.deepEqual(cursorElement.getAddress(),
-        {leftSide: false, number: 4});
-
-    // Base line 4 is left.
-    cursorElement.moveLeft();
-    assert.deepEqual(cursorElement.getAddress(), {leftSide: true, number: 4});
-
-    // Moving to the next chunk takes it back to the start.
-    cursorElement.moveToNextChunk();
-    assert.deepEqual(cursorElement.getAddress(),
-        {leftSide: false, number: 5});
-
-    // The following chunk is a removal starting on line 10 of the base.
-    cursorElement.moveToNextChunk();
-    assert.deepEqual(cursorElement.getAddress(),
-        {leftSide: true, number: 10});
-
-    // Should be null if there is no selection.
-    cursorElement.$.cursorManager.unsetCursor();
-    assert.isNotOk(cursorElement.getAddress());
-  });
-
-  test('_findRowByNumberAndFile', () => {
-    // Get the first ab row after the first chunk.
-    const row = dom(diffElement.root).querySelectorAll('tr')[8];
-
-    // It should be line 8 on the right, but line 5 on the left.
-    assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
-    assert.equal(cursorElement._findRowByNumberAndFile(5, 'left'), row);
-  });
-
-  test('expand context updates stops', done => {
-    sandbox.spy(cursorElement, '_updateStops');
-    MockInteractions.tap(diffElement.shadowRoot
-        .querySelector('.showContext'));
-    flush(() => {
-      assert.isTrue(cursorElement._updateStops.called);
-      done();
-    });
-  });
-
-  suite('gr-diff-cursor event tests', () => {
-    let sandbox;
-    let someEmptyDiv;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      someEmptyDiv = fixture('empty');
-    });
-
-    teardown(() => sandbox.restore());
-
-    test('ready is fired after component is rendered', done => {
-      const cursorElement = document.createElement('gr-diff-cursor');
-      cursorElement.addEventListener('ready', () => {
-        done();
-      });
-      someEmptyDiv.appendChild(cursorElement);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
new file mode 100644
index 0000000..4ca75eb
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -0,0 +1,425 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-diff/gr-diff.js';
+import './gr-diff-cursor.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+  <gr-diff></gr-diff>
+  <gr-diff-cursor></gr-diff-cursor>
+  <gr-rest-api-interface></gr-rest-api-interface>
+`);
+
+const emptyFixture = fixtureFromElement('div');
+
+suite('gr-diff-cursor tests', () => {
+  let cursorElement;
+  let diffElement;
+
+  setup(done => {
+    const fixtureElems = basicFixture.instantiate();
+    diffElement = fixtureElems[0];
+    cursorElement = fixtureElems[1];
+    const restAPI = fixtureElems[2];
+
+    // Register the diff with the cursor.
+    cursorElement.push('diffs', diffElement);
+
+    diffElement.loggedIn = false;
+    diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
+    diffElement.comments = {
+      left: [],
+      right: [],
+      meta: {patchRange: undefined},
+    };
+    const setupDone = () => {
+      cursorElement._updateStops();
+      cursorElement.moveToFirstChunk();
+      diffElement.removeEventListener('render', setupDone);
+      done();
+    };
+    diffElement.addEventListener('render', setupDone);
+
+    restAPI.getDiffPreferences().then(prefs => {
+      diffElement.prefs = prefs;
+      diffElement.diff = getMockDiffResponse();
+    });
+  });
+
+  test('diff cursor functionality (side-by-side)', () => {
+    // The cursor has been initialized to the first delta.
+    assert.isOk(cursorElement.diffRow);
+
+    const firstDeltaRow = diffElement.shadowRoot
+        .querySelector('.section.delta .diff-row');
+    assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+    cursorElement.moveDown();
+
+    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+    assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+
+    cursorElement.moveUp();
+
+    assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
+    assert.equal(cursorElement.diffRow, firstDeltaRow);
+  });
+
+  test('moveToLastChunk', () => {
+    const chunks = Array.from(dom(diffElement.root).querySelectorAll(
+        '.section.delta'));
+    assert.isAbove(chunks.length, 1);
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
+
+    cursorElement.moveToLastChunk();
+
+    assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement),
+        chunks.length - 1);
+  });
+
+  test('cursor scroll behavior', () => {
+    assert.equal(cursorElement._scrollMode, 'keep-visible');
+
+    cursorElement._handleDiffRenderStart();
+    assert.isTrue(cursorElement._focusOnMove);
+
+    cursorElement._handleWindowScroll();
+    assert.equal(cursorElement._scrollMode, 'never');
+    assert.isFalse(cursorElement._focusOnMove);
+
+    cursorElement._handleDiffRenderContent();
+    assert.isTrue(cursorElement._focusOnMove);
+
+    cursorElement.reInitCursor();
+    assert.equal(cursorElement._scrollMode, 'keep-visible');
+  });
+
+  test('moves to selected line', () => {
+    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
+
+    cursorElement._handleDiffLineSelected(
+        new CustomEvent('line-selected', {
+          detail: {number: '123', side: 'right', path: 'some/file'},
+        }));
+
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], '123');
+    assert.equal(moveToNumStub.lastCall.args[1], 'right');
+    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
+  });
+
+  suite('unified diff', () => {
+    setup(done => {
+      // We must allow the diff to re-render after setting the viewMode.
+      const renderHandler = function() {
+        diffElement.removeEventListener('render', renderHandler);
+        cursorElement.reInitCursor();
+        done();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.viewMode = 'UNIFIED_DIFF';
+    });
+
+    test('diff cursor functionality (unified)', () => {
+      // The cursor has been initialized to the first delta.
+      assert.isOk(cursorElement.diffRow);
+
+      let firstDeltaRow = diffElement.shadowRoot
+          .querySelector('.section.delta .diff-row');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+      firstDeltaRow = diffElement.shadowRoot
+          .querySelector('.section.delta .diff-row');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+      cursorElement.moveDown();
+
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+
+      cursorElement.moveUp();
+
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+    });
+  });
+
+  test('cursor side functionality', () => {
+    // The side only applies to side-by-side mode, which should be the default
+    // mode.
+    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
+
+    const firstDeltaSection = diffElement.shadowRoot
+        .querySelector('.section.delta');
+    const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
+
+    // Because the first delta in this diff is on the right, it should be set
+    // to the right side.
+    assert.equal(cursorElement.side, 'right');
+    assert.equal(cursorElement.diffRow, firstDeltaRow);
+    const firstIndex = cursorElement.$.cursorManager.index;
+
+    // Move the side to the left. Because this delta only has a right side, we
+    // should be moved up to the previous line where there is content on the
+    // right. The previous row is part of the previous section.
+    cursorElement.moveLeft();
+
+    assert.equal(cursorElement.side, 'left');
+    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+    assert.equal(cursorElement.$.cursorManager.index, firstIndex - 1);
+    assert.equal(cursorElement.diffRow.parentElement,
+        firstDeltaSection.previousSibling);
+
+    // If we move down, we should skip everything in the first delta because
+    // we are on the left side and the first delta has no content on the left.
+    cursorElement.moveDown();
+
+    assert.equal(cursorElement.side, 'left');
+    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+    assert.isTrue(cursorElement.$.cursorManager.index > firstIndex);
+    assert.equal(cursorElement.diffRow.parentElement,
+        firstDeltaSection.nextSibling);
+  });
+
+  test('chunk skip functionality', () => {
+    const chunks = dom(diffElement.root).querySelectorAll(
+        '.section.delta');
+    const indexOfChunk = function(chunk) {
+      return Array.prototype.indexOf.call(chunks, chunk);
+    };
+
+    // We should be initialized to the first chunk. Since this chunk only has
+    // content on the right side, our side should be right.
+    let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+    assert.equal(currentIndex, 0);
+    assert.equal(cursorElement.side, 'right');
+
+    // Move to the next chunk.
+    cursorElement.moveToNextChunk();
+
+    // Since this chunk only has content on the left side. we should have been
+    // automatically moved over.
+    const previousIndex = currentIndex;
+    currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+    assert.equal(currentIndex, previousIndex + 1);
+    assert.equal(cursorElement.side, 'left');
+  });
+
+  test('navigate to next unreviewed file via moveToNextChunk', () => {
+    const cursor = cursorElement.shadowRoot.querySelector('#cursorManager');
+    cursor.index = cursor.stops.length - 1;
+    const dispatchEventStub = sinon.stub(cursor, 'dispatchEvent');
+    cursorElement.moveToNextChunk(/* opt_clipToTop = */false,
+        /* opt_navigateToNextFile = */true);
+    assert.isTrue(dispatchEventStub.called);
+    assert.equal(dispatchEventStub.getCall(0).args[0].type, 'show-alert');
+
+    cursorElement.moveToNextChunk(/* opt_clipToTop = */false,
+        /* opt_navigateToNextFile = */true);
+    assert.equal(dispatchEventStub.getCall(1).args[0].type,
+        'navigate-to-next-unreviewed-file');
+  });
+
+  test('initialLineNumber not provided', done => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
+    const moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk')
+        .callsFake(
+            () => { scrollBehaviorDuringMove = cursorElement._scrollMode; });
+
+    function renderHandler() {
+      diffElement.removeEventListener('render', renderHandler);
+      cursorElement.reInitCursor();
+      assert.isFalse(moveToNumStub.called);
+      assert.isTrue(moveToChunkStub.called);
+      assert.equal(scrollBehaviorDuringMove, 'never');
+      assert.equal(cursorElement._scrollMode, 'keep-visible');
+      done();
+    }
+    diffElement.addEventListener('render', renderHandler);
+    diffElement._diffChanged(getMockDiffResponse());
+  });
+
+  test('initialLineNumber provided', done => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber')
+        .callsFake(
+            () => { scrollBehaviorDuringMove = cursorElement._scrollMode; });
+    const moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk');
+    function renderHandler() {
+      diffElement.removeEventListener('render', renderHandler);
+      cursorElement.reInitCursor();
+      assert.isFalse(moveToChunkStub.called);
+      assert.isTrue(moveToNumStub.called);
+      assert.equal(moveToNumStub.lastCall.args[0], 10);
+      assert.equal(moveToNumStub.lastCall.args[1], 'right');
+      assert.equal(scrollBehaviorDuringMove, 'keep-visible');
+      assert.equal(cursorElement._scrollMode, 'keep-visible');
+      done();
+    }
+    diffElement.addEventListener('render', renderHandler);
+    cursorElement.initialLineNumber = 10;
+    cursorElement.side = 'right';
+
+    diffElement._diffChanged(getMockDiffResponse());
+  });
+
+  test('getTargetDiffElement', () => {
+    cursorElement.initialLineNumber = 1;
+    assert.isTrue(!!cursorElement.diffRow);
+    assert.equal(
+        cursorElement.getTargetDiffElement(),
+        diffElement
+    );
+  });
+
+  suite('createCommentInPlace', () => {
+    setup(() => {
+      diffElement.loggedIn = true;
+    });
+
+    test('adds new draft for selected line on the left', done => {
+      cursorElement.moveToLineNumber(2, 'left');
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side, patchNum} = e.detail;
+        assert.equal(lineNum, 2);
+        assert.equal(range, undefined);
+        assert.equal(patchNum, 1);
+        assert.equal(side, 'left');
+        done();
+      });
+      cursorElement.createCommentInPlace();
+    });
+
+    test('adds draft for selected line on the right', done => {
+      cursorElement.moveToLineNumber(4, 'right');
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side, patchNum} = e.detail;
+        assert.equal(lineNum, 4);
+        assert.equal(range, undefined);
+        assert.equal(patchNum, 2);
+        assert.equal(side, 'right');
+        done();
+      });
+      cursorElement.createCommentInPlace();
+    });
+
+    test('createCommentInPlace creates comment for range if selected', done => {
+      const someRange = {
+        start_line: 2,
+        start_character: 3,
+        end_line: 6,
+        end_character: 1,
+      };
+      diffElement.$.highlights.selectedRange = {
+        side: 'right',
+        range: someRange,
+      };
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side, patchNum} = e.detail;
+        assert.equal(lineNum, 6);
+        assert.equal(range, someRange);
+        assert.equal(patchNum, 2);
+        assert.equal(side, 'right');
+        done();
+      });
+      cursorElement.createCommentInPlace();
+    });
+
+    test('createCommentInPlace ignores call if nothing is selected', () => {
+      const createRangeCommentStub = sinon.stub(diffElement,
+          'createRangeComment');
+      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
+      cursorElement.diffRow = undefined;
+      cursorElement.createCommentInPlace();
+      assert.isFalse(createRangeCommentStub.called);
+      assert.isFalse(addDraftAtLineStub.called);
+    });
+  });
+
+  test('getAddress', () => {
+    // It should initialize to the first chunk: line 5 of the revision.
+    assert.deepEqual(cursorElement.getAddress(),
+        {leftSide: false, number: 5});
+
+    // Revision line 4 is up.
+    cursorElement.moveUp();
+    assert.deepEqual(cursorElement.getAddress(),
+        {leftSide: false, number: 4});
+
+    // Base line 4 is left.
+    cursorElement.moveLeft();
+    assert.deepEqual(cursorElement.getAddress(), {leftSide: true, number: 4});
+
+    // Moving to the next chunk takes it back to the start.
+    cursorElement.moveToNextChunk();
+    assert.deepEqual(cursorElement.getAddress(),
+        {leftSide: false, number: 5});
+
+    // The following chunk is a removal starting on line 10 of the base.
+    cursorElement.moveToNextChunk();
+    assert.deepEqual(cursorElement.getAddress(),
+        {leftSide: true, number: 10});
+
+    // Should be null if there is no selection.
+    cursorElement.$.cursorManager.unsetCursor();
+    assert.isNotOk(cursorElement.getAddress());
+  });
+
+  test('_findRowByNumberAndFile', () => {
+    // Get the first ab row after the first chunk.
+    const row = dom(diffElement.root).querySelectorAll('tr')[8];
+
+    // It should be line 8 on the right, but line 5 on the left.
+    assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
+    assert.equal(cursorElement._findRowByNumberAndFile(5, 'left'), row);
+  });
+
+  test('expand context updates stops', done => {
+    sinon.spy(cursorElement, '_updateStops');
+    MockInteractions.tap(diffElement.shadowRoot
+        .querySelector('.showContext'));
+    flush(() => {
+      assert.isTrue(cursorElement._updateStops.called);
+      done();
+    });
+  });
+
+  suite('gr-diff-cursor event tests', () => {
+    let someEmptyDiv;
+
+    setup(() => {
+      someEmptyDiv = emptyFixture.instantiate();
+    });
+
+    teardown(() => sinon.restore());
+
+    test('ready is fired after component is rendered', done => {
+      const cursorElement = document.createElement('gr-diff-cursor');
+      cursorElement.addEventListener('ready', () => {
+        done();
+      });
+      someEmptyDiv.appendChild(cursorElement);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
index 4006d13..c86760a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
@@ -14,6 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {sanitizeDOMValue} from '@polymer/polymer/lib/utils/settings.js';
 
 // TODO(wyatta): refactor this to be <MARK> rather than <HL>.
 const ANNOTATION_TAG = 'HL';
@@ -84,7 +86,7 @@
     }
 
     const wrapper = document.createElement(tagName);
-    const sanitizer = window.Polymer.sanitizeDOMValue;
+    const sanitizer = sanitizeDOMValue;
     for (const [name, value] of Object.entries(attributes)) {
       wrapper.setAttribute(
           name, sanitizer ?
@@ -149,8 +151,8 @@
     } else {
       hl = document.createElement(ANNOTATION_TAG);
       hl.className = cssClass;
-      Polymer.dom(node.parentElement).replaceChild(hl, node);
-      Polymer.dom(hl).appendChild(node);
+      dom(node.parentElement).replaceChild(hl, node);
+      dom(hl).appendChild(node);
     }
     return hl;
   },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
deleted file mode 100644
index 2bda950..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
+++ /dev/null
@@ -1,298 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-annotation</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import {GrAnnotation} from './gr-annotation.js';
-import {sanitizeDOMValue, setSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings.js';
-suite('annotation', () => {
-  let str;
-  let parent;
-  let textNode;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    parent = fixture('basic');
-    textNode = parent.childNodes[0];
-    str = textNode.textContent;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_annotateText Case 1', () => {
-    GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 1);
-    assert.instanceOf(parent.childNodes[0], HTMLElement);
-    assert.equal(parent.childNodes[0].className, 'foobar');
-    assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
-    assert.equal(parent.childNodes[0].childNodes[0].textContent, str);
-  });
-
-  test('_annotateText Case 2', () => {
-    const length = 12;
-    const substr = str.substr(0, length);
-    const remainder = str.substr(length);
-
-    GrAnnotation._annotateText(textNode, 0, length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 2);
-
-    assert.instanceOf(parent.childNodes[0], HTMLElement);
-    assert.equal(parent.childNodes[0].className, 'foobar');
-    assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
-    assert.equal(parent.childNodes[0].childNodes[0].textContent, substr);
-
-    assert.instanceOf(parent.childNodes[1], Text);
-    assert.equal(parent.childNodes[1].textContent, remainder);
-  });
-
-  test('_annotateText Case 3', () => {
-    const index = 12;
-    const length = str.length - index;
-    const remainder = str.substr(0, index);
-    const substr = str.substr(index);
-
-    GrAnnotation._annotateText(textNode, index, length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 2);
-
-    assert.instanceOf(parent.childNodes[0], Text);
-    assert.equal(parent.childNodes[0].textContent, remainder);
-
-    assert.instanceOf(parent.childNodes[1], HTMLElement);
-    assert.equal(parent.childNodes[1].className, 'foobar');
-    assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
-    assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
-  });
-
-  test('_annotateText Case 4', () => {
-    const index = str.indexOf('dolor');
-    const length = 'dolor '.length;
-
-    const remainderPre = str.substr(0, index);
-    const substr = str.substr(index, length);
-    const remainderPost = str.substr(index + length);
-
-    GrAnnotation._annotateText(textNode, index, length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 3);
-
-    assert.instanceOf(parent.childNodes[0], Text);
-    assert.equal(parent.childNodes[0].textContent, remainderPre);
-
-    assert.instanceOf(parent.childNodes[1], HTMLElement);
-    assert.equal(parent.childNodes[1].className, 'foobar');
-    assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
-    assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
-
-    assert.instanceOf(parent.childNodes[2], Text);
-    assert.equal(parent.childNodes[2].textContent, remainderPost);
-  });
-
-  test('_annotateElement design doc example', () => {
-    const layers = [
-      'amet, ',
-      'inceptos ',
-      'amet, ',
-      'et, suspendisse ince',
-    ];
-
-    // Apply the layers successively.
-    layers.forEach((layer, i) => {
-      GrAnnotation.annotateElement(
-          parent, str.indexOf(layer), layer.length, `layer-${i + 1}`);
-    });
-
-    assert.equal(parent.textContent, str);
-
-    // Layer 1:
-    const layer1 = parent.querySelectorAll('.layer-1');
-    assert.equal(layer1.length, 1);
-    assert.equal(layer1[0].textContent, layers[0]);
-    assert.equal(layer1[0].parentElement, parent);
-
-    // Layer 2:
-    const layer2 = parent.querySelectorAll('.layer-2');
-    assert.equal(layer2.length, 1);
-    assert.equal(layer2[0].textContent, layers[1]);
-    assert.equal(layer2[0].parentElement, parent);
-
-    // Layer 3:
-    const layer3 = parent.querySelectorAll('.layer-3');
-    assert.equal(layer3.length, 1);
-    assert.equal(layer3[0].textContent, layers[2]);
-    assert.equal(layer3[0].parentElement, layer1[0]);
-
-    // Layer 4:
-    const layer4 = parent.querySelectorAll('.layer-4');
-    assert.equal(layer4.length, 3);
-
-    assert.equal(layer4[0].textContent, 'et, ');
-    assert.equal(layer4[0].parentElement, layer3[0]);
-
-    assert.equal(layer4[1].textContent, 'suspendisse ');
-    assert.equal(layer4[1].parentElement, parent);
-
-    assert.equal(layer4[2].textContent, 'ince');
-    assert.equal(layer4[2].parentElement, layer2[0]);
-
-    assert.equal(layer4[0].textContent +
-        layer4[1].textContent +
-        layer4[2].textContent,
-    layers[3]);
-  });
-
-  test('splitTextNode', () => {
-    const helloString = 'hello';
-    const asciiString = 'ASCII';
-    const unicodeString = 'Unic💢de';
-
-    let node;
-    let tail;
-
-    // Non-unicode path:
-    node = document.createTextNode(helloString + asciiString);
-    tail = GrAnnotation.splitTextNode(node, helloString.length);
-    assert(node.textContent, helloString);
-    assert(tail.textContent, asciiString);
-
-    // Unicdoe path:
-    node = document.createTextNode(helloString + unicodeString);
-    tail = GrAnnotation.splitTextNode(node, helloString.length);
-    assert(node.textContent, helloString);
-    assert(tail.textContent, unicodeString);
-  });
-
-  suite('annotateWithElement', () => {
-    const fullText = '01234567890123456789';
-    let mockSanitize;
-    let originalSanitizeDOMValue;
-
-    setup(() => {
-      originalSanitizeDOMValue = sanitizeDOMValue;
-      assert.isDefined(originalSanitizeDOMValue);
-      mockSanitize = sandbox.spy(originalSanitizeDOMValue);
-      setSanitizeDOMValue(mockSanitize);
-    });
-
-    teardown(() => {
-      setSanitizeDOMValue(originalSanitizeDOMValue);
-    });
-
-    test('annotates when fully contained', () => {
-      const length = 10;
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      GrAnnotation.annotateWithElement(
-          container, 1, length, {tagName: 'test-wrapper'});
-
-      assert.equal(
-          container.innerHTML,
-          '0<test-wrapper>1234567890</test-wrapper>123456789');
-    });
-
-    test('annotates when spanning multiple nodes', () => {
-      const length = 10;
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      GrAnnotation.annotateElement(container, 5, length, 'testclass');
-      GrAnnotation.annotateWithElement(
-          container, 1, length, {tagName: 'test-wrapper'});
-
-      assert.equal(
-          container.innerHTML,
-          '0' +
-          '<test-wrapper>' +
-          '1234' +
-          '<hl class="testclass">567890</hl>' +
-          '</test-wrapper>' +
-          '<hl class="testclass">1234</hl>' +
-          '56789');
-    });
-
-    test('annotates text node', () => {
-      const length = 10;
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      GrAnnotation.annotateWithElement(
-          container.childNodes[0], 1, length, {tagName: 'test-wrapper'});
-
-      assert.equal(
-          container.innerHTML,
-          '0<test-wrapper>1234567890</test-wrapper>123456789');
-    });
-
-    test('handles zero-length nodes', () => {
-      const container = document.createElement('div');
-      container.appendChild(document.createTextNode('0123456789'));
-      container.appendChild(document.createElement('span'));
-      container.appendChild(document.createTextNode('0123456789'));
-      GrAnnotation.annotateWithElement(
-          container, 1, 10, {tagName: 'test-wrapper'});
-
-      assert.equal(
-          container.innerHTML,
-          '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789');
-    });
-
-    test('sets sanitized attributes', () => {
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      const attributes = {
-        'href': 'foo',
-        'data-foo': 'bar',
-        'class': 'hello world',
-      };
-      GrAnnotation.annotateWithElement(
-          container, 1, length, {tagName: 'test-wrapper', attributes});
-      assert(mockSanitize.calledWith(
-          'foo', 'href', 'attribute', sinon.match.instanceOf(Element)));
-      assert(mockSanitize.calledWith(
-          'bar', 'data-foo', 'attribute', sinon.match.instanceOf(Element)));
-      assert(mockSanitize.calledWith(
-          'hello world',
-          'class',
-          'attribute',
-          sinon.match.instanceOf(Element)));
-      const el = container.querySelector('test-wrapper');
-      assert.equal(el.getAttribute('href'), 'foo');
-      assert.equal(el.getAttribute('data-foo'), 'bar');
-      assert.equal(el.getAttribute('class'), 'hello world');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js
new file mode 100644
index 0000000..65f5e07
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js
@@ -0,0 +1,281 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const basicFixture = fixtureFromTemplate(html`
+<div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+`);
+
+import '../../../test/common-test-setup-karma.js';
+import {GrAnnotation} from './gr-annotation.js';
+import {sanitizeDOMValue, setSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+suite('annotation', () => {
+  let str;
+  let parent;
+  let textNode;
+
+  setup(() => {
+    parent = basicFixture.instantiate();
+    textNode = parent.childNodes[0];
+    str = textNode.textContent;
+  });
+
+  test('_annotateText Case 1', () => {
+    GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
+
+    assert.equal(parent.childNodes.length, 1);
+    assert.instanceOf(parent.childNodes[0], HTMLElement);
+    assert.equal(parent.childNodes[0].className, 'foobar');
+    assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
+    assert.equal(parent.childNodes[0].childNodes[0].textContent, str);
+  });
+
+  test('_annotateText Case 2', () => {
+    const length = 12;
+    const substr = str.substr(0, length);
+    const remainder = str.substr(length);
+
+    GrAnnotation._annotateText(textNode, 0, length, 'foobar');
+
+    assert.equal(parent.childNodes.length, 2);
+
+    assert.instanceOf(parent.childNodes[0], HTMLElement);
+    assert.equal(parent.childNodes[0].className, 'foobar');
+    assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
+    assert.equal(parent.childNodes[0].childNodes[0].textContent, substr);
+
+    assert.instanceOf(parent.childNodes[1], Text);
+    assert.equal(parent.childNodes[1].textContent, remainder);
+  });
+
+  test('_annotateText Case 3', () => {
+    const index = 12;
+    const length = str.length - index;
+    const remainder = str.substr(0, index);
+    const substr = str.substr(index);
+
+    GrAnnotation._annotateText(textNode, index, length, 'foobar');
+
+    assert.equal(parent.childNodes.length, 2);
+
+    assert.instanceOf(parent.childNodes[0], Text);
+    assert.equal(parent.childNodes[0].textContent, remainder);
+
+    assert.instanceOf(parent.childNodes[1], HTMLElement);
+    assert.equal(parent.childNodes[1].className, 'foobar');
+    assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
+    assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
+  });
+
+  test('_annotateText Case 4', () => {
+    const index = str.indexOf('dolor');
+    const length = 'dolor '.length;
+
+    const remainderPre = str.substr(0, index);
+    const substr = str.substr(index, length);
+    const remainderPost = str.substr(index + length);
+
+    GrAnnotation._annotateText(textNode, index, length, 'foobar');
+
+    assert.equal(parent.childNodes.length, 3);
+
+    assert.instanceOf(parent.childNodes[0], Text);
+    assert.equal(parent.childNodes[0].textContent, remainderPre);
+
+    assert.instanceOf(parent.childNodes[1], HTMLElement);
+    assert.equal(parent.childNodes[1].className, 'foobar');
+    assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
+    assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
+
+    assert.instanceOf(parent.childNodes[2], Text);
+    assert.equal(parent.childNodes[2].textContent, remainderPost);
+  });
+
+  test('_annotateElement design doc example', () => {
+    const layers = [
+      'amet, ',
+      'inceptos ',
+      'amet, ',
+      'et, suspendisse ince',
+    ];
+
+    // Apply the layers successively.
+    layers.forEach((layer, i) => {
+      GrAnnotation.annotateElement(
+          parent, str.indexOf(layer), layer.length, `layer-${i + 1}`);
+    });
+
+    assert.equal(parent.textContent, str);
+
+    // Layer 1:
+    const layer1 = parent.querySelectorAll('.layer-1');
+    assert.equal(layer1.length, 1);
+    assert.equal(layer1[0].textContent, layers[0]);
+    assert.equal(layer1[0].parentElement, parent);
+
+    // Layer 2:
+    const layer2 = parent.querySelectorAll('.layer-2');
+    assert.equal(layer2.length, 1);
+    assert.equal(layer2[0].textContent, layers[1]);
+    assert.equal(layer2[0].parentElement, parent);
+
+    // Layer 3:
+    const layer3 = parent.querySelectorAll('.layer-3');
+    assert.equal(layer3.length, 1);
+    assert.equal(layer3[0].textContent, layers[2]);
+    assert.equal(layer3[0].parentElement, layer1[0]);
+
+    // Layer 4:
+    const layer4 = parent.querySelectorAll('.layer-4');
+    assert.equal(layer4.length, 3);
+
+    assert.equal(layer4[0].textContent, 'et, ');
+    assert.equal(layer4[0].parentElement, layer3[0]);
+
+    assert.equal(layer4[1].textContent, 'suspendisse ');
+    assert.equal(layer4[1].parentElement, parent);
+
+    assert.equal(layer4[2].textContent, 'ince');
+    assert.equal(layer4[2].parentElement, layer2[0]);
+
+    assert.equal(layer4[0].textContent +
+        layer4[1].textContent +
+        layer4[2].textContent,
+    layers[3]);
+  });
+
+  test('splitTextNode', () => {
+    const helloString = 'hello';
+    const asciiString = 'ASCII';
+    const unicodeString = 'Unic💢de';
+
+    let node;
+    let tail;
+
+    // Non-unicode path:
+    node = document.createTextNode(helloString + asciiString);
+    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    assert(node.textContent, helloString);
+    assert(tail.textContent, asciiString);
+
+    // Unicdoe path:
+    node = document.createTextNode(helloString + unicodeString);
+    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    assert(node.textContent, helloString);
+    assert(tail.textContent, unicodeString);
+  });
+
+  suite('annotateWithElement', () => {
+    const fullText = '01234567890123456789';
+    let mockSanitize;
+    let originalSanitizeDOMValue;
+
+    setup(() => {
+      originalSanitizeDOMValue = sanitizeDOMValue;
+      assert.isDefined(originalSanitizeDOMValue);
+      mockSanitize = sinon.spy(originalSanitizeDOMValue);
+      setSanitizeDOMValue(mockSanitize);
+    });
+
+    teardown(() => {
+      setSanitizeDOMValue(originalSanitizeDOMValue);
+    });
+
+    test('annotates when fully contained', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateWithElement(
+          container, 1, length, {tagName: 'test-wrapper'});
+
+      assert.equal(
+          container.innerHTML,
+          '0<test-wrapper>1234567890</test-wrapper>123456789');
+    });
+
+    test('annotates when spanning multiple nodes', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateElement(container, 5, length, 'testclass');
+      GrAnnotation.annotateWithElement(
+          container, 1, length, {tagName: 'test-wrapper'});
+
+      assert.equal(
+          container.innerHTML,
+          '0' +
+          '<test-wrapper>' +
+          '1234' +
+          '<hl class="testclass">567890</hl>' +
+          '</test-wrapper>' +
+          '<hl class="testclass">1234</hl>' +
+          '56789');
+    });
+
+    test('annotates text node', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateWithElement(
+          container.childNodes[0], 1, length, {tagName: 'test-wrapper'});
+
+      assert.equal(
+          container.innerHTML,
+          '0<test-wrapper>1234567890</test-wrapper>123456789');
+    });
+
+    test('handles zero-length nodes', () => {
+      const container = document.createElement('div');
+      container.appendChild(document.createTextNode('0123456789'));
+      container.appendChild(document.createElement('span'));
+      container.appendChild(document.createTextNode('0123456789'));
+      GrAnnotation.annotateWithElement(
+          container, 1, 10, {tagName: 'test-wrapper'});
+
+      assert.equal(
+          container.innerHTML,
+          '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789');
+    });
+
+    test('sets sanitized attributes', () => {
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      const attributes = {
+        'href': 'foo',
+        'data-foo': 'bar',
+        'class': 'hello world',
+      };
+      GrAnnotation.annotateWithElement(
+          container, 1, length, {tagName: 'test-wrapper', attributes});
+      assert(mockSanitize.calledWith(
+          'foo', 'href', 'attribute', sinon.match.instanceOf(Element)));
+      assert(mockSanitize.calledWith(
+          'bar', 'data-foo', 'attribute', sinon.match.instanceOf(Element)));
+      assert(mockSanitize.calledWith(
+          'hello world',
+          'class',
+          'attribute',
+          sinon.match.instanceOf(Element)));
+      const el = container.querySelector('test-wrapper');
+      assert.equal(el.getAttribute('href'), 'foo');
+      assert.equal(el.getAttribute('data-foo'), 'bar');
+      assert.equal(el.getAttribute('class'), 'hello world');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index d655002..b82d4b8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../gr-selection-action-box/gr-selection-action-box.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
@@ -25,9 +23,10 @@
 import {htmlTemplate} from './gr-diff-highlight_html.js';
 import {GrAnnotation} from './gr-annotation.js';
 import {GrRangeNormalizer} from './gr-range-normalizer.js';
+import {strToClassName} from '../../../utils/dom-util.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrDiffHighlight extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -127,20 +126,27 @@
     // As gr-ranged-comment-layer now does not notify the layer re-render and
     // lack of access to the thread or the lineEl from the ranged-comment-layer,
     // need to update range class for styles here.
-    const currentLine = threadEl.assignedSlot.parentElement.previousSibling;
-    if (currentLine && currentLine.querySelector) {
+    let curNode = threadEl.assignedSlot;
+    while (curNode) {
+      if (curNode.nodeName === 'TABLE') break;
+      curNode = curNode.parentElement;
+    }
+    if (curNode && curNode.querySelectorAll) {
       if (highlightRange) {
-        const rangeNode = currentLine.querySelector('.range');
-        if (rangeNode) {
+        const rangeNodes = curNode
+            .querySelectorAll(`.range.${strToClassName(threadEl.rootId)}`);
+        rangeNodes.forEach(rangeNode => {
           rangeNode.classList.add('rangeHighlight');
           rangeNode.classList.remove('range');
-        }
+        });
       } else {
-        const rangeNode = currentLine.querySelector('.rangeHighlight');
-        if (rangeNode) {
+        const rangeNodes = curNode.querySelectorAll(
+            `.rangeHighlight.${strToClassName(threadEl.rootId)}`
+        );
+        rangeNodes.forEach(rangeNode => {
           rangeNode.classList.remove('rangeHighlight');
           rangeNode.classList.add('range');
-        }
+        });
       }
     }
   }
@@ -320,11 +326,8 @@
     if (!line) {
       return;
     }
-    const contentText = this.diffBuilder.getContentByLineEl(lineEl);
-    if (!contentText) {
-      return;
-    }
-    const contentTd = contentText.parentElement;
+    const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
+    const contentText = contentTd.querySelector('.contentText');
     if (!contentTd.contains(node)) {
       node = contentText;
       column = 0;
@@ -368,12 +371,9 @@
     }
     const start = range.start;
     const end = range.end;
-    if (start.side !== end.side ||
+    return !(start.side !== end.side ||
         end.line < start.line ||
-        (start.line === end.line && start.column === end.column)) {
-      return false;
-    }
-    return true;
+        (start.line === end.line && start.column === end.column));
   }
 
   _handleSelection(selection, isMouseUp) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.js
deleted file mode 100644
index 08b21499..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      position: relative;
-    }
-    gr-selection-action-box {
-      /**
-         * Needs z-index to apear above wrapped content, since it's inseted
-         * into DOM before it.
-         */
-      z-index: 10;
-    }
-  </style>
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.ts
new file mode 100644
index 0000000..5a6cb1c
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.ts
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      position: relative;
+    }
+    gr-selection-action-box {
+      /**
+         * Needs z-index to appear above wrapped content, since it's inserted
+         * into DOM before it.
+         */
+      z-index: 10;
+    }
+  </style>
+  <div class="contentWrapper">
+    <slot></slot>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
deleted file mode 100644
index 86f1505..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ /dev/null
@@ -1,630 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-diff-highlight</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <style>
-      .tab-indicator:before {
-        color: #C62828;
-        /* >> character */
-        content: '\00BB';
-      }
-    </style>
-    <gr-diff-highlight>
-      <table id="diffTable">
-
-        <tbody class="section both">
-           <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">	</span>quod <hl>Epicurum</hl></div></td>
-            <td class="right lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">	</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-<tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="138"></td>
-            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="119"></td>
-            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="140"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">	</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
-                [Yet another random diff thread content here]
-            </div></td>
-            <td class="right lineNum" data-value="120"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">	</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="141"></td>
-            <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>complectitur<span class="tab-indicator" style="tab-size:8;">	</span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-            <td class="right lineNum" data-value="130"></td>
-            <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section contextControl">
-          <tr class="diff-row side-by-side" left-type="contextControl" right-type="contextControl">
-            <td class="left contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-            <td class="right contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta total">
-          <tr class="diff-row side-by-side" left-type="blank" right-type="add">
-            <td class="left"></td>
-            <td class="blank"></td>
-            <td class="right lineNum" data-value="146"></td>
-            <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="165"></td>
-            <td class="content both"><div class="contentText"></div></td>
-            <td class="right lineNum" data-value="147"></td>
-            <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
-          </tr>
-        </tbody>
-
-      </table>
-    </gr-diff-highlight>
-  </template>
-</test-fixture>
-
-<test-fixture id="highlighted">
-  <template>
-    <div>
-      <hl class="rangeHighlight">foo</hl>
-      bar
-      <hl class="rangeHighlight">baz</hl>
-    </div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-diff-highlight.js';
-import {GrRangeNormalizer} from './gr-range-normalizer.js';
-
-suite('gr-diff-highlight', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic')[1];
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('comment events', () => {
-    let builder;
-
-    setup(() => {
-      builder = {
-        getContentsByLineRange: sandbox.stub().returns([]),
-        getLineElByChild: sandbox.stub().returns({}),
-        getSideByLineEl: sandbox.stub().returns('other-side'),
-      };
-      element._cachedDiffBuilder = builder;
-    });
-
-    test('comment-thread-mouseenter from line comments is ignored', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right'}];
-
-      sandbox.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseenter', {bubbles: true, composed: true}));
-      assert.isFalse(element.set.called);
-    });
-
-    test('comment-thread-mouseenter from ranged comment causes set', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      threadEl.setAttribute('range', JSON.stringify({
-        start_line: 3,
-        start_character: 4,
-        end_line: 5,
-        end_character: 6,
-      }));
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right', range: {
-        start_line: 3,
-        start_character: 4,
-        end_line: 5,
-        end_character: 6,
-      }}];
-
-      sandbox.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseenter', {bubbles: true, composed: true}));
-      assert.isTrue(element.set.called);
-      const args = element.set.lastCall.args;
-      assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
-      assert.deepEqual(args[1], true);
-    });
-
-    test('comment-thread-mouseleave from line comments is ignored', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right'}];
-
-      sandbox.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseleave', {bubbles: true, composed: true}));
-      assert.isFalse(element.set.called);
-    });
-
-    test(`create-range-comment for range when create-comment-requested
-          is fired`, () => {
-      sandbox.stub(element, '_removeActionBox');
-      element.selectedRange = {
-        side: 'left',
-        range: {
-          start_line: 7,
-          start_character: 11,
-          end_line: 24,
-          end_character: 42,
-        },
-      };
-      const requestEvent = new CustomEvent('create-comment-requested');
-      let createRangeEvent;
-      element.addEventListener('create-range-comment', e => {
-        createRangeEvent = e;
-      });
-      element.dispatchEvent(requestEvent);
-      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
-      assert.isTrue(element._removeActionBox.called);
-    });
-  });
-
-  suite('selection', () => {
-    let diff;
-    let builder;
-    let contentStubs;
-
-    const stubContent = (line, side, opt_child) => {
-      const contentTd = diff.querySelector(
-          `.${side}.lineNum[data-value="${line}"] ~ .content`);
-      const contentText = contentTd.querySelector('.contentText');
-      const lineEl = diff.querySelector(
-          `.${side}.lineNum[data-value="${line}"]`);
-      contentStubs.push({
-        lineEl,
-        contentTd,
-        contentText,
-      });
-      builder.getContentByLineEl.withArgs(lineEl).returns(contentText);
-      builder.getLineNumberByChild.withArgs(lineEl).returns(line);
-      builder.getContentByLine.withArgs(line, side).returns(contentText);
-      builder.getSideByLineEl.withArgs(lineEl).returns(side);
-      return contentText;
-    };
-
-    const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
-      const selection = window.getSelection();
-      const range = document.createRange();
-      range.setStart(startNode, startOffset);
-      range.setEnd(endNode, endOffset);
-      selection.addRange(range);
-      element._handleSelection(selection);
-    };
-
-    const getLineElByChild = node => {
-      const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
-      return stubs && stubs.lineEl;
-    };
-
-    setup(() => {
-      contentStubs = [];
-      stub('gr-selection-action-box', {
-        placeAbove: sandbox.stub(),
-        placeBelow: sandbox.stub(),
-      });
-      diff = element.querySelector('#diffTable');
-      builder = {
-        getContentByLine: sandbox.stub(),
-        getContentByLineEl: sandbox.stub(),
-        getLineElByChild,
-        getLineNumberByChild: sandbox.stub(),
-        getSideByLineEl: sandbox.stub(),
-      };
-      element._cachedDiffBuilder = builder;
-    });
-
-    teardown(() => {
-      contentStubs = null;
-      window.getSelection().removeAllRanges();
-    });
-
-    test('single first line', () => {
-      const content = stubContent(1, 'right');
-      sandbox.spy(element, '_positionActionBox');
-      emulateSelection(content.firstChild, 5, content.firstChild, 12);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      assert.isTrue(actionBox.positionBelow);
-    });
-
-    test('multiline starting on first line', () => {
-      const startContent = stubContent(1, 'right');
-      const endContent = stubContent(2, 'right');
-      sandbox.spy(element, '_positionActionBox');
-      emulateSelection(
-          startContent.firstChild, 10, endContent.lastChild, 7);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      assert.isTrue(actionBox.positionBelow);
-    });
-
-    test('single line', () => {
-      const content = stubContent(138, 'left');
-      sandbox.spy(element, '_positionActionBox');
-      emulateSelection(content.firstChild, 5, content.firstChild, 12);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 138,
-        start_character: 5,
-        end_line: 138,
-        end_character: 12,
-      });
-      assert.equal(side, 'left');
-      assert.notOk(actionBox.positionBelow);
-    });
-
-    test('multiline', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      sandbox.spy(element, '_positionActionBox');
-      emulateSelection(
-          startContent.firstChild, 10, endContent.lastChild, 7);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 36,
-      });
-      assert.equal(side, 'right');
-      assert.notOk(actionBox.positionBelow);
-    });
-
-    test('multiple ranges aka firefox implementation', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-
-      const startRange = document.createRange();
-      startRange.setStart(startContent.firstChild, 10);
-      startRange.setEnd(startContent.firstChild, 11);
-
-      const endRange = document.createRange();
-      endRange.setStart(endContent.lastChild, 6);
-      endRange.setEnd(endContent.lastChild, 7);
-
-      const getRangeAtStub = sandbox.stub();
-      getRangeAtStub
-          .onFirstCall().returns(startRange)
-          .onSecondCall()
-          .returns(endRange);
-      const selection = {
-        rangeCount: 2,
-        getRangeAt: getRangeAtStub,
-        removeAllRanges: sandbox.stub(),
-      };
-      element._handleSelection(selection);
-      const {range} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 36,
-      });
-    });
-
-    test('multiline grow end highlight over tabs', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 2,
-      });
-      assert.equal(side, 'right');
-    });
-
-    test('collapsed', () => {
-      const content = stubContent(138, 'left');
-      emulateSelection(content.firstChild, 5, content.firstChild, 5);
-      assert.isOk(window.getSelection().getRangeAt(0).startContainer);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts inside hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelector('.foo');
-      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 8,
-        end_line: 140,
-        end_character: 23,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('ends inside hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelector('.bar');
-      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
-      const {range} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 18,
-        end_line: 140,
-        end_character: 27,
-      });
-    });
-
-    test('multiple hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelectorAll('hl')[4];
-      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 2,
-        end_line: 140,
-        end_character: 61,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts outside of diff', () => {
-      const contentText = stubContent(140, 'left');
-      const contentTd = contentText.parentElement;
-
-      emulateSelection(contentTd.previousElementSibling, 0,
-          contentText.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('ends outside of diff', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(content.nextElementSibling.firstChild, 2,
-          content.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts and ends on different sides', () => {
-      const startContent = stubContent(140, 'left');
-      const endContent = stubContent(130, 'right');
-      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts in comment thread element', () => {
-      const startContent = stubContent(140, 'left');
-      const comment = startContent.parentElement.querySelector(
-          '.comment-thread');
-      const endContent = stubContent(141, 'left');
-      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 83,
-        end_line: 141,
-        end_character: 4,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('ends in comment thread element', () => {
-      const content = stubContent(140, 'left');
-      const comment = content.parentElement.querySelector(
-          '.comment-thread');
-      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 4,
-        end_line: 140,
-        end_character: 83,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts in context element', () => {
-      const contextControl =
-          diff.querySelector('.contextControl').querySelector('gr-button');
-      const content = stubContent(146, 'right');
-      emulateSelection(contextControl, 0, content.firstChild, 7);
-      // TODO (viktard): Select nearest line.
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('ends in context element', () => {
-      const contextControl =
-          diff.querySelector('.contextControl').querySelector('gr-button');
-      const content = stubContent(141, 'left');
-      emulateSelection(content.firstChild, 2, contextControl, 1);
-      // TODO (viktard): Select nearest line.
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('selection containing context element', () => {
-      const startContent = stubContent(130, 'right');
-      const endContent = stubContent(146, 'right');
-      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 130,
-        start_character: 3,
-        end_line: 146,
-        end_character: 14,
-      });
-      assert.equal(side, 'right');
-    });
-
-    test('ends at a tab', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(
-          content.firstChild, 1, content.querySelector('span'), 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 1,
-        end_line: 140,
-        end_character: 51,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts at a tab', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(
-          content.querySelectorAll('hl')[3], 0,
-          content.querySelectorAll('span')[1].nextSibling, 1);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 51,
-        end_line: 140,
-        end_character: 71,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('properly accounts for syntax highlighting', () => {
-      const content = stubContent(140, 'left');
-      const spy = sinon.spy(element, '_normalizeRange');
-      emulateSelection(
-          content.querySelectorAll('hl')[3], 0,
-          content.querySelectorAll('span')[1], 0);
-      const spyCall = spy.getCall(0);
-      const range = window.getSelection().getRangeAt(0);
-      assert.notDeepEqual(spyCall.returnValue, range);
-    });
-
-    test('GrRangeNormalizer._getTextOffset computes text offset', () => {
-      let content = stubContent(140, 'left');
-      let child = content.lastChild.lastChild;
-      let result = GrRangeNormalizer._getTextOffset(content, child);
-      assert.equal(result, 75);
-      content = stubContent(146, 'right');
-      child = content.lastChild;
-      result = GrRangeNormalizer._getTextOffset(content, child);
-      assert.equal(result, 0);
-    });
-
-    test('_fixTripleClickSelection', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 0,
-        end_line: 119,
-        end_character: element._getLength(startContent),
-      });
-      assert.equal(side, 'right');
-    });
-
-    test('_fixTripleClickSelection empty line', () => {
-      const startContent = stubContent(146, 'right');
-      const endContent = stubContent(165, 'left');
-      emulateSelection(startContent.firstChild, 0,
-          endContent.parentElement.previousElementSibling, 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 146,
-        start_character: 0,
-        end_line: 146,
-        end_character: 84,
-      });
-      assert.equal(side, 'right');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
new file mode 100644
index 0000000..957d039
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
@@ -0,0 +1,606 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-diff-highlight.js';
+import {GrRangeNormalizer} from './gr-range-normalizer.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+// Splitting long lines in html into shorter rows breaks tests:
+// zero-length text nodes and new lines are not expected in some places
+/* eslint-disable max-len */
+const basicFixture = fixtureFromTemplate(html`
+<style>
+      .tab-indicator:before {
+        color: #C62828;
+        /* >> character */
+        content: '\\00BB';
+      }
+    </style>
+    <gr-diff-highlight>
+      <table id="diffTable">
+
+        <tbody class="section both">
+           <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="1"></td>
+            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+            <td class="right lineNum" data-value="1"></td>
+            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section delta">
+          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+            <td class="left lineNum" data-value="2"></td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div></td>
+            <td class="right lineNum" data-value="2"></td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
+          </tr>
+        </tbody>
+
+<tbody class="section both">
+          <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="138"></td>
+            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+            <td class="right lineNum" data-value="119"></td>
+            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section delta">
+          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+            <td class="left lineNum" data-value="140"></td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
+                [Yet another random diff thread content here]
+            </div></td>
+            <td class="right lineNum" data-value="120"></td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section both">
+          <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="141"></td>
+            <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>complectitur<span class="tab-indicator" style="tab-size:8;">\u0009</span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+            <td class="right lineNum" data-value="130"></td>
+            <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section contextControl">
+          <tr class="diff-row side-by-side" left-type="contextControl" right-type="contextControl">
+            <td class="left contextLineNum"></td>
+            <td>
+              <gr-button>+10↑</gr-button>
+              -
+              <gr-button>Show 21 common lines</gr-button>
+              -
+              <gr-button>+10↓</gr-button>
+            </td>
+            <td class="right contextLineNum"></td>
+            <td>
+              <gr-button>+10↑</gr-button>
+              -
+              <gr-button>Show 21 common lines</gr-button>
+              -
+              <gr-button>+10↓</gr-button>
+            </td>
+          </tr>
+        </tbody>
+
+        <tbody class="section delta total">
+          <tr class="diff-row side-by-side" left-type="blank" right-type="add">
+            <td class="left"></td>
+            <td class="blank"></td>
+            <td class="right lineNum" data-value="146"></td>
+            <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section both">
+          <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="165"></td>
+            <td class="content both"><div class="contentText"></div></td>
+            <td class="right lineNum" data-value="147"></td>
+            <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+          </tr>
+        </tbody>
+
+      </table>
+    </gr-diff-highlight>
+`);
+/* eslint-enable max-len */
+
+suite('gr-diff-highlight', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate()[1];
+  });
+
+  suite('comment events', () => {
+    let builder;
+
+    setup(() => {
+      builder = {
+        getContentsByLineRange: sinon.stub().returns([]),
+        getLineElByChild: sinon.stub().returns({}),
+        getSideByLineEl: sinon.stub().returns('other-side'),
+      };
+      element._cachedDiffBuilder = builder;
+    });
+
+    test('comment-thread-mouseenter from line comments is ignored', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('line-num', 3);
+      element.appendChild(threadEl);
+      element.commentRanges = [{side: 'right'}];
+
+      sinon.stub(element, 'set');
+      threadEl.dispatchEvent(new CustomEvent(
+          'comment-thread-mouseenter', {bubbles: true, composed: true}));
+      assert.isFalse(element.set.called);
+    });
+
+    test('comment-thread-mouseenter from ranged comment causes set', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('line-num', 3);
+      threadEl.setAttribute('range', JSON.stringify({
+        start_line: 3,
+        start_character: 4,
+        end_line: 5,
+        end_character: 6,
+      }));
+      element.appendChild(threadEl);
+      element.commentRanges = [{side: 'right', range: {
+        start_line: 3,
+        start_character: 4,
+        end_line: 5,
+        end_character: 6,
+      }}];
+
+      sinon.stub(element, 'set');
+      threadEl.dispatchEvent(new CustomEvent(
+          'comment-thread-mouseenter', {bubbles: true, composed: true}));
+      assert.isTrue(element.set.called);
+      const args = element.set.lastCall.args;
+      assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
+      assert.deepEqual(args[1], true);
+    });
+
+    test('comment-thread-mouseleave from line comments is ignored', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('line-num', 3);
+      element.appendChild(threadEl);
+      element.commentRanges = [{side: 'right'}];
+
+      sinon.stub(element, 'set');
+      threadEl.dispatchEvent(new CustomEvent(
+          'comment-thread-mouseleave', {bubbles: true, composed: true}));
+      assert.isFalse(element.set.called);
+    });
+
+    test(`create-range-comment for range when create-comment-requested
+          is fired`, () => {
+      sinon.stub(element, '_removeActionBox');
+      element.selectedRange = {
+        side: 'left',
+        range: {
+          start_line: 7,
+          start_character: 11,
+          end_line: 24,
+          end_character: 42,
+        },
+      };
+      const requestEvent = new CustomEvent('create-comment-requested');
+      let createRangeEvent;
+      element.addEventListener('create-range-comment', e => {
+        createRangeEvent = e;
+      });
+      element.dispatchEvent(requestEvent);
+      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
+      assert.isTrue(element._removeActionBox.called);
+    });
+  });
+
+  suite('selection', () => {
+    let diff;
+    let builder;
+    let contentStubs;
+
+    const stubContent = (line, side, opt_child) => {
+      const contentTd = diff.querySelector(
+          `.${side}.lineNum[data-value="${line}"] ~ .content`);
+      const contentText = contentTd.querySelector('.contentText');
+      const lineEl = diff.querySelector(
+          `.${side}.lineNum[data-value="${line}"]`);
+      contentStubs.push({
+        lineEl,
+        contentTd,
+        contentText,
+      });
+      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
+      builder.getLineNumberByChild.withArgs(lineEl).returns(line);
+      builder.getContentTdByLine.withArgs(line, side).returns(contentTd);
+      builder.getSideByLineEl.withArgs(lineEl).returns(side);
+      return contentText;
+    };
+
+    const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
+      const selection = window.getSelection();
+      const range = document.createRange();
+      range.setStart(startNode, startOffset);
+      range.setEnd(endNode, endOffset);
+      selection.addRange(range);
+      element._handleSelection(selection);
+    };
+
+    const getLineElByChild = node => {
+      const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
+      return stubs && stubs.lineEl;
+    };
+
+    setup(() => {
+      contentStubs = [];
+      stub('gr-selection-action-box', {
+        placeAbove: sinon.stub(),
+        placeBelow: sinon.stub(),
+      });
+      diff = element.querySelector('#diffTable');
+      builder = {
+        getContentTdByLine: sinon.stub(),
+        getContentTdByLineEl: sinon.stub(),
+        getLineElByChild,
+        getLineNumberByChild: sinon.stub(),
+        getSideByLineEl: sinon.stub(),
+      };
+      element._cachedDiffBuilder = builder;
+    });
+
+    teardown(() => {
+      contentStubs = null;
+      window.getSelection().removeAllRanges();
+    });
+
+    test('single first line', () => {
+      const content = stubContent(1, 'right');
+      sinon.spy(element, '_positionActionBox');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = element.shadowRoot
+          .querySelector('gr-selection-action-box');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('multiline starting on first line', () => {
+      const startContent = stubContent(1, 'right');
+      const endContent = stubContent(2, 'right');
+      sinon.spy(element, '_positionActionBox');
+      emulateSelection(
+          startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = element.shadowRoot
+          .querySelector('gr-selection-action-box');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('single line', () => {
+      const content = stubContent(138, 'left');
+      sinon.spy(element, '_positionActionBox');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = element.shadowRoot
+          .querySelector('gr-selection-action-box');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 138,
+        start_character: 5,
+        end_line: 138,
+        end_character: 12,
+      });
+      assert.equal(side, 'left');
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiline', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
+      sinon.spy(element, '_positionActionBox');
+      emulateSelection(
+          startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = element.shadowRoot
+          .querySelector('gr-selection-action-box');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+      assert.equal(side, 'right');
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiple ranges aka firefox implementation', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
+
+      const startRange = document.createRange();
+      startRange.setStart(startContent.firstChild, 10);
+      startRange.setEnd(startContent.firstChild, 11);
+
+      const endRange = document.createRange();
+      endRange.setStart(endContent.lastChild, 6);
+      endRange.setEnd(endContent.lastChild, 7);
+
+      const getRangeAtStub = sinon.stub();
+      getRangeAtStub
+          .onFirstCall().returns(startRange)
+          .onSecondCall()
+          .returns(endRange);
+      const selection = {
+        rangeCount: 2,
+        getRangeAt: getRangeAtStub,
+        removeAllRanges: sinon.stub(),
+      };
+      element._handleSelection(selection);
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+    });
+
+    test('multiline grow end highlight over tabs', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
+      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 2,
+      });
+      assert.equal(side, 'right');
+    });
+
+    test('collapsed', () => {
+      const content = stubContent(138, 'left');
+      emulateSelection(content.firstChild, 5, content.firstChild, 5);
+      assert.isOk(window.getSelection().getRangeAt(0).startContainer);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts inside hl', () => {
+      const content = stubContent(140, 'left');
+      const hl = content.querySelector('.foo');
+      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 8,
+        end_line: 140,
+        end_character: 23,
+      });
+      assert.equal(side, 'left');
+    });
+
+    test('ends inside hl', () => {
+      const content = stubContent(140, 'left');
+      const hl = content.querySelector('.bar');
+      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 18,
+        end_line: 140,
+        end_character: 27,
+      });
+    });
+
+    test('multiple hl', () => {
+      const content = stubContent(140, 'left');
+      const hl = content.querySelectorAll('hl')[4];
+      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 2,
+        end_line: 140,
+        end_character: 61,
+      });
+      assert.equal(side, 'left');
+    });
+
+    test('starts outside of diff', () => {
+      const contentText = stubContent(140, 'left');
+      const contentTd = contentText.parentElement;
+
+      emulateSelection(contentTd.previousElementSibling, 0,
+          contentText.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends outside of diff', () => {
+      const content = stubContent(140, 'left');
+      emulateSelection(content.nextElementSibling.firstChild, 2,
+          content.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts and ends on different sides', () => {
+      const startContent = stubContent(140, 'left');
+      const endContent = stubContent(130, 'right');
+      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts in comment thread element', () => {
+      const startContent = stubContent(140, 'left');
+      const comment = startContent.parentElement.querySelector(
+          '.comment-thread');
+      const endContent = stubContent(141, 'left');
+      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 83,
+        end_line: 141,
+        end_character: 4,
+      });
+      assert.equal(side, 'left');
+    });
+
+    test('ends in comment thread element', () => {
+      const content = stubContent(140, 'left');
+      const comment = content.parentElement.querySelector(
+          '.comment-thread');
+      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 4,
+        end_line: 140,
+        end_character: 83,
+      });
+      assert.equal(side, 'left');
+    });
+
+    test('starts in context element', () => {
+      const contextControl =
+          diff.querySelector('.contextControl').querySelector('gr-button');
+      const content = stubContent(146, 'right');
+      emulateSelection(contextControl, 0, content.firstChild, 7);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends in context element', () => {
+      const contextControl =
+          diff.querySelector('.contextControl').querySelector('gr-button');
+      const content = stubContent(141, 'left');
+      emulateSelection(content.firstChild, 2, contextControl, 1);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('selection containing context element', () => {
+      const startContent = stubContent(130, 'right');
+      const endContent = stubContent(146, 'right');
+      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 130,
+        start_character: 3,
+        end_line: 146,
+        end_character: 14,
+      });
+      assert.equal(side, 'right');
+    });
+
+    test('ends at a tab', () => {
+      const content = stubContent(140, 'left');
+      emulateSelection(
+          content.firstChild, 1, content.querySelector('span'), 0);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 1,
+        end_line: 140,
+        end_character: 51,
+      });
+      assert.equal(side, 'left');
+    });
+
+    test('starts at a tab', () => {
+      const content = stubContent(140, 'left');
+      emulateSelection(
+          content.querySelectorAll('hl')[3], 0,
+          content.querySelectorAll('span')[1].nextSibling, 1);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 71,
+      });
+      assert.equal(side, 'left');
+    });
+
+    test('properly accounts for syntax highlighting', () => {
+      const content = stubContent(140, 'left');
+      const spy = sinon.spy(element, '_normalizeRange');
+      emulateSelection(
+          content.querySelectorAll('hl')[3], 0,
+          content.querySelectorAll('span')[1], 0);
+      const spyCall = spy.getCall(0);
+      const range = window.getSelection().getRangeAt(0);
+      assert.notDeepEqual(spyCall.returnValue, range);
+    });
+
+    test('GrRangeNormalizer._getTextOffset computes text offset', () => {
+      let content = stubContent(140, 'left');
+      let child = content.lastChild.lastChild;
+      let result = GrRangeNormalizer._getTextOffset(content, child);
+      assert.equal(result, 75);
+      content = stubContent(146, 'right');
+      child = content.lastChild;
+      result = GrRangeNormalizer._getTextOffset(content, child);
+      assert.equal(result, 0);
+    });
+
+    test('_fixTripleClickSelection', () => {
+      const startContent = stubContent(119, 'right');
+      const endContent = stubContent(120, 'right');
+      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 0,
+        end_line: 119,
+        end_character: element._getLength(startContent),
+      });
+      assert.equal(side, 'right');
+    });
+
+    test('_fixTripleClickSelection empty line', () => {
+      const startContent = stubContent(146, 'right');
+      const endContent = stubContent(165, 'left');
+      emulateSelection(startContent.firstChild, 0,
+          endContent.parentElement.previousElementSibling, 0);
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 146,
+        start_character: 0,
+        end_line: 146,
+        end_character: 84,
+      });
+      assert.equal(side, 'right');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
index 2ed69b6..4293c54 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -14,25 +14,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
-import '../../core/gr-reporting/gr-reporting.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../shared/gr-comment-thread/gr-comment-thread.js';
 import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
 import '../gr-diff/gr-diff.js';
 import '../gr-syntax-layer/gr-syntax-layer.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-diff-host_html.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
 import {GrDiffBuilder} from '../gr-diff-builder/gr-diff-builder.js';
-import {util} from '../../../scripts/util.js';
+import {parseDate} from '../../../utils/date-util.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {DiffSide, rangesEqual} from '../gr-diff/gr-diff-utils.js';
+import {appContext} from '../../../services/app-context.js';
+import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util.js';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -85,13 +82,11 @@
  * Webcomponent fetching diffs and related data from restAPI and passing them
  * to the presentational gr-diff for rendering.
  *
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrDiffHost extends mixinBehaviors( [
-  PatchSetBehavior,
-], GestureEventListeners(
+class GrDiffHost extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-diff-host'; }
@@ -122,6 +117,9 @@
       },
       /** @type {?} */
       patchRange: Object,
+      /** @type {!Gerrit.FileRange} */
+      file: Object,
+      // TODO: deprecate path since that info is included in file
       path: String,
       prefs: {
         type: Object,
@@ -217,6 +215,11 @@
         notify: true,
       },
 
+      _fetchDiffPromise: {
+        type: Object,
+        value: null,
+      },
+
       /** @type {?Object} */
       _blame: {
         type: Object,
@@ -259,6 +262,11 @@
     ];
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   /** @override */
   created() {
     super.created();
@@ -303,12 +311,19 @@
     });
   }
 
+  /** @override */
+  detached() {
+    super.detached();
+    this.clear();
+  }
+
   /**
    * @param {boolean=} shouldReportMetric indicate a new Diff Page. This is a
    * signal to report metrics event that started on location change.
    * @return {!Promise}
    **/
   reload(shouldReportMetric) {
+    this.clear();
     this._loading = true;
     this._errorMessage = null;
     const whitespaceLevel = this._getIgnoreWhitespace();
@@ -360,21 +375,21 @@
               const needsSyntaxHighlighting = event.detail &&
                     event.detail.contentRendered;
               if (needsSyntaxHighlighting) {
-                this.$.reporting.time(TimingLabel.SYNTAX);
-                this.$.syntaxLayer.process().then(() => {
-                  this.$.reporting.timeEnd(TimingLabel.SYNTAX);
-                  this.$.reporting.timeEnd(TimingLabel.TOTAL);
+                this.reporting.time(TimingLabel.SYNTAX);
+                this.$.syntaxLayer.process().finally(() => {
+                  this.reporting.timeEnd(TimingLabel.SYNTAX);
+                  this.reporting.timeEnd(TimingLabel.TOTAL);
                   resolve();
                 });
               } else {
-                this.$.reporting.timeEnd(TimingLabel.TOTAL);
+                this.reporting.timeEnd(TimingLabel.TOTAL);
                 resolve();
               }
               this.removeEventListener('render', callback);
               if (shouldReportMetric) {
                 // We report diffViewContentDisplayed only on reload caused
                 // by params changed - expected only on Diff Page.
-                this.$.reporting.diffViewContentDisplayed();
+                this.reporting.diffViewContentDisplayed();
               }
             };
             this.addEventListener('render', callback);
@@ -387,6 +402,11 @@
         .then(() => { this._loading = false; });
   }
 
+  clear() {
+    this.$.jsAPI.disposeDiffLayers(this.path);
+    this._layers = [];
+  }
+
   _getCoverageData() {
     const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this;
     this.$.jsAPI.getCoverageAnnotationApi().
@@ -448,6 +468,7 @@
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
     this.$.diff.cancel();
+    this.$.syntaxLayer.cancel();
   }
 
   /** @return {!Array<!HTMLElement>} */
@@ -528,8 +549,21 @@
         !this.noAutoRender;
   }
 
+  // TODO(milutin): Use rest-api with fetchCacheURL instead of this.
+  prefetchDiff() {
+    if (!!this.changeNum && !!this.patchRange && !!this.path
+        && this._fetchDiffPromise === null) {
+      this._fetchDiffPromise = this._getDiff();
+    }
+  }
+
   /** @return {!Promise<!Object>} */
   _getDiff() {
+    if (this._fetchDiffPromise !== null) {
+      const fetchDiffPromise = this._fetchDiffPromise;
+      this._fetchDiffPromise = null;
+      return fetchDiffPromise;
+    }
     // Wrap the diff request in a new promise so that the error handler
     // rejects the promise, allowing the error to be handled in the .catch.
     return new Promise((resolve, reject) => {
@@ -602,11 +636,11 @@
     // Report the due_to_rebase percentage in the "diff" category when
     // applicable.
     if (this.patchRange.basePatchNum === 'PARENT') {
-      this.$.reporting.reportInteraction(EVENT_AGAINST_PARENT);
+      this.reporting.reportInteraction(EVENT_AGAINST_PARENT);
     } else if (percentRebaseDelta === 0) {
-      this.$.reporting.reportInteraction(EVENT_ZERO_REBASE);
+      this.reporting.reportInteraction(EVENT_ZERO_REBASE);
     } else {
-      this.$.reporting.reportInteraction(EVENT_NONZERO_REBASE,
+      this.reporting.reportInteraction(EVENT_NONZERO_REBASE,
           {percentRebaseDelta});
     }
   }
@@ -661,7 +695,7 @@
     return comments.slice(0).sort((a, b) => {
       if (b.__draft && !a.__draft ) { return -1; }
       if (a.__draft && !b.__draft ) { return 1; }
-      return util.parseDate(a.updated) - util.parseDate(b.updated);
+      return parseDate(a.updated) - parseDate(b.updated);
     });
   }
 
@@ -695,7 +729,7 @@
         isOnParent: comment.side === 'PARENT',
       };
       if (comment.range) {
-        newThread.range = Object.assign({}, comment.range);
+        newThread.range = {...comment.range};
       }
       threads.push(newThread);
     }
@@ -726,7 +760,7 @@
         isOnParent);
     threadEl.addOrEditDraft(lineNum, range);
 
-    this.$.reporting.recordDraftInteraction();
+    this.reporting.recordDraftInteraction();
   }
 
   /**
@@ -775,14 +809,23 @@
     threadEl.commentSide = thread.commentSide;
     threadEl.isOnParent = !!thread.isOnParent;
     threadEl.parentIndex = this._parentIndex;
+    // Use path before renmaing when comment added on the left when comparing
+    // two patch sets (not against base)
+    if (this.file && this.file.basePath
+        && thread.commentSide === GrDiffBuilder.Side.LEFT
+        && !thread.isOnParent) {
+      threadEl.path = this.file.basePath;
+    } else {
+      threadEl.path = this.path;
+    }
     threadEl.changeNum = this.changeNum;
     threadEl.patchNum = thread.patchNum;
+    threadEl.showPatchset = false;
     threadEl.lineNum = thread.lineNum;
     const rootIdChangedListener = changeEvent => {
       thread.rootId = changeEvent.detail.value;
     };
     threadEl.addEventListener('root-id-changed', rootIdChangedListener);
-    threadEl.path = this.path;
     threadEl.projectName = this.projectName;
     threadEl.range = thread.range;
     const threadDiscardListener = e => {
@@ -887,10 +930,11 @@
       preferredWhitespaceLevel,
       loadedWhitespaceLevel,
       noRenderOnPrefsChange,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return;
     }
 
+    this._fetchDiffPromise = null;
     if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
         !noRenderOnPrefsChange) {
       this.reload();
@@ -902,7 +946,7 @@
     if ([
       noRenderOnPrefsChange,
       prefsChangeRecord,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return;
     }
 
@@ -920,8 +964,9 @@
    * @return {number|null}
    */
   _computeParentIndex(patchRangeRecord) {
-    return this.isMergeParent(patchRangeRecord.base.basePatchNum) ?
-      this.getParentIndex(patchRangeRecord.base.basePatchNum) : null;
+    if (!patchRangeRecord.base) return null;
+    return isMergeParent(patchRangeRecord.base.basePatchNum) ?
+      getParentIndex(patchRangeRecord.base.basePatchNum) : null;
   }
 
   _handleCommentSave(e) {
@@ -941,7 +986,7 @@
   /**
    * Closure annotation for Polymer.prototype.push is off. Submitted PR:
    * https://github.com/Polymer/polymer/pull/4776
-   * but for not supressing annotations.
+   * but for not suppressing annotations.
    *
    * @suppress {checkTypes}
    */
@@ -1024,7 +1069,7 @@
   _listenToViewportRender() {
     const renderUpdateListener = start => {
       if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
-        this.$.reporting.diffViewDisplayed();
+        this.reporting.diffViewDisplayed();
         this.$.syntaxLayer.removeListener(renderUpdateListener);
       }
     };
@@ -1033,16 +1078,16 @@
   }
 
   _handleRenderStart() {
-    this.$.reporting.time(TimingLabel.TOTAL);
-    this.$.reporting.time(TimingLabel.CONTENT);
+    this.reporting.time(TimingLabel.TOTAL);
+    this.reporting.time(TimingLabel.CONTENT);
   }
 
   _handleRenderContent() {
-    this.$.reporting.timeEnd(TimingLabel.CONTENT);
+    this.reporting.timeEnd(TimingLabel.CONTENT);
   }
 
   _handleNormalizeRange(event) {
-    this.$.reporting.reportInteraction('normalize-range',
+    this.reporting.reportInteraction('normalize-range',
         {
           side: event.detail.side,
           lineNum: event.detail.lineNum,
@@ -1050,7 +1095,7 @@
   }
 
   _handleDiffContextExpanded(event) {
-    this.$.reporting.reportInteraction(
+    this.reporting.reportInteraction(
         'diff-context-expanded', {numLines: event.detail.numLines}
     );
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.js
deleted file mode 100644
index 4e425dc..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.js
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <gr-diff
-    id="diff"
-    change-num="[[changeNum]]"
-    no-auto-render="[[noAutoRender]]"
-    patch-range="[[patchRange]]"
-    path="[[path]]"
-    prefs="[[prefs]]"
-    project-name="[[projectName]]"
-    display-line="[[displayLine]]"
-    is-image-diff="[[isImageDiff]]"
-    commit-range="[[commitRange]]"
-    hidden$="[[hidden]]"
-    no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
-    line-wrapping="[[lineWrapping]]"
-    view-mode="[[viewMode]]"
-    line-of-interest="[[lineOfInterest]]"
-    logged-in="[[_loggedIn]]"
-    loading="[[_loading]]"
-    error-message="[[_errorMessage]]"
-    base-image="[[_baseImage]]"
-    revision-image="[[_revisionImage]]"
-    coverage-ranges="[[_coverageRanges]]"
-    blame="[[_blame]]"
-    layers="[[_layers]]"
-    diff="[[diff]]"
-    show-newline-warning-left="[[_showNewlineWarningLeft(diff)]]"
-    show-newline-warning-right="[[_showNewlineWarningRight(diff)]]"
-  >
-  </gr-diff>
-  <gr-syntax-layer
-    id="syntaxLayer"
-    enabled="[[_syntaxHighlightingEnabled]]"
-    diff="[[diff]]"
-  ></gr-syntax-layer>
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-reporting id="reporting" category="diff"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
new file mode 100644
index 0000000..38b0d6d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <gr-diff
+    id="diff"
+    change-num="[[changeNum]]"
+    no-auto-render="[[noAutoRender]]"
+    patch-range="[[patchRange]]"
+    path="[[path]]"
+    prefs="[[prefs]]"
+    project-name="[[projectName]]"
+    display-line="[[displayLine]]"
+    is-image-diff="[[isImageDiff]]"
+    commit-range="[[commitRange]]"
+    hidden$="[[hidden]]"
+    no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
+    line-wrapping="[[lineWrapping]]"
+    view-mode="[[viewMode]]"
+    line-of-interest="[[lineOfInterest]]"
+    logged-in="[[_loggedIn]]"
+    loading="[[_loading]]"
+    error-message="[[_errorMessage]]"
+    base-image="[[_baseImage]]"
+    revision-image="[[_revisionImage]]"
+    coverage-ranges="[[_coverageRanges]]"
+    blame="[[_blame]]"
+    layers="[[_layers]]"
+    diff="[[diff]]"
+    show-newline-warning-left="[[_showNewlineWarningLeft(diff)]]"
+    show-newline-warning-right="[[_showNewlineWarningRight(diff)]]"
+  >
+  </gr-diff>
+  <gr-syntax-layer
+    id="syntaxLayer"
+    enabled="[[_syntaxHighlightingEnabled]]"
+    diff="[[diff]]"
+  ></gr-syntax-layer>
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
deleted file mode 100644
index 0cb2b5e..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
+++ /dev/null
@@ -1,1644 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-diff</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-host></gr-diff-host>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-diff-host.js';
-import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {DiffSide} from '../gr-diff/gr-diff-utils.js';
-
-suite('gr-diff-host tests', () => {
-  let element;
-  let sandbox;
-  let getLoggedIn;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    getLoggedIn = false;
-    stub('gr-rest-api-interface', {
-      async getLoggedIn() { return getLoggedIn; },
-    });
-    stub('gr-reporting', {
-      time: sandbox.stub(),
-      timeEnd: sandbox.stub(),
-    });
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('plugin layers', () => {
-    const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
-    setup(() => {
-      stub('gr-js-api-interface', {
-        getDiffLayers() { return pluginLayers; },
-      });
-      element = fixture('basic');
-    });
-    test('plugin layers requested', () => {
-      element.patchRange = {};
-      element.reload();
-      assert(element.$.jsAPI.getDiffLayers.called);
-    });
-  });
-
-  suite('handle comment-update', () => {
-    setup(() => {
-      sandbox.stub(element, '_commentsChanged');
-      element.comments = {
-        meta: {
-          changeNum: '42',
-          patchRange: {
-            basePatchNum: 'PARENT',
-            patchNum: 3,
-          },
-          path: '/path/to/foo',
-          projectConfig: {foo: 'bar'},
-        },
-        left: [
-          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        ],
-        right: [
-          {id: 'c1', __commentSide: 'right'},
-          {id: 'c2', __commentSide: 'right'},
-          {id: 'd1', __draft: true, __commentSide: 'right'},
-          {id: 'd2', __draft: true, __commentSide: 'right'},
-        ],
-      };
-    });
-
-    test('creating a draft', () => {
-      const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
-        __commentSide: 'left'};
-      element.dispatchEvent(
-          new CustomEvent('comment-update', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      assert.include(element.comments.left, comment);
-    });
-
-    test('discarding a draft', () => {
-      const draftID = 'tempID';
-      const id = 'savedID';
-      const comment = {
-        __draft: true,
-        __draftID: draftID,
-        side: 'PARENT',
-        __commentSide: 'left',
-      };
-      const diffCommentsModifiedStub = sandbox.stub();
-      element.addEventListener('diff-comments-modified',
-          diffCommentsModifiedStub);
-      element.comments.left.push(comment);
-      comment.id = id;
-      element.dispatchEvent(
-          new CustomEvent('comment-discard', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      const drafts = element.comments.left
-          .filter(item => item.__draftID === draftID);
-      assert.equal(drafts.length, 0);
-      assert.isTrue(diffCommentsModifiedStub.called);
-    });
-
-    test('saving a draft', () => {
-      const draftID = 'tempID';
-      const id = 'savedID';
-      const comment = {
-        __draft: true,
-        __draftID: draftID,
-        side: 'PARENT',
-        __commentSide: 'left',
-      };
-      const diffCommentsModifiedStub = sandbox.stub();
-      element.addEventListener('diff-comments-modified',
-          diffCommentsModifiedStub);
-      element.comments.left.push(comment);
-      comment.id = id;
-      element.dispatchEvent(
-          new CustomEvent('comment-save', {
-            detail: {comment},
-            composed: true, bubbles: true,
-          }));
-      const drafts = element.comments.left
-          .filter(item => item.__draftID === draftID);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].id, id);
-      assert.isTrue(diffCommentsModifiedStub.called);
-    });
-  });
-
-  test('remove comment', () => {
-    sandbox.stub(element, '_commentsChanged');
-    element.comments = {
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-        {id: 'd2', __draft: true, __commentSide: 'right'},
-      ],
-    };
-
-    element._removeComment({});
-    // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
-    // to believe that one object deepEquals another even when they do :-/.
-    assert.equal(JSON.stringify(element.comments), JSON.stringify({
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-        {id: 'd2', __draft: true, __commentSide: 'right'},
-      ],
-    }));
-
-    element._removeComment({id: 'bc2', side: 'PARENT',
-      __commentSide: 'left'});
-    assert.deepEqual(element.comments, {
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-        {id: 'd2', __draft: true, __commentSide: 'right'},
-      ],
-    });
-
-    element._removeComment({id: 'd2', __commentSide: 'right'});
-    assert.deepEqual(element.comments, {
-      meta: {
-        changeNum: '42',
-        patchRange: {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        },
-        path: '/path/to/foo',
-        projectConfig: {foo: 'bar'},
-      },
-      left: [
-        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
-        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
-      ],
-      right: [
-        {id: 'c1', __commentSide: 'right'},
-        {id: 'c2', __commentSide: 'right'},
-        {id: 'd1', __draft: true, __commentSide: 'right'},
-      ],
-    });
-  });
-
-  test('thread-discard handling', () => {
-    const threads = [
-      {comments: [{id: 4711}]},
-      {comments: [{id: 42}]},
-    ];
-    element._parentIndex = 1;
-    element.changeNum = '2';
-    element.path = 'some/path';
-    element.projectName = 'Some project';
-    const threadEls = threads.map(
-        thread => {
-          const threadEl = element._createThreadElement(thread);
-          // Polymer 2 doesn't fire ready events and doesn't execute
-          // observers if element is not added to the Dom.
-          // See https://github.com/Polymer/old-docs-site/issues/2322
-          // and https://github.com/Polymer/polymer/issues/4526
-          element._attachThreadElement(threadEl);
-          return threadEl;
-        });
-    assert.equal(threadEls.length, 2);
-    assert.equal(threadEls[0].rootId, 4711);
-    assert.equal(threadEls[1].rootId, 42);
-    for (const threadEl of threadEls) {
-      dom(element).appendChild(threadEl);
-    }
-
-    threadEls[0].dispatchEvent(
-        new CustomEvent('thread-discard', {detail: {rootId: 4711}}));
-    const attachedThreads = element.queryAllEffectiveChildren(
-        'gr-comment-thread');
-    assert.equal(attachedThreads.length, 1);
-    assert.equal(attachedThreads[0].rootId, 42);
-  });
-
-  suite('render reporting', () => {
-    test('starts total and content timer on render-start', done => {
-      element.dispatchEvent(
-          new CustomEvent('render-start', {bubbles: true, composed: true}));
-      assert.isTrue(element.$.reporting.time.calledWithExactly(
-          'Diff Total Render'));
-      assert.isTrue(element.$.reporting.time.calledWithExactly(
-          'Diff Content Render'));
-      done();
-    });
-
-    test('ends content timer on render-content', () => {
-      element.dispatchEvent(
-          new CustomEvent('render-content', {bubbles: true, composed: true}));
-      assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-          'Diff Content Render'));
-    });
-
-    test('ends total and syntax timer after syntax layer processing', done => {
-      let notifySyntaxProcessed;
-      sandbox.stub(element.$.syntaxLayer, 'process').returns(new Promise(
-          resolve => {
-            notifySyntaxProcessed = resolve;
-          }));
-      sandbox.stub(element.$.restAPI, 'getDiff').returns(
-          Promise.resolve({content: []}));
-      element.patchRange = {};
-      element.$.restAPI.getDiffPreferences().then(prefs => {
-        element.prefs = prefs;
-        return element.reload(true);
-      });
-      // Multiple cascading microtasks are scheduled.
-      setTimeout(() => {
-        notifySyntaxProcessed();
-        // Assert after the notification task is processed.
-        Promise.resolve().then(() => {
-          assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-              'Diff Total Render'));
-          assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-              'Diff Syntax Render'));
-          assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-              'StartupDiffViewOnlyContent'));
-          done();
-        });
-      });
-    });
-
-    test('ends total timer w/ no syntax layer processing', done => {
-      sandbox.stub(element.$.restAPI, 'getDiff').returns(
-          Promise.resolve({content: []}));
-      element.patchRange = {};
-      element.reload();
-      // Multiple cascading microtasks are scheduled.
-      setTimeout(() => {
-        assert.isTrue(element.$.reporting.timeEnd.calledOnce);
-        assert.isTrue(element.$.reporting.timeEnd.calledWithExactly(
-            'Diff Total Render'));
-        done();
-      });
-    });
-
-    test('completes reload promise after syntax layer processing', done => {
-      let notifySyntaxProcessed;
-      sandbox.stub(element.$.syntaxLayer, 'process').returns(new Promise(
-          resolve => {
-            notifySyntaxProcessed = resolve;
-          }));
-      sandbox.stub(element.$.restAPI, 'getDiff').returns(
-          Promise.resolve({content: []}));
-      element.patchRange = {};
-      let reloadComplete = false;
-      element.$.restAPI.getDiffPreferences()
-          .then(prefs => {
-            element.prefs = prefs;
-            return element.reload();
-          })
-          .then(() => {
-            reloadComplete = true;
-          });
-      // Multiple cascading microtasks are scheduled.
-      setTimeout(() => {
-        assert.isFalse(reloadComplete);
-        notifySyntaxProcessed();
-        // Assert after the notification task is processed.
-        setTimeout(() => {
-          assert.isTrue(reloadComplete);
-          done();
-        });
-      });
-    });
-  });
-
-  test('reload() cancels before network resolves', () => {
-    const cancelStub = sandbox.stub(element.$.diff, 'cancel');
-
-    // Stub the network calls into requests that never resolve.
-    sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
-    element.patchRange = {};
-
-    element.reload();
-    assert.isTrue(cancelStub.called);
-  });
-
-  suite('not logged in', () => {
-    setup(() => {
-      getLoggedIn = false;
-      element = fixture('basic');
-    });
-
-    test('reload() loads files weblinks', () => {
-      const weblinksStub = sandbox.stub(GerritNav, '_generateWeblinks')
-          .returns({name: 'stubb', url: '#s'});
-      sandbox.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({
-        content: [],
-      }));
-      element.projectName = 'test-project';
-      element.path = 'test-path';
-      element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
-      element.patchRange = {};
-      return element.reload().then(() => {
-        assert.isTrue(weblinksStub.calledTwice);
-        assert.isTrue(weblinksStub.firstCall.calledWith({
-          commit: 'test-base',
-          file: 'test-path',
-          options: {
-            weblinks: undefined,
-          },
-          repo: 'test-project',
-          type: GerritNav.WeblinkType.FILE}));
-        assert.isTrue(weblinksStub.secondCall.calledWith({
-          commit: 'test-commit',
-          file: 'test-path',
-          options: {
-            weblinks: undefined,
-          },
-          repo: 'test-project',
-          type: GerritNav.WeblinkType.FILE}));
-        assert.deepEqual(element.filesWeblinks, {
-          meta_a: [{name: 'stubb', url: '#s'}],
-          meta_b: [{name: 'stubb', url: '#s'}],
-        });
-      });
-    });
-
-    test('_getDiff handles null diff responses', done => {
-      stub('gr-rest-api-interface', {
-        getDiff() { return Promise.resolve(null); },
-      });
-      element.changeNum = 123;
-      element.patchRange = {basePatchNum: 1, patchNum: 2};
-      element.path = 'file.txt';
-      element._getDiff().then(done);
-    });
-
-    test('reload resolves on error', () => {
-      const onErrStub = sandbox.stub(element, '_handleGetDiffError');
-      const error = {ok: false, status: 500};
-      sandbox.stub(element.$.restAPI, 'getDiff',
-          (changeNum, basePatchNum, patchNum, path, onErr) => {
-            onErr(error);
-          });
-      element.patchRange = {};
-      return element.reload().then(() => {
-        assert.isTrue(onErrStub.calledOnce);
-      });
-    });
-
-    suite('_handleGetDiffError', () => {
-      let serverErrorStub;
-      let pageErrorStub;
-
-      setup(() => {
-        serverErrorStub = sinon.stub();
-        element.addEventListener('server-error', serverErrorStub);
-        pageErrorStub = sinon.stub();
-        element.addEventListener('page-error', pageErrorStub);
-      });
-
-      test('page error on HTTP-409', () => {
-        element._handleGetDiffError({status: 409});
-        assert.isTrue(serverErrorStub.calledOnce);
-        assert.isFalse(pageErrorStub.called);
-        assert.isNotOk(element._errorMessage);
-      });
-
-      test('server error on non-HTTP-409', () => {
-        element._handleGetDiffError({status: 500});
-        assert.isFalse(serverErrorStub.called);
-        assert.isTrue(pageErrorStub.calledOnce);
-        assert.isNotOk(element._errorMessage);
-      });
-
-      test('error message if showLoadFailure', () => {
-        element.showLoadFailure = true;
-        element._handleGetDiffError({status: 500, statusText: 'Failure!'});
-        assert.isFalse(serverErrorStub.called);
-        assert.isFalse(pageErrorStub.called);
-        assert.equal(element._errorMessage,
-            'Encountered error when loading the diff: 500 Failure!');
-      });
-    });
-
-    suite('image diffs', () => {
-      let mockFile1;
-      let mockFile2;
-      setup(() => {
-        mockFile1 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAAAAAA/w==',
-          type: 'image/bmp',
-        };
-        mockFile2 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAA/////w==',
-          type: 'image/bmp',
-        };
-        sandbox.stub(element.$.restAPI,
-            'getB64FileContents',
-            (changeId, patchNum, path, opt_parentIndex) => Promise.resolve(
-                opt_parentIndex === 1 ? mockFile1 :
-                  mockFile2)
-        );
-
-        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-        element.comments = {
-          left: [],
-          right: [],
-          meta: {patchRange: element.patchRange},
-        };
-      });
-
-      test('renders image diffs with same file name', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        sandbox.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diff.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diff.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diff.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isNotOk(rightLabelName);
-          assert.isNotOk(leftLabelName);
-
-          let leftLoaded = false;
-          let rightLoaded = false;
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-            leftLoaded = true;
-            if (rightLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-            rightLoaded = true;
-            if (leftLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-
-      test('renders image diffs with a different file name', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot2.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot2.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        sandbox.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diff.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diff.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diff.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isOk(rightLabelName);
-          assert.isOk(leftLabelName);
-          assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-          assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-          let leftLoaded = false;
-          let rightLoaded = false;
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
-            leftLoaded = true;
-            if (rightLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
-
-            rightLoaded = true;
-            if (leftLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-
-      test('renders added image', done => {
-        const mockDiff = {
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'ADDED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 0000000..f9c2f2c 100644',
-            '--- /dev/null',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        sandbox.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        element.addEventListener('render', () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          const rightImage =
-              element.$.diff.$.diffTable.querySelector('td.right img');
-
-          assert.isNotOk(leftImage);
-          assert.isOk(rightImage);
-          done();
-        });
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-
-      test('renders removed image', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        sandbox.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        element.addEventListener('render', () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          const rightImage =
-              element.$.diff.$.diffTable.querySelector('td.right img');
-
-          assert.isOk(leftImage);
-          assert.isNotOk(rightImage);
-          done();
-        });
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-
-      test('does not render disallowed image type', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        mockFile1.type = 'image/jpeg-evil';
-
-        sandbox.stub(element.$.restAPI, 'getDiff')
-            .returns(Promise.resolve(mockDiff));
-
-        element.addEventListener('render', () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
-          const leftImage =
-              element.$.diff.$.diffTable.querySelector('td.left img');
-          assert.isNotOk(leftImage);
-          done();
-        });
-
-        element.$.restAPI.getDiffPreferences().then(prefs => {
-          element.prefs = prefs;
-          element.reload();
-        });
-      });
-    });
-  });
-
-  test('delegates cancel()', () => {
-    const stub = sandbox.stub(element.$.diff, 'cancel');
-    element.patchRange = {};
-    element.reload();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates getCursorStops()', () => {
-    const returnValue = [document.createElement('b')];
-    const stub = sandbox.stub(element.$.diff, 'getCursorStops')
-        .returns(returnValue);
-    assert.equal(element.getCursorStops(), returnValue);
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates isRangeSelected()', () => {
-    const returnValue = true;
-    const stub = sandbox.stub(element.$.diff, 'isRangeSelected')
-        .returns(returnValue);
-    assert.equal(element.isRangeSelected(), returnValue);
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates toggleLeftDiff()', () => {
-    const stub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
-    element.toggleLeftDiff();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  suite('blame', () => {
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('clearBlame', () => {
-      element._blame = [];
-      const setBlameSpy = sandbox.spy(element.$.diff.$.diffBuilder, 'setBlame');
-      element.clearBlame();
-      assert.isNull(element._blame);
-      assert.isTrue(setBlameSpy.calledWithExactly(null));
-      assert.equal(element.isBlameLoaded, false);
-    });
-
-    test('loadBlame', () => {
-      const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-      const showAlertStub = sinon.stub();
-      element.addEventListener('show-alert', showAlertStub);
-      const getBlameStub = sandbox.stub(element.$.restAPI, 'getBlame')
-          .returns(Promise.resolve(mockBlame));
-      element.changeNum = 42;
-      element.patchRange = {patchNum: 5, basePatchNum: 4};
-      element.path = 'foo/bar.baz';
-      return element.loadBlame().then(() => {
-        assert.isTrue(getBlameStub.calledWithExactly(
-            42, 5, 'foo/bar.baz', true));
-        assert.isFalse(showAlertStub.called);
-        assert.equal(element._blame, mockBlame);
-        assert.equal(element.isBlameLoaded, true);
-      });
-    });
-
-    test('loadBlame empty', () => {
-      const mockBlame = [];
-      const showAlertStub = sinon.stub();
-      element.addEventListener('show-alert', showAlertStub);
-      sandbox.stub(element.$.restAPI, 'getBlame')
-          .returns(Promise.resolve(mockBlame));
-      element.changeNum = 42;
-      element.patchRange = {patchNum: 5, basePatchNum: 4};
-      element.path = 'foo/bar.baz';
-      return element.loadBlame()
-          .then(() => {
-            assert.isTrue(false, 'Promise should not resolve');
-          })
-          .catch(() => {
-            assert.isTrue(showAlertStub.calledOnce);
-            assert.isNull(element._blame);
-            assert.equal(element.isBlameLoaded, false);
-          });
-    });
-  });
-
-  test('getThreadEls() returns .comment-threads', () => {
-    const threadEl = document.createElement('div');
-    threadEl.className = 'comment-thread';
-    dom(element.$.diff).appendChild(threadEl);
-    assert.deepEqual(element.getThreadEls(), [threadEl]);
-  });
-
-  test('delegates addDraftAtLine(el)', () => {
-    const param0 = document.createElement('b');
-    const stub = sandbox.stub(element.$.diff, 'addDraftAtLine');
-    element.addDraftAtLine(param0);
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 1);
-    assert.equal(stub.lastCall.args[0], param0);
-  });
-
-  test('delegates clearDiffContent()', () => {
-    const stub = sandbox.stub(element.$.diff, 'clearDiffContent');
-    element.clearDiffContent();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates expandAllContext()', () => {
-    const stub = sandbox.stub(element.$.diff, 'expandAllContext');
-    element.expandAllContext();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('passes in changeNum', () => {
-    const value = '12345';
-    element.changeNum = value;
-    assert.equal(element.$.diff.changeNum, value);
-  });
-
-  test('passes in noAutoRender', () => {
-    const value = true;
-    element.noAutoRender = value;
-    assert.equal(element.$.diff.noAutoRender, value);
-  });
-
-  test('passes in patchRange', () => {
-    const value = {patchNum: 'foo', basePatchNum: 'bar'};
-    element.patchRange = value;
-    assert.equal(element.$.diff.patchRange, value);
-  });
-
-  test('passes in path', () => {
-    const value = 'some/file/path';
-    element.path = value;
-    assert.equal(element.$.diff.path, value);
-  });
-
-  test('passes in prefs', () => {
-    const value = {};
-    element.prefs = value;
-    assert.equal(element.$.diff.prefs, value);
-  });
-
-  test('passes in changeNum', () => {
-    const value = '12345';
-    element.changeNum = value;
-    assert.equal(element.$.diff.changeNum, value);
-  });
-
-  test('passes in projectName', () => {
-    const value = 'Gerrit';
-    element.projectName = value;
-    assert.equal(element.$.diff.projectName, value);
-  });
-
-  test('passes in displayLine', () => {
-    const value = true;
-    element.displayLine = value;
-    assert.equal(element.$.diff.displayLine, value);
-  });
-
-  test('passes in commitRange', () => {
-    const value = {};
-    element.commitRange = value;
-    assert.equal(element.$.diff.commitRange, value);
-  });
-
-  test('passes in hidden', () => {
-    const value = true;
-    element.hidden = value;
-    assert.equal(element.$.diff.hidden, value);
-    assert.isNotNull(element.getAttribute('hidden'));
-  });
-
-  test('passes in noRenderOnPrefsChange', () => {
-    const value = true;
-    element.noRenderOnPrefsChange = value;
-    assert.equal(element.$.diff.noRenderOnPrefsChange, value);
-  });
-
-  test('passes in lineWrapping', () => {
-    const value = true;
-    element.lineWrapping = value;
-    assert.equal(element.$.diff.lineWrapping, value);
-  });
-
-  test('passes in viewMode', () => {
-    const value = 'SIDE_BY_SIDE';
-    element.viewMode = value;
-    assert.equal(element.$.diff.viewMode, value);
-  });
-
-  test('passes in lineOfInterest', () => {
-    const value = {number: 123, leftSide: true};
-    element.lineOfInterest = value;
-    assert.equal(element.$.diff.lineOfInterest, value);
-  });
-
-  suite('_reportDiff', () => {
-    let reportStub;
-
-    setup(() => {
-      element = fixture('basic');
-      element.patchRange = {basePatchNum: 1};
-      reportStub = sandbox.stub(element.$.reporting, 'reportInteraction');
-    });
-
-    test('null and content-less', () => {
-      element._reportDiff(null);
-      assert.isFalse(reportStub.called);
-
-      element._reportDiff({});
-      assert.isFalse(reportStub.called);
-    });
-
-    test('diff w/ no delta', () => {
-      const diff = {
-        content: [
-          {ab: ['foo', 'bar']},
-          {ab: ['baz', 'foo']},
-        ],
-      };
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-      assert.isUndefined(reportStub.lastCall.args[1]);
-    });
-
-    test('diff w/ no rebase delta', () => {
-      const diff = {
-        content: [
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo']},
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo'], b: ['bar', 'baz']},
-          {ab: ['foo', 'bar']},
-          {b: ['baz', 'foo']},
-          {ab: ['foo', 'bar']},
-        ],
-      };
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-      assert.isUndefined(reportStub.lastCall.args[1]);
-    });
-
-    test('diff w/ some rebase delta', () => {
-      const diff = {
-        content: [
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo'], due_to_rebase: true},
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo'], b: ['bar', 'baz']},
-          {ab: ['foo', 'bar']},
-          {b: ['baz', 'foo'], due_to_rebase: true},
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo']},
-        ],
-      };
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.isTrue(reportStub.calledWith(
-          'rebase-percent-nonzero',
-          {percentRebaseDelta: 50}
-      ));
-    });
-
-    test('diff w/ all rebase delta', () => {
-      const diff = {content: [{
-        a: ['foo', 'bar'],
-        b: ['baz', 'foo'],
-        due_to_rebase: true,
-      }]};
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.isTrue(reportStub.calledWith(
-          'rebase-percent-nonzero',
-          {percentRebaseDelta: 100}
-      ));
-    });
-
-    test('diff against parent event', () => {
-      element.patchRange.basePatchNum = 'PARENT';
-      const diff = {content: [{
-        a: ['foo', 'bar'],
-        b: ['baz', 'foo'],
-      }]};
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
-      assert.isUndefined(reportStub.lastCall.args[1]);
-    });
-  });
-
-  test('comments sorting', () => {
-    const comments = [
-      {
-        id: 'new_draft',
-        message: 'i do not like either of you',
-        __commentSide: 'left',
-        __draft: true,
-        updated: '2015-12-20 15:01:20.396000000',
-      },
-      {
-        id: 'sallys_confession',
-        message: 'i like you, jack',
-        updated: '2015-12-23 15:00:20.396000000',
-        line: 1,
-        __commentSide: 'left',
-      }, {
-        id: 'jacks_reply',
-        message: 'i like you, too',
-        updated: '2015-12-24 15:01:20.396000000',
-        __commentSide: 'left',
-        line: 1,
-        in_reply_to: 'sallys_confession',
-      },
-    ];
-    const sortedComments = element._sortComments(comments);
-    assert.equal(sortedComments[0], comments[1]);
-    assert.equal(sortedComments[1], comments[2]);
-    assert.equal(sortedComments[2], comments[0]);
-  });
-
-  test('_createThreads', () => {
-    const comments = [
-      {
-        id: 'sallys_confession',
-        message: 'i like you, jack',
-        updated: '2015-12-23 15:00:20.396000000',
-        line: 1,
-        __commentSide: 'left',
-      }, {
-        id: 'jacks_reply',
-        message: 'i like you, too',
-        updated: '2015-12-24 15:01:20.396000000',
-        __commentSide: 'left',
-        line: 1,
-        in_reply_to: 'sallys_confession',
-      },
-      {
-        id: 'new_draft',
-        message: 'i do not like either of you',
-        __commentSide: 'left',
-        __draft: true,
-        updated: '2015-12-20 15:01:20.396000000',
-      },
-    ];
-
-    const actualThreads = element._createThreads(comments);
-
-    assert.equal(actualThreads.length, 2);
-
-    assert.equal(
-        actualThreads[0].start_datetime, '2015-12-23 15:00:20.396000000');
-    assert.equal(actualThreads[0].commentSide, 'left');
-    assert.equal(actualThreads[0].comments.length, 2);
-    assert.deepEqual(actualThreads[0].comments[0], comments[0]);
-    assert.deepEqual(actualThreads[0].comments[1], comments[1]);
-    assert.equal(actualThreads[0].patchNum, undefined);
-    assert.equal(actualThreads[0].rootId, 'sallys_confession');
-    assert.equal(actualThreads[0].lineNum, 1);
-
-    assert.equal(
-        actualThreads[1].start_datetime, '2015-12-20 15:01:20.396000000');
-    assert.equal(actualThreads[1].commentSide, 'left');
-    assert.equal(actualThreads[1].comments.length, 1);
-    assert.deepEqual(actualThreads[1].comments[0], comments[2]);
-    assert.equal(actualThreads[1].patchNum, undefined);
-    assert.equal(actualThreads[1].rootId, 'new_draft');
-    assert.equal(actualThreads[1].lineNum, undefined);
-  });
-
-  test('_createThreads inherits patchNum and range', () => {
-    const comments = [{
-      id: 'betsys_confession',
-      message: 'i like you, jack',
-      updated: '2015-12-24 15:00:10.396000000',
-      range: {
-        start_line: 1,
-        start_character: 1,
-        end_line: 1,
-        end_character: 2,
-      },
-      patch_set: 5,
-      __commentSide: 'left',
-      line: 1,
-    }];
-
-    const expectedThreads = [
-      {
-        start_datetime: '2015-12-24 15:00:10.396000000',
-        commentSide: 'left',
-        comments: [{
-          id: 'betsys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:10.396000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 1,
-            end_character: 2,
-          },
-          patch_set: 5,
-          __commentSide: 'left',
-          line: 1,
-        }],
-        patchNum: 5,
-        rootId: 'betsys_confession',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 1,
-          end_character: 2,
-        },
-        lineNum: 1,
-        isOnParent: false,
-      },
-    ];
-
-    assert.deepEqual(
-        element._createThreads(comments),
-        expectedThreads);
-  });
-
-  test('_createThreads does not thread unrelated comments at same location',
-      () => {
-        const comments = [
-          {
-            id: 'sallys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-23 15:00:20.396000000',
-            __commentSide: 'left',
-          }, {
-            id: 'jacks_reply',
-            message: 'i like you, too',
-            updated: '2015-12-24 15:01:20.396000000',
-            __commentSide: 'left',
-          },
-        ];
-        assert.equal(element._createThreads(comments).length, 2);
-      });
-
-  test('_createThreads derives isOnParent using  side from first comment',
-      () => {
-        const comments = [
-          {
-            id: 'sallys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-23 15:00:20.396000000',
-            // line: 1,
-            // __commentSide: 'left',
-          }, {
-            id: 'jacks_reply',
-            message: 'i like you, too',
-            updated: '2015-12-24 15:01:20.396000000',
-            // __commentSide: 'left',
-            // line: 1,
-            in_reply_to: 'sallys_confession',
-          },
-        ];
-
-        assert.equal(element._createThreads(comments)[0].isOnParent, false);
-
-        comments[0].side = 'REVISION';
-        assert.equal(element._createThreads(comments)[0].isOnParent, false);
-
-        comments[0].side = 'PARENT';
-        assert.equal(element._createThreads(comments)[0].isOnParent, true);
-      });
-
-  test('_getOrCreateThread', () => {
-    const commentSide = 'left';
-
-    assert.isOk(element._getOrCreateThread('2', 3,
-        commentSide, undefined, false));
-
-    let threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
-
-    assert.equal(threads.length, 1);
-    assert.equal(threads[0].commentSide, commentSide);
-    assert.equal(threads[0].range, undefined);
-    assert.equal(threads[0].isOnParent, false);
-    assert.equal(threads[0].patchNum, 2);
-
-    // Try to fetch a thread with a different range.
-    const range = {
-      start_line: 1,
-      start_character: 1,
-      end_line: 1,
-      end_character: 3,
-    };
-
-    assert.isOk(element._getOrCreateThread(
-        '3', 1, commentSide, range, true));
-
-    threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
-
-    assert.equal(threads.length, 2);
-    assert.equal(threads[1].commentSide, commentSide);
-    assert.equal(threads[1].range, range);
-    assert.equal(threads[1].isOnParent, true);
-    assert.equal(threads[1].patchNum, 3);
-  });
-
-  test('_filterThreadElsForLocation with no threads', () => {
-    const line = {beforeNumber: 3, afterNumber: 5};
-
-    const threads = [];
-    assert.deepEqual(element._filterThreadElsForLocation(threads, line), []);
-    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
-        DiffSide.LEFT), []);
-    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
-        DiffSide.RIGHT), []);
-  });
-
-  test('_filterThreadElsForLocation for line comments', () => {
-    const line = {beforeNumber: 3, afterNumber: 5};
-
-    const l3 = document.createElement('div');
-    l3.setAttribute('line-num', 3);
-    l3.setAttribute('comment-side', 'left');
-
-    const l5 = document.createElement('div');
-    l5.setAttribute('line-num', 5);
-    l5.setAttribute('comment-side', 'left');
-
-    const r3 = document.createElement('div');
-    r3.setAttribute('line-num', 3);
-    r3.setAttribute('comment-side', 'right');
-
-    const r5 = document.createElement('div');
-    r5.setAttribute('line-num', 5);
-    r5.setAttribute('comment-side', 'right');
-
-    const threadEls = [l3, l5, r3, r5];
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
-        [l3, r5]);
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        DiffSide.LEFT), [l3]);
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        DiffSide.RIGHT), [r5]);
-  });
-
-  test('_filterThreadElsForLocation for file comments', () => {
-    const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
-
-    const l = document.createElement('div');
-    l.setAttribute('comment-side', 'left');
-    l.setAttribute('line-num', 'FILE');
-
-    const r = document.createElement('div');
-    r.setAttribute('comment-side', 'right');
-    r.setAttribute('line-num', 'FILE');
-
-    const threadEls = [l, r];
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
-        [l, r]);
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        DiffSide.BOTH), [l, r]);
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        DiffSide.LEFT), [l]);
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        DiffSide.RIGHT), [r]);
-  });
-
-  suite('syntax layer with syntax_highlighting on', () => {
-    setup(() => {
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      element.patchRange = {};
-      element.prefs = prefs;
-    });
-
-    test('gr-diff-host provides syntax highlighting layer to gr-diff', () => {
-      element.reload();
-      assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
-    });
-
-    test('rendering normal-sized diff does not disable syntax', () => {
-      element.diff = {
-        content: [{
-          a: ['foo'],
-        }],
-      };
-      assert.isTrue(element.$.syntaxLayer.enabled);
-    });
-
-    test('rendering large diff disables syntax', () => {
-      // Before it renders, set the first diff line to 500 '*' characters.
-      element.diff = {
-        content: [{
-          a: [new Array(501).join('*')],
-        }],
-      };
-      assert.isFalse(element.$.syntaxLayer.enabled);
-    });
-
-    test('starts syntax layer processing on render event', done => {
-      sandbox.stub(element.$.syntaxLayer, 'process')
-          .returns(Promise.resolve());
-      sandbox.stub(element.$.restAPI, 'getDiff').returns(
-          Promise.resolve({content: []}));
-      element.reload();
-      setTimeout(() => {
-        element.dispatchEvent(
-            new CustomEvent('render', {bubbles: true, composed: true}));
-        assert.isTrue(element.$.syntaxLayer.process.called);
-        done();
-      });
-    });
-  });
-
-  suite('syntax layer with syntax_highlgihting off', () => {
-    setup(() => {
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-      };
-      element.diff = {
-        content: [{
-          a: ['foo'],
-        }],
-      };
-      element.patchRange = {};
-      element.prefs = prefs;
-    });
-
-    test('gr-diff-host provides syntax highlighting layer', () => {
-      element.reload();
-      assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
-    });
-
-    test('syntax layer should be disabled', () => {
-      assert.isFalse(element.$.syntaxLayer.enabled);
-    });
-
-    test('still disabled for large diff', () => {
-      // Before it renders, set the first diff line to 500 '*' characters.
-      element.diff = {
-        content: [{
-          a: [new Array(501).join('*')],
-        }],
-      };
-      assert.isFalse(element.$.syntaxLayer.enabled);
-    });
-  });
-
-  suite('coverage layer', () => {
-    let notifyStub;
-    setup(() => {
-      notifyStub = sinon.stub();
-      stub('gr-js-api-interface', {
-        getCoverageAnnotationApi() {
-          return Promise.resolve({
-            notify: notifyStub,
-            getCoverageProvider() {
-              return () => Promise.resolve([
-                {
-                  type: 'COVERED',
-                  side: 'right',
-                  code_range: {
-                    start_line: 1,
-                    end_line: 2,
-                  },
-                },
-                {
-                  type: 'NOT_COVERED',
-                  side: 'right',
-                  code_range: {
-                    start_line: 3,
-                    end_line: 4,
-                  },
-                },
-              ]);
-            },
-          });
-        },
-      });
-      element = fixture('basic');
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-      };
-      element.diff = {
-        content: [{
-          a: ['foo'],
-        }],
-      };
-      element.patchRange = {};
-      element.prefs = prefs;
-    });
-
-    test('getCoverageAnnotationApi should be called', done => {
-      element.reload();
-      flush(() => {
-        assert.isTrue(element.$.jsAPI.getCoverageAnnotationApi.calledOnce);
-        done();
-      });
-    });
-
-    test('coverageRangeChanged should be called', done => {
-      element.reload();
-      flush(() => {
-        assert.equal(notifyStub.callCount, 2);
-        done();
-      });
-    });
-  });
-
-  suite('trailing newlines', () => {
-    setup(() => {
-    });
-
-    suite('_lastChunkForSide', () => {
-      test('deltas', () => {
-        const diff = {content: [
-          {a: ['foo', 'bar'], b: ['baz']},
-          {ab: ['foo', 'bar', 'baz']},
-          {b: ['foo']},
-        ]};
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);
-
-        diff.content.push({a: ['foo'], b: ['bar']});
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
-      });
-
-      test('addition with a undefined', () => {
-        const diff = {content: [
-          {b: ['foo', 'bar', 'baz']},
-        ]};
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
-        assert.isNull(element._lastChunkForSide(diff, true));
-      });
-
-      test('addition with a empty', () => {
-        const diff = {content: [
-          {a: [], b: ['foo', 'bar', 'baz']},
-        ]};
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
-        assert.isNull(element._lastChunkForSide(diff, true));
-      });
-
-      test('deletion with b undefined', () => {
-        const diff = {content: [
-          {a: ['foo', 'bar', 'baz']},
-        ]};
-        assert.isNull(element._lastChunkForSide(diff, false));
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
-      });
-
-      test('deletion with b empty', () => {
-        const diff = {content: [
-          {a: ['foo', 'bar', 'baz'], b: []},
-        ]};
-        assert.isNull(element._lastChunkForSide(diff, false));
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
-      });
-
-      test('empty', () => {
-        const diff = {content: []};
-        assert.isNull(element._lastChunkForSide(diff, false));
-        assert.isNull(element._lastChunkForSide(diff, true));
-      });
-    });
-
-    suite('_hasTrailingNewlines', () => {
-      test('shared no trailing', () => {
-        const diff = undefined;
-        sandbox.stub(element, '_lastChunkForSide')
-            .returns({ab: ['foo', 'bar']});
-        assert.isFalse(element._hasTrailingNewlines(diff, false));
-        assert.isFalse(element._hasTrailingNewlines(diff, true));
-      });
-
-      test('delta trailing in right', () => {
-        const diff = undefined;
-        sandbox.stub(element, '_lastChunkForSide')
-            .returns({a: ['foo', 'bar'], b: ['baz', '']});
-        assert.isTrue(element._hasTrailingNewlines(diff, false));
-        assert.isFalse(element._hasTrailingNewlines(diff, true));
-      });
-
-      test('addition', () => {
-        const diff = undefined;
-        sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
-          if (leftSide) { return null; }
-          return {b: ['foo', '']};
-        });
-        assert.isTrue(element._hasTrailingNewlines(diff, false));
-        assert.isNull(element._hasTrailingNewlines(diff, true));
-      });
-
-      test('deletion', () => {
-        const diff = undefined;
-        sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
-          if (!leftSide) { return null; }
-          return {a: ['foo']};
-        });
-        assert.isNull(element._hasTrailingNewlines(diff, false));
-        assert.isFalse(element._hasTrailingNewlines(diff, true));
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
new file mode 100644
index 0000000..d0e6b62
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -0,0 +1,1688 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-diff-host.js';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {DiffSide} from '../gr-diff/gr-diff-utils.js';
+
+const basicFixture = fixtureFromElement('gr-diff-host');
+
+suite('gr-diff-host tests', () => {
+  let element;
+
+  let getLoggedIn;
+
+  setup(() => {
+    getLoggedIn = false;
+    stub('gr-rest-api-interface', {
+      async getLoggedIn() { return getLoggedIn; },
+    });
+    element = basicFixture.instantiate();
+    sinon.stub(element.reporting, 'time');
+    sinon.stub(element.reporting, 'timeEnd');
+  });
+
+  suite('plugin layers', () => {
+    const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
+    setup(() => {
+      stub('gr-js-api-interface', {
+        getDiffLayers() { return pluginLayers; },
+      });
+      element = basicFixture.instantiate();
+    });
+    test('plugin layers requested', () => {
+      element.patchRange = {};
+      element.reload();
+      assert(element.$.jsAPI.getDiffLayers.called);
+    });
+  });
+
+  suite('handle comment-update', () => {
+    setup(() => {
+      sinon.stub(element, '_commentsChanged');
+      element.comments = {
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 3,
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
+        },
+        left: [
+          {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+          {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        ],
+        right: [
+          {id: 'c1', __commentSide: 'right'},
+          {id: 'c2', __commentSide: 'right'},
+          {id: 'd1', __draft: true, __commentSide: 'right'},
+          {id: 'd2', __draft: true, __commentSide: 'right'},
+        ],
+      };
+    });
+
+    test('creating a draft', () => {
+      const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
+        __commentSide: 'left'};
+      element.dispatchEvent(
+          new CustomEvent('comment-update', {
+            detail: {comment},
+            composed: true, bubbles: true,
+          }));
+      assert.include(element.comments.left, comment);
+    });
+
+    test('discarding a draft', () => {
+      const draftID = 'tempID';
+      const id = 'savedID';
+      const comment = {
+        __draft: true,
+        __draftID: draftID,
+        side: 'PARENT',
+        __commentSide: 'left',
+      };
+      const diffCommentsModifiedStub = sinon.stub();
+      element.addEventListener('diff-comments-modified',
+          diffCommentsModifiedStub);
+      element.comments.left.push(comment);
+      comment.id = id;
+      element.dispatchEvent(
+          new CustomEvent('comment-discard', {
+            detail: {comment},
+            composed: true, bubbles: true,
+          }));
+      const drafts = element.comments.left
+          .filter(item => item.__draftID === draftID);
+      assert.equal(drafts.length, 0);
+      assert.isTrue(diffCommentsModifiedStub.called);
+    });
+
+    test('saving a draft', () => {
+      const draftID = 'tempID';
+      const id = 'savedID';
+      const comment = {
+        __draft: true,
+        __draftID: draftID,
+        side: 'PARENT',
+        __commentSide: 'left',
+      };
+      const diffCommentsModifiedStub = sinon.stub();
+      element.addEventListener('diff-comments-modified',
+          diffCommentsModifiedStub);
+      element.comments.left.push(comment);
+      comment.id = id;
+      element.dispatchEvent(
+          new CustomEvent('comment-save', {
+            detail: {comment},
+            composed: true, bubbles: true,
+          }));
+      const drafts = element.comments.left
+          .filter(item => item.__draftID === draftID);
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].id, id);
+      assert.isTrue(diffCommentsModifiedStub.called);
+    });
+  });
+
+  test('remove comment', () => {
+    sinon.stub(element, '_commentsChanged');
+    element.comments = {
+      meta: {
+        changeNum: '42',
+        patchRange: {
+          basePatchNum: 'PARENT',
+          patchNum: 3,
+        },
+        path: '/path/to/foo',
+        projectConfig: {foo: 'bar'},
+      },
+      left: [
+        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+      ],
+      right: [
+        {id: 'c1', __commentSide: 'right'},
+        {id: 'c2', __commentSide: 'right'},
+        {id: 'd1', __draft: true, __commentSide: 'right'},
+        {id: 'd2', __draft: true, __commentSide: 'right'},
+      ],
+    };
+
+    element._removeComment({});
+    // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
+    // to believe that one object deepEquals another even when they do :-/.
+    assert.equal(JSON.stringify(element.comments), JSON.stringify({
+      meta: {
+        changeNum: '42',
+        patchRange: {
+          basePatchNum: 'PARENT',
+          patchNum: 3,
+        },
+        path: '/path/to/foo',
+        projectConfig: {foo: 'bar'},
+      },
+      left: [
+        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+      ],
+      right: [
+        {id: 'c1', __commentSide: 'right'},
+        {id: 'c2', __commentSide: 'right'},
+        {id: 'd1', __draft: true, __commentSide: 'right'},
+        {id: 'd2', __draft: true, __commentSide: 'right'},
+      ],
+    }));
+
+    element._removeComment({id: 'bc2', side: 'PARENT',
+      __commentSide: 'left'});
+    assert.deepEqual(element.comments, {
+      meta: {
+        changeNum: '42',
+        patchRange: {
+          basePatchNum: 'PARENT',
+          patchNum: 3,
+        },
+        path: '/path/to/foo',
+        projectConfig: {foo: 'bar'},
+      },
+      left: [
+        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+      ],
+      right: [
+        {id: 'c1', __commentSide: 'right'},
+        {id: 'c2', __commentSide: 'right'},
+        {id: 'd1', __draft: true, __commentSide: 'right'},
+        {id: 'd2', __draft: true, __commentSide: 'right'},
+      ],
+    });
+
+    element._removeComment({id: 'd2', __commentSide: 'right'});
+    assert.deepEqual(element.comments, {
+      meta: {
+        changeNum: '42',
+        patchRange: {
+          basePatchNum: 'PARENT',
+          patchNum: 3,
+        },
+        path: '/path/to/foo',
+        projectConfig: {foo: 'bar'},
+      },
+      left: [
+        {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+        {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+      ],
+      right: [
+        {id: 'c1', __commentSide: 'right'},
+        {id: 'c2', __commentSide: 'right'},
+        {id: 'd1', __draft: true, __commentSide: 'right'},
+      ],
+    });
+  });
+
+  test('thread-discard handling', () => {
+    const threads = [
+      {comments: [{id: 4711}]},
+      {comments: [{id: 42}]},
+    ];
+    element._parentIndex = 1;
+    element.changeNum = '2';
+    element.path = 'some/path';
+    element.projectName = 'Some project';
+    const threadEls = threads.map(
+        thread => {
+          const threadEl = element._createThreadElement(thread);
+          // Polymer 2 doesn't fire ready events and doesn't execute
+          // observers if element is not added to the Dom.
+          // See https://github.com/Polymer/old-docs-site/issues/2322
+          // and https://github.com/Polymer/polymer/issues/4526
+          element._attachThreadElement(threadEl);
+          return threadEl;
+        });
+    assert.equal(threadEls.length, 2);
+    assert.equal(threadEls[0].rootId, 4711);
+    assert.equal(threadEls[1].rootId, 42);
+    for (const threadEl of threadEls) {
+      dom(element).appendChild(threadEl);
+    }
+
+    threadEls[0].dispatchEvent(
+        new CustomEvent('thread-discard', {detail: {rootId: 4711}}));
+    const attachedThreads = element.queryAllEffectiveChildren(
+        'gr-comment-thread');
+    assert.equal(attachedThreads.length, 1);
+    assert.equal(attachedThreads[0].rootId, 42);
+  });
+
+  suite('render reporting', () => {
+    test('starts total and content timer on render-start', done => {
+      element.dispatchEvent(
+          new CustomEvent('render-start', {bubbles: true, composed: true}));
+      assert.isTrue(element.reporting.time.calledWithExactly(
+          'Diff Total Render'));
+      assert.isTrue(element.reporting.time.calledWithExactly(
+          'Diff Content Render'));
+      done();
+    });
+
+    test('ends content timer on render-content', () => {
+      element.dispatchEvent(
+          new CustomEvent('render-content', {bubbles: true, composed: true}));
+      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
+          'Diff Content Render'));
+    });
+
+    test('ends total and syntax timer after syntax layer processing', done => {
+      sinon.stub(element.reporting, 'diffViewContentDisplayed');
+      let notifySyntaxProcessed;
+      sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
+          resolve => {
+            notifySyntaxProcessed = resolve;
+          }));
+      sinon.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.patchRange = {};
+      element.$.restAPI.getDiffPreferences().then(prefs => {
+        element.prefs = prefs;
+        return element.reload(true);
+      });
+      // Multiple cascading microtasks are scheduled.
+      setTimeout(() => {
+        notifySyntaxProcessed();
+        // Assert after the notification task is processed.
+        Promise.resolve().then(() => {
+          assert.isTrue(element.reporting.timeEnd.calledWithExactly(
+              'Diff Total Render'));
+          assert.isTrue(element.reporting.timeEnd.calledWithExactly(
+              'Diff Syntax Render'));
+          assert.isTrue(element.reporting.diffViewContentDisplayed.called);
+          done();
+        });
+      });
+    });
+
+    test('ends total timer w/ no syntax layer processing', done => {
+      sinon.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.patchRange = {};
+      element.reload();
+      // Multiple cascading microtasks are scheduled.
+      setTimeout(() => {
+        // Reporting can be called with other parameters (ex. PluginsLoaded),
+        // but only 'Diff Total Render' is important in this test.
+        assert.equal(
+            element.reporting.timeEnd.getCalls()
+                .filter(call => call.calledWithExactly('Diff Total Render'))
+                .length,
+            1);
+        done();
+      });
+    });
+
+    test('completes reload promise after syntax layer processing', done => {
+      let notifySyntaxProcessed;
+      sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
+          resolve => {
+            notifySyntaxProcessed = resolve;
+          }));
+      sinon.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.patchRange = {};
+      let reloadComplete = false;
+      element.$.restAPI.getDiffPreferences()
+          .then(prefs => {
+            element.prefs = prefs;
+            return element.reload();
+          })
+          .then(() => {
+            reloadComplete = true;
+          });
+      // Multiple cascading microtasks are scheduled.
+      setTimeout(() => {
+        assert.isFalse(reloadComplete);
+        notifySyntaxProcessed();
+        // Assert after the notification task is processed.
+        setTimeout(() => {
+          assert.isTrue(reloadComplete);
+          done();
+        });
+      });
+    });
+  });
+
+  test('reload() cancels before network resolves', () => {
+    const cancelStub = sinon.stub(element.$.diff, 'cancel');
+
+    // Stub the network calls into requests that never resolve.
+    sinon.stub(element, '_getDiff').callsFake(() => new Promise(() => {}));
+    element.patchRange = {};
+
+    element.reload();
+    assert.isTrue(cancelStub.called);
+  });
+
+  suite('not logged in', () => {
+    setup(() => {
+      getLoggedIn = false;
+      element = basicFixture.instantiate();
+    });
+
+    test('reload() loads files weblinks', () => {
+      const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
+          .returns({name: 'stubb', url: '#s'});
+      sinon.stub(element.$.restAPI, 'getDiff').returns(Promise.resolve({
+        content: [],
+      }));
+      element.projectName = 'test-project';
+      element.path = 'test-path';
+      element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
+      element.patchRange = {};
+      return element.reload().then(() => {
+        assert.isTrue(weblinksStub.calledTwice);
+        assert.isTrue(weblinksStub.firstCall.calledWith({
+          commit: 'test-base',
+          file: 'test-path',
+          options: {
+            weblinks: undefined,
+          },
+          repo: 'test-project',
+          type: GerritNav.WeblinkType.FILE}));
+        assert.isTrue(weblinksStub.secondCall.calledWith({
+          commit: 'test-commit',
+          file: 'test-path',
+          options: {
+            weblinks: undefined,
+          },
+          repo: 'test-project',
+          type: GerritNav.WeblinkType.FILE}));
+        assert.deepEqual(element.filesWeblinks, {
+          meta_a: [{name: 'stubb', url: '#s'}],
+          meta_b: [{name: 'stubb', url: '#s'}],
+        });
+      });
+    });
+
+    test('prefetch getDiff', done => {
+      const diffRestApiStub = sinon.stub(element.$.restAPI, 'getDiff')
+          .returns(Promise.resolve({content: []}));
+      element.changeNum = 123;
+      element.patchRange = {basePatchNum: 1, patchNum: 2};
+      element.path = 'file.txt';
+      element.prefetchDiff();
+      element._getDiff().then(() =>{
+        assert.isTrue(diffRestApiStub.calledOnce);
+        done();
+      });
+    });
+
+    test('_getDiff handles null diff responses', done => {
+      stub('gr-rest-api-interface', {
+        getDiff() { return Promise.resolve(null); },
+      });
+      element.changeNum = 123;
+      element.patchRange = {basePatchNum: 1, patchNum: 2};
+      element.path = 'file.txt';
+      element._getDiff().then(done);
+    });
+
+    test('reload resolves on error', () => {
+      const onErrStub = sinon.stub(element, '_handleGetDiffError');
+      const error = {ok: false, status: 500};
+      sinon.stub(element.$.restAPI, 'getDiff').callsFake(
+          (changeNum, basePatchNum, patchNum, path, onErr) => {
+            onErr(error);
+          });
+      element.patchRange = {};
+      return element.reload().then(() => {
+        assert.isTrue(onErrStub.calledOnce);
+      });
+    });
+
+    suite('_handleGetDiffError', () => {
+      let serverErrorStub;
+      let pageErrorStub;
+
+      setup(() => {
+        serverErrorStub = sinon.stub();
+        element.addEventListener('server-error', serverErrorStub);
+        pageErrorStub = sinon.stub();
+        element.addEventListener('page-error', pageErrorStub);
+      });
+
+      test('page error on HTTP-409', () => {
+        element._handleGetDiffError({status: 409});
+        assert.isTrue(serverErrorStub.calledOnce);
+        assert.isFalse(pageErrorStub.called);
+        assert.isNotOk(element._errorMessage);
+      });
+
+      test('server error on non-HTTP-409', () => {
+        element._handleGetDiffError({status: 500});
+        assert.isFalse(serverErrorStub.called);
+        assert.isTrue(pageErrorStub.calledOnce);
+        assert.isNotOk(element._errorMessage);
+      });
+
+      test('error message if showLoadFailure', () => {
+        element.showLoadFailure = true;
+        element._handleGetDiffError({status: 500, statusText: 'Failure!'});
+        assert.isFalse(serverErrorStub.called);
+        assert.isFalse(pageErrorStub.called);
+        assert.equal(element._errorMessage,
+            'Encountered error when loading the diff: 500 Failure!');
+      });
+    });
+
+    suite('image diffs', () => {
+      let mockFile1;
+      let mockFile2;
+      setup(() => {
+        mockFile1 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAAAAAA/w==',
+          type: 'image/bmp',
+        };
+        mockFile2 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAA/////w==',
+          type: 'image/bmp',
+        };
+        sinon.stub(element.$.restAPI,
+            'getB64FileContents')
+            .callsFake(
+                (changeId, patchNum, path, opt_parentIndex) => Promise.resolve(
+                    opt_parentIndex === 1 ? mockFile1 : mockFile2)
+            );
+
+        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+        element.comments = {
+          left: [],
+          right: [],
+          meta: {patchRange: element.patchRange},
+        };
+      });
+
+      test('renders image diffs with same file name', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        sinon.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
+
+        const rendered = () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          // Left image rendered with the parent commit's version of the file.
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          const leftLabel =
+              element.$.diff.$.diffTable.querySelector('td.left label');
+          const leftLabelContent = leftLabel.querySelector('.label');
+          const leftLabelName = leftLabel.querySelector('.name');
+
+          const rightImage =
+              element.$.diff.$.diffTable.querySelector('td.right img');
+          const rightLabel = element.$.diff.$.diffTable.querySelector(
+              'td.right label');
+          const rightLabelContent = rightLabel.querySelector('.label');
+          const rightLabelName = rightLabel.querySelector('.name');
+
+          assert.isNotOk(rightLabelName);
+          assert.isNotOk(leftLabelName);
+
+          let leftLoaded = false;
+          let rightLoaded = false;
+
+          leftImage.addEventListener('load', () => {
+            assert.isOk(leftImage);
+            assert.equal(leftImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile1.body);
+            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+            leftLoaded = true;
+            if (rightLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+
+          rightImage.addEventListener('load', () => {
+            assert.isOk(rightImage);
+            assert.equal(rightImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile2.body);
+            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+            rightLoaded = true;
+            if (leftLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+        };
+
+        element.addEventListener('render', rendered);
+
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
+        });
+      });
+
+      test('renders image diffs with a different file name', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot2.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot2.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        sinon.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
+
+        const rendered = () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          // Left image rendered with the parent commit's version of the file.
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          const leftLabel =
+              element.$.diff.$.diffTable.querySelector('td.left label');
+          const leftLabelContent = leftLabel.querySelector('.label');
+          const leftLabelName = leftLabel.querySelector('.name');
+
+          const rightImage =
+              element.$.diff.$.diffTable.querySelector('td.right img');
+          const rightLabel = element.$.diff.$.diffTable.querySelector(
+              'td.right label');
+          const rightLabelContent = rightLabel.querySelector('.label');
+          const rightLabelName = rightLabel.querySelector('.name');
+
+          assert.isOk(rightLabelName);
+          assert.isOk(leftLabelName);
+          assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+          assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+          let leftLoaded = false;
+          let rightLoaded = false;
+
+          leftImage.addEventListener('load', () => {
+            assert.isOk(leftImage);
+            assert.equal(leftImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile1.body);
+            assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
+            leftLoaded = true;
+            if (rightLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+
+          rightImage.addEventListener('load', () => {
+            assert.isOk(rightImage);
+            assert.equal(rightImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile2.body);
+            assert.equal(rightLabelContent.textContent, '1×1 image/bmp');
+
+            rightLoaded = true;
+            if (leftLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+        };
+
+        element.addEventListener('render', rendered);
+
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
+        });
+      });
+
+      test('renders added image', done => {
+        const mockDiff = {
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'ADDED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 0000000..f9c2f2c 100644',
+            '--- /dev/null',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        sinon.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
+
+        element.addEventListener('render', () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          const rightImage =
+              element.$.diff.$.diffTable.querySelector('td.right img');
+
+          assert.isNotOk(leftImage);
+          assert.isOk(rightImage);
+          done();
+        });
+
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
+        });
+      });
+
+      test('renders removed image', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        sinon.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
+
+        element.addEventListener('render', () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          const rightImage =
+              element.$.diff.$.diffTable.querySelector('td.right img');
+
+          assert.isOk(leftImage);
+          assert.isNotOk(rightImage);
+          done();
+        });
+
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
+        });
+      });
+
+      test('does not render disallowed image type', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        mockFile1.type = 'image/jpeg-evil';
+
+        sinon.stub(element.$.restAPI, 'getDiff')
+            .returns(Promise.resolve(mockDiff));
+
+        element.addEventListener('render', () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
+          const leftImage =
+              element.$.diff.$.diffTable.querySelector('td.left img');
+          assert.isNotOk(leftImage);
+          done();
+        });
+
+        element.$.restAPI.getDiffPreferences().then(prefs => {
+          element.prefs = prefs;
+          element.reload();
+        });
+      });
+    });
+  });
+
+  test('delegates cancel()', () => {
+    const stub = sinon.stub(element.$.diff, 'cancel');
+    element.patchRange = {};
+    element.reload();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates getCursorStops()', () => {
+    const returnValue = [document.createElement('b')];
+    const stub = sinon.stub(element.$.diff, 'getCursorStops')
+        .returns(returnValue);
+    assert.equal(element.getCursorStops(), returnValue);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates isRangeSelected()', () => {
+    const returnValue = true;
+    const stub = sinon.stub(element.$.diff, 'isRangeSelected')
+        .returns(returnValue);
+    assert.equal(element.isRangeSelected(), returnValue);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates toggleLeftDiff()', () => {
+    const stub = sinon.stub(element.$.diff, 'toggleLeftDiff');
+    element.toggleLeftDiff();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  suite('blame', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+    });
+
+    test('clearBlame', () => {
+      element._blame = [];
+      const setBlameSpy = sinon.spy(element.$.diff.$.diffBuilder, 'setBlame');
+      element.clearBlame();
+      assert.isNull(element._blame);
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.equal(element.isBlameLoaded, false);
+    });
+
+    test('loadBlame', () => {
+      const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
+      const showAlertStub = sinon.stub();
+      element.addEventListener('show-alert', showAlertStub);
+      const getBlameStub = sinon.stub(element.$.restAPI, 'getBlame')
+          .returns(Promise.resolve(mockBlame));
+      element.changeNum = 42;
+      element.patchRange = {patchNum: 5, basePatchNum: 4};
+      element.path = 'foo/bar.baz';
+      return element.loadBlame().then(() => {
+        assert.isTrue(getBlameStub.calledWithExactly(
+            42, 5, 'foo/bar.baz', true));
+        assert.isFalse(showAlertStub.called);
+        assert.equal(element._blame, mockBlame);
+        assert.equal(element.isBlameLoaded, true);
+      });
+    });
+
+    test('loadBlame empty', () => {
+      const mockBlame = [];
+      const showAlertStub = sinon.stub();
+      element.addEventListener('show-alert', showAlertStub);
+      sinon.stub(element.$.restAPI, 'getBlame')
+          .returns(Promise.resolve(mockBlame));
+      element.changeNum = 42;
+      element.patchRange = {patchNum: 5, basePatchNum: 4};
+      element.path = 'foo/bar.baz';
+      return element.loadBlame()
+          .then(() => {
+            assert.isTrue(false, 'Promise should not resolve');
+          })
+          .catch(() => {
+            assert.isTrue(showAlertStub.calledOnce);
+            assert.isNull(element._blame);
+            assert.equal(element.isBlameLoaded, false);
+          });
+    });
+  });
+
+  test('getThreadEls() returns .comment-threads', () => {
+    const threadEl = document.createElement('div');
+    threadEl.className = 'comment-thread';
+    dom(element.$.diff).appendChild(threadEl);
+    assert.deepEqual(element.getThreadEls(), [threadEl]);
+  });
+
+  test('delegates addDraftAtLine(el)', () => {
+    const param0 = document.createElement('b');
+    const stub = sinon.stub(element.$.diff, 'addDraftAtLine');
+    element.addDraftAtLine(param0);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 1);
+    assert.equal(stub.lastCall.args[0], param0);
+  });
+
+  test('delegates clearDiffContent()', () => {
+    const stub = sinon.stub(element.$.diff, 'clearDiffContent');
+    element.clearDiffContent();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates expandAllContext()', () => {
+    const stub = sinon.stub(element.$.diff, 'expandAllContext');
+    element.expandAllContext();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('passes in changeNum', () => {
+    const value = '12345';
+    element.changeNum = value;
+    assert.equal(element.$.diff.changeNum, value);
+  });
+
+  test('passes in noAutoRender', () => {
+    const value = true;
+    element.noAutoRender = value;
+    assert.equal(element.$.diff.noAutoRender, value);
+  });
+
+  test('passes in patchRange', () => {
+    const value = {patchNum: 'foo', basePatchNum: 'bar'};
+    element.patchRange = value;
+    assert.equal(element.$.diff.patchRange, value);
+  });
+
+  test('passes in path', () => {
+    const value = 'some/file/path';
+    element.path = value;
+    assert.equal(element.$.diff.path, value);
+  });
+
+  test('passes in prefs', () => {
+    const value = {};
+    element.prefs = value;
+    assert.equal(element.$.diff.prefs, value);
+  });
+
+  test('passes in changeNum', () => {
+    const value = '12345';
+    element.changeNum = value;
+    assert.equal(element.$.diff.changeNum, value);
+  });
+
+  test('passes in projectName', () => {
+    const value = 'Gerrit';
+    element.projectName = value;
+    assert.equal(element.$.diff.projectName, value);
+  });
+
+  test('passes in displayLine', () => {
+    const value = true;
+    element.displayLine = value;
+    assert.equal(element.$.diff.displayLine, value);
+  });
+
+  test('passes in commitRange', () => {
+    const value = {};
+    element.commitRange = value;
+    assert.equal(element.$.diff.commitRange, value);
+  });
+
+  test('passes in hidden', () => {
+    const value = true;
+    element.hidden = value;
+    assert.equal(element.$.diff.hidden, value);
+    assert.isNotNull(element.getAttribute('hidden'));
+  });
+
+  test('passes in noRenderOnPrefsChange', () => {
+    const value = true;
+    element.noRenderOnPrefsChange = value;
+    assert.equal(element.$.diff.noRenderOnPrefsChange, value);
+  });
+
+  test('passes in lineWrapping', () => {
+    const value = true;
+    element.lineWrapping = value;
+    assert.equal(element.$.diff.lineWrapping, value);
+  });
+
+  test('passes in viewMode', () => {
+    const value = 'SIDE_BY_SIDE';
+    element.viewMode = value;
+    assert.equal(element.$.diff.viewMode, value);
+  });
+
+  test('passes in lineOfInterest', () => {
+    const value = {number: 123, leftSide: true};
+    element.lineOfInterest = value;
+    assert.equal(element.$.diff.lineOfInterest, value);
+  });
+
+  suite('_reportDiff', () => {
+    let reportStub;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.path = 'file.txt';
+      element.patchRange = {basePatchNum: 1};
+      reportStub = sinon.stub(element.reporting, 'reportInteraction');
+    });
+
+    test('null and content-less', () => {
+      element._reportDiff(null);
+      assert.isFalse(reportStub.called);
+
+      element._reportDiff({});
+      assert.isFalse(reportStub.called);
+    });
+
+    test('diff w/ no delta', () => {
+      const diff = {
+        content: [
+          {ab: ['foo', 'bar']},
+          {ab: ['baz', 'foo']},
+        ],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+
+    test('diff w/ no rebase delta', () => {
+      const diff = {
+        content: [
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo']},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], b: ['bar', 'baz']},
+          {ab: ['foo', 'bar']},
+          {b: ['baz', 'foo']},
+          {ab: ['foo', 'bar']},
+        ],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+
+    test('diff w/ some rebase delta', () => {
+      const diff = {
+        content: [
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], due_to_rebase: true},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], b: ['bar', 'baz']},
+          {ab: ['foo', 'bar']},
+          {b: ['baz', 'foo'], due_to_rebase: true},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo']},
+        ],
+      };
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.isTrue(reportStub.calledWith(
+          'rebase-percent-nonzero',
+          {percentRebaseDelta: 50}
+      ));
+    });
+
+    test('diff w/ all rebase delta', () => {
+      const diff = {content: [{
+        a: ['foo', 'bar'],
+        b: ['baz', 'foo'],
+        due_to_rebase: true,
+      }]};
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.isTrue(reportStub.calledWith(
+          'rebase-percent-nonzero',
+          {percentRebaseDelta: 100}
+      ));
+    });
+
+    test('diff against parent event', () => {
+      element.patchRange.basePatchNum = 'PARENT';
+      const diff = {content: [{
+        a: ['foo', 'bar'],
+        b: ['baz', 'foo'],
+      }]};
+      element._reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+  });
+
+  test('comments sorting', () => {
+    const comments = [
+      {
+        id: 'new_draft',
+        message: 'i do not like either of you',
+        __commentSide: 'left',
+        __draft: true,
+        updated: '2015-12-20 15:01:20.396000000',
+      },
+      {
+        id: 'sallys_confession',
+        message: 'i like you, jack',
+        updated: '2015-12-23 15:00:20.396000000',
+        line: 1,
+        __commentSide: 'left',
+      }, {
+        id: 'jacks_reply',
+        message: 'i like you, too',
+        updated: '2015-12-24 15:01:20.396000000',
+        __commentSide: 'left',
+        line: 1,
+        in_reply_to: 'sallys_confession',
+      },
+    ];
+    const sortedComments = element._sortComments(comments);
+    assert.equal(sortedComments[0], comments[1]);
+    assert.equal(sortedComments[1], comments[2]);
+    assert.equal(sortedComments[2], comments[0]);
+  });
+
+  test('_createThreads', () => {
+    const comments = [
+      {
+        id: 'sallys_confession',
+        message: 'i like you, jack',
+        updated: '2015-12-23 15:00:20.396000000',
+        line: 1,
+        __commentSide: 'left',
+      }, {
+        id: 'jacks_reply',
+        message: 'i like you, too',
+        updated: '2015-12-24 15:01:20.396000000',
+        __commentSide: 'left',
+        line: 1,
+        in_reply_to: 'sallys_confession',
+      },
+      {
+        id: 'new_draft',
+        message: 'i do not like either of you',
+        __commentSide: 'left',
+        __draft: true,
+        updated: '2015-12-20 15:01:20.396000000',
+      },
+    ];
+
+    const actualThreads = element._createThreads(comments);
+
+    assert.equal(actualThreads.length, 2);
+
+    assert.equal(
+        actualThreads[0].start_datetime, '2015-12-23 15:00:20.396000000');
+    assert.equal(actualThreads[0].commentSide, 'left');
+    assert.equal(actualThreads[0].comments.length, 2);
+    assert.deepEqual(actualThreads[0].comments[0], comments[0]);
+    assert.deepEqual(actualThreads[0].comments[1], comments[1]);
+    assert.equal(actualThreads[0].patchNum, undefined);
+    assert.equal(actualThreads[0].rootId, 'sallys_confession');
+    assert.equal(actualThreads[0].lineNum, 1);
+
+    assert.equal(
+        actualThreads[1].start_datetime, '2015-12-20 15:01:20.396000000');
+    assert.equal(actualThreads[1].commentSide, 'left');
+    assert.equal(actualThreads[1].comments.length, 1);
+    assert.deepEqual(actualThreads[1].comments[0], comments[2]);
+    assert.equal(actualThreads[1].patchNum, undefined);
+    assert.equal(actualThreads[1].rootId, 'new_draft');
+    assert.equal(actualThreads[1].lineNum, undefined);
+  });
+
+  test('_createThreads inherits patchNum and range', () => {
+    const comments = [{
+      id: 'betsys_confession',
+      message: 'i like you, jack',
+      updated: '2015-12-24 15:00:10.396000000',
+      range: {
+        start_line: 1,
+        start_character: 1,
+        end_line: 1,
+        end_character: 2,
+      },
+      patch_set: 5,
+      __commentSide: 'left',
+      line: 1,
+    }];
+
+    const expectedThreads = [
+      {
+        start_datetime: '2015-12-24 15:00:10.396000000',
+        commentSide: 'left',
+        comments: [{
+          id: 'betsys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:10.396000000',
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 1,
+            end_character: 2,
+          },
+          patch_set: 5,
+          __commentSide: 'left',
+          line: 1,
+        }],
+        patchNum: 5,
+        rootId: 'betsys_confession',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 2,
+        },
+        lineNum: 1,
+        isOnParent: false,
+      },
+    ];
+
+    assert.deepEqual(
+        element._createThreads(comments),
+        expectedThreads);
+  });
+
+  test('_createThreads does not thread unrelated comments at same location',
+      () => {
+        const comments = [
+          {
+            id: 'sallys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-23 15:00:20.396000000',
+            __commentSide: 'left',
+          }, {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            updated: '2015-12-24 15:01:20.396000000',
+            __commentSide: 'left',
+          },
+        ];
+        assert.equal(element._createThreads(comments).length, 2);
+      });
+
+  test('_createThreads derives isOnParent using  side from first comment',
+      () => {
+        const comments = [
+          {
+            id: 'sallys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-23 15:00:20.396000000',
+            // line: 1,
+            // __commentSide: 'left',
+          }, {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            updated: '2015-12-24 15:01:20.396000000',
+            // __commentSide: 'left',
+            // line: 1,
+            in_reply_to: 'sallys_confession',
+          },
+        ];
+
+        assert.equal(element._createThreads(comments)[0].isOnParent, false);
+
+        comments[0].side = 'REVISION';
+        assert.equal(element._createThreads(comments)[0].isOnParent, false);
+
+        comments[0].side = 'PARENT';
+        assert.equal(element._createThreads(comments)[0].isOnParent, true);
+      });
+
+  test('_getOrCreateThread', () => {
+    const commentSide = 'left';
+
+    assert.isOk(element._getOrCreateThread('2', 3,
+        commentSide, undefined, false));
+
+    let threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
+
+    assert.equal(threads.length, 1);
+    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].range, undefined);
+    assert.equal(threads[0].isOnParent, false);
+    assert.equal(threads[0].patchNum, 2);
+
+    // Try to fetch a thread with a different range.
+    const range = {
+      start_line: 1,
+      start_character: 1,
+      end_line: 1,
+      end_character: 3,
+    };
+
+    assert.isOk(element._getOrCreateThread(
+        '3', 1, commentSide, range, true));
+
+    threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
+
+    assert.equal(threads.length, 2);
+    assert.equal(threads[1].commentSide, commentSide);
+    assert.equal(threads[1].range, range);
+    assert.equal(threads[1].isOnParent, true);
+    assert.equal(threads[1].patchNum, 3);
+  });
+
+  test('thread should use old file path if first created' +
+   'on patch set (left) before renaming', () => {
+    const commentSide = 'left';
+    element.file = {basePath: 'file_renamed.txt', path: element.path};
+
+    assert.isOk(element._getOrCreateThread('2', 3,
+        commentSide, undefined, /* isOnParent= */ false));
+
+    const threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
+
+    assert.equal(threads.length, 1);
+    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].path, element.file.basePath);
+  });
+
+  test('thread should use new file path if first created' +
+   'on patch set (right) after renaming', () => {
+    const commentSide = 'right';
+    element.file = {basePath: 'file_renamed.txt', path: element.path};
+
+    assert.isOk(element._getOrCreateThread('2', 3,
+        commentSide, undefined, /* isOnParent= */ false));
+
+    const threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
+
+    assert.equal(threads.length, 1);
+    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].path, element.file.path);
+  });
+
+  test('thread should use new file path if first created' +
+   'on patch set (left) but is base', () => {
+    const commentSide = 'left';
+    element.file = {basePath: 'file_renamed.txt', path: element.path};
+
+    assert.isOk(element._getOrCreateThread('2', 3,
+        commentSide, undefined, /* isOnParent= */ true));
+
+    const threads = dom(element.$.diff)
+        .queryDistributedElements('gr-comment-thread');
+
+    assert.equal(threads.length, 1);
+    assert.equal(threads[0].commentSide, commentSide);
+    assert.equal(threads[0].path, element.file.path);
+  });
+
+  test('_filterThreadElsForLocation with no threads', () => {
+    const line = {beforeNumber: 3, afterNumber: 5};
+
+    const threads = [];
+    assert.deepEqual(element._filterThreadElsForLocation(threads, line), []);
+    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
+        DiffSide.LEFT), []);
+    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
+        DiffSide.RIGHT), []);
+  });
+
+  test('_filterThreadElsForLocation for line comments', () => {
+    const line = {beforeNumber: 3, afterNumber: 5};
+
+    const l3 = document.createElement('div');
+    l3.setAttribute('line-num', 3);
+    l3.setAttribute('comment-side', 'left');
+
+    const l5 = document.createElement('div');
+    l5.setAttribute('line-num', 5);
+    l5.setAttribute('comment-side', 'left');
+
+    const r3 = document.createElement('div');
+    r3.setAttribute('line-num', 3);
+    r3.setAttribute('comment-side', 'right');
+
+    const r5 = document.createElement('div');
+    r5.setAttribute('line-num', 5);
+    r5.setAttribute('comment-side', 'right');
+
+    const threadEls = [l3, l5, r3, r5];
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
+        [l3, r5]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        DiffSide.LEFT), [l3]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        DiffSide.RIGHT), [r5]);
+  });
+
+  test('_filterThreadElsForLocation for file comments', () => {
+    const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
+
+    const l = document.createElement('div');
+    l.setAttribute('comment-side', 'left');
+    l.setAttribute('line-num', 'FILE');
+
+    const r = document.createElement('div');
+    r.setAttribute('comment-side', 'right');
+    r.setAttribute('line-num', 'FILE');
+
+    const threadEls = [l, r];
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
+        [l, r]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        DiffSide.BOTH), [l, r]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        DiffSide.LEFT), [l]);
+    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
+        DiffSide.RIGHT), [r]);
+  });
+
+  suite('syntax layer with syntax_highlighting on', () => {
+    setup(() => {
+      const prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      element.patchRange = {};
+      element.prefs = prefs;
+    });
+
+    test('gr-diff-host provides syntax highlighting layer to gr-diff', () => {
+      element.reload();
+      assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
+    });
+
+    test('rendering normal-sized diff does not disable syntax', () => {
+      element.diff = {
+        content: [{
+          a: ['foo'],
+        }],
+      };
+      assert.isTrue(element.$.syntaxLayer.enabled);
+    });
+
+    test('rendering large diff disables syntax', () => {
+      // Before it renders, set the first diff line to 500 '*' characters.
+      element.diff = {
+        content: [{
+          a: [new Array(501).join('*')],
+        }],
+      };
+      assert.isFalse(element.$.syntaxLayer.enabled);
+    });
+
+    test('starts syntax layer processing on render event', done => {
+      sinon.stub(element.$.syntaxLayer, 'process')
+          .returns(Promise.resolve());
+      sinon.stub(element.$.restAPI, 'getDiff').returns(
+          Promise.resolve({content: []}));
+      element.reload();
+      setTimeout(() => {
+        element.dispatchEvent(
+            new CustomEvent('render', {bubbles: true, composed: true}));
+        assert.isTrue(element.$.syntaxLayer.process.called);
+        done();
+      });
+    });
+  });
+
+  suite('syntax layer with syntax_highlighting off', () => {
+    setup(() => {
+      const prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+      };
+      element.diff = {
+        content: [{
+          a: ['foo'],
+        }],
+      };
+      element.patchRange = {};
+      element.prefs = prefs;
+    });
+
+    test('gr-diff-host provides syntax highlighting layer', () => {
+      element.reload();
+      assert.equal(element.$.diff.layers[0], element.$.syntaxLayer);
+    });
+
+    test('syntax layer should be disabled', () => {
+      assert.isFalse(element.$.syntaxLayer.enabled);
+    });
+
+    test('still disabled for large diff', () => {
+      // Before it renders, set the first diff line to 500 '*' characters.
+      element.diff = {
+        content: [{
+          a: [new Array(501).join('*')],
+        }],
+      };
+      assert.isFalse(element.$.syntaxLayer.enabled);
+    });
+  });
+
+  suite('coverage layer', () => {
+    let notifyStub;
+    setup(() => {
+      notifyStub = sinon.stub();
+      stub('gr-js-api-interface', {
+        getCoverageAnnotationApi() {
+          return Promise.resolve({
+            notify: notifyStub,
+            getCoverageProvider() {
+              return () => Promise.resolve([
+                {
+                  type: 'COVERED',
+                  side: 'right',
+                  code_range: {
+                    start_line: 1,
+                    end_line: 2,
+                  },
+                },
+                {
+                  type: 'NOT_COVERED',
+                  side: 'right',
+                  code_range: {
+                    start_line: 3,
+                    end_line: 4,
+                  },
+                },
+              ]);
+            },
+          });
+        },
+      });
+      element = basicFixture.instantiate();
+      const prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+      };
+      element.diff = {
+        content: [{
+          a: ['foo'],
+        }],
+      };
+      element.patchRange = {};
+      element.prefs = prefs;
+    });
+
+    test('getCoverageAnnotationApi should be called', done => {
+      element.reload();
+      flush(() => {
+        assert.isTrue(element.$.jsAPI.getCoverageAnnotationApi.calledOnce);
+        done();
+      });
+    });
+
+    test('coverageRangeChanged should be called', done => {
+      element.reload();
+      flush(() => {
+        assert.equal(notifyStub.callCount, 2);
+        done();
+      });
+    });
+  });
+
+  suite('trailing newlines', () => {
+    setup(() => {
+    });
+
+    suite('_lastChunkForSide', () => {
+      test('deltas', () => {
+        const diff = {content: [
+          {a: ['foo', 'bar'], b: ['baz']},
+          {ab: ['foo', 'bar', 'baz']},
+          {b: ['foo']},
+        ]};
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);
+
+        diff.content.push({a: ['foo'], b: ['bar']});
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
+      });
+
+      test('addition with a undefined', () => {
+        const diff = {content: [
+          {b: ['foo', 'bar', 'baz']},
+        ]};
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
+        assert.isNull(element._lastChunkForSide(diff, true));
+      });
+
+      test('addition with a empty', () => {
+        const diff = {content: [
+          {a: [], b: ['foo', 'bar', 'baz']},
+        ]};
+        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
+        assert.isNull(element._lastChunkForSide(diff, true));
+      });
+
+      test('deletion with b undefined', () => {
+        const diff = {content: [
+          {a: ['foo', 'bar', 'baz']},
+        ]};
+        assert.isNull(element._lastChunkForSide(diff, false));
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
+      });
+
+      test('deletion with b empty', () => {
+        const diff = {content: [
+          {a: ['foo', 'bar', 'baz'], b: []},
+        ]};
+        assert.isNull(element._lastChunkForSide(diff, false));
+        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
+      });
+
+      test('empty', () => {
+        const diff = {content: []};
+        assert.isNull(element._lastChunkForSide(diff, false));
+        assert.isNull(element._lastChunkForSide(diff, true));
+      });
+    });
+
+    suite('_hasTrailingNewlines', () => {
+      test('shared no trailing', () => {
+        const diff = undefined;
+        sinon.stub(element, '_lastChunkForSide')
+            .returns({ab: ['foo', 'bar']});
+        assert.isFalse(element._hasTrailingNewlines(diff, false));
+        assert.isFalse(element._hasTrailingNewlines(diff, true));
+      });
+
+      test('delta trailing in right', () => {
+        const diff = undefined;
+        sinon.stub(element, '_lastChunkForSide')
+            .returns({a: ['foo', 'bar'], b: ['baz', '']});
+        assert.isTrue(element._hasTrailingNewlines(diff, false));
+        assert.isFalse(element._hasTrailingNewlines(diff, true));
+      });
+
+      test('addition', () => {
+        const diff = undefined;
+        sinon.stub(element, '_lastChunkForSide').callsFake((diff, leftSide) => {
+          if (leftSide) { return null; }
+          return {b: ['foo', '']};
+        });
+        assert.isTrue(element._hasTrailingNewlines(diff, false));
+        assert.isNull(element._hasTrailingNewlines(diff, true));
+      });
+
+      test('deletion', () => {
+        const diff = undefined;
+        sinon.stub(element, '_lastChunkForSide').callsFake((diff, leftSide) => {
+          if (!leftSide) { return null; }
+          return {a: ['foo']};
+        });
+        assert.isNull(element._hasTrailingNewlines(diff, false));
+        assert.isFalse(element._hasTrailingNewlines(diff, true));
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
index acd9457..7c9d1ec 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-icon/iron-icon.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-button/gr-button.js';
@@ -25,7 +23,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-diff-mode-selector_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrDiffModeSelector extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.js
deleted file mode 100644
index b5393ea..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      /* Used to remove horizontal whitespace between the icons. */
-      display: flex;
-    }
-    gr-button.selected iron-icon {
-      color: var(--link-color);
-    }
-    iron-icon {
-      height: 1.3rem;
-      width: 1.3rem;
-    }
-  </style>
-  <gr-button
-    id="sideBySideBtn"
-    link=""
-    has-tooltip=""
-    class$="[[_computeSelectedClass(mode, _VIEW_MODES.SIDE_BY_SIDE)]]"
-    title="Side-by-side diff"
-    on-click="_handleSideBySideTap"
-  >
-    <iron-icon icon="gr-icons:side-by-side"></iron-icon>
-  </gr-button>
-  <gr-button
-    id="unifiedBtn"
-    link=""
-    has-tooltip=""
-    title="Unified diff"
-    class$="[[_computeSelectedClass(mode, _VIEW_MODES.UNIFIED)]]"
-    on-click="_handleUnifiedTap"
-  >
-    <iron-icon icon="gr-icons:unified"></iron-icon>
-  </gr-button>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
new file mode 100644
index 0000000..40f6a32
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      /* Used to remove horizontal whitespace between the icons. */
+      display: flex;
+    }
+    gr-button.selected iron-icon {
+      color: var(--link-color);
+    }
+    iron-icon {
+      height: 1.3rem;
+      width: 1.3rem;
+    }
+  </style>
+  <gr-button
+    id="sideBySideBtn"
+    link=""
+    has-tooltip=""
+    class$="[[_computeSelectedClass(mode, _VIEW_MODES.SIDE_BY_SIDE)]]"
+    title="Side-by-side diff"
+    on-click="_handleSideBySideTap"
+  >
+    <iron-icon icon="gr-icons:side-by-side"></iron-icon>
+  </gr-button>
+  <gr-button
+    id="unifiedBtn"
+    link=""
+    has-tooltip=""
+    title="Unified diff"
+    class$="[[_computeSelectedClass(mode, _VIEW_MODES.UNIFIED)]]"
+    on-click="_handleUnifiedTap"
+  >
+    <iron-icon icon="gr-icons:unified"></iron-icon>
+  </gr-button>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
deleted file mode 100644
index 309f4ac..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html
+++ /dev/null
@@ -1,84 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-diff-mode-selector</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script src="/node_modules/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-mode-selector></gr-diff-mode-selector>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-diff-mode-selector.js';
-suite('gr-diff-mode-selector tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_computeSelectedClass', () => {
-    assert.equal(
-        element._computeSelectedClass('SIDE_BY_SIDE', 'SIDE_BY_SIDE'),
-        'selected');
-    assert.equal(
-        element._computeSelectedClass('SIDE_BY_SIDE', 'UNIFIED_DIFF'), '');
-  });
-
-  test('setMode', () => {
-    const saveStub = sandbox.stub(element.$.restAPI, 'savePreferences');
-
-    // Setting the mode initially does not save prefs.
-    element.saveOnChange = true;
-    element.setMode('SIDE_BY_SIDE');
-    assert.isFalse(saveStub.called);
-
-    // Setting the mode to itself does not save prefs.
-    element.setMode('SIDE_BY_SIDE');
-    assert.isFalse(saveStub.called);
-
-    // Setting the mode to something else does not save prefs if saveOnChange
-    // is false.
-    element.saveOnChange = false;
-    element.setMode('UNIFIED_DIFF');
-    assert.isFalse(saveStub.called);
-
-    // Setting the mode to something else does not save prefs if saveOnChange
-    // is false.
-    element.saveOnChange = true;
-    element.setMode('SIDE_BY_SIDE');
-    assert.isTrue(saveStub.calledOnce);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
new file mode 100644
index 0000000..e84ef2b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-diff-mode-selector.js';
+
+const basicFixture = fixtureFromElement('gr-diff-mode-selector');
+
+suite('gr-diff-mode-selector tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_computeSelectedClass', () => {
+    assert.equal(
+        element._computeSelectedClass('SIDE_BY_SIDE', 'SIDE_BY_SIDE'),
+        'selected');
+    assert.equal(
+        element._computeSelectedClass('SIDE_BY_SIDE', 'UNIFIED_DIFF'), '');
+  });
+
+  test('setMode', () => {
+    const saveStub = sinon.stub(element.$.restAPI, 'savePreferences');
+
+    // Setting the mode initially does not save prefs.
+    element.saveOnChange = true;
+    element.setMode('SIDE_BY_SIDE');
+    assert.isFalse(saveStub.called);
+
+    // Setting the mode to itself does not save prefs.
+    element.setMode('SIDE_BY_SIDE');
+    assert.isFalse(saveStub.called);
+
+    // Setting the mode to something else does not save prefs if saveOnChange
+    // is false.
+    element.saveOnChange = false;
+    element.setMode('UNIFIED_DIFF');
+    assert.isFalse(saveStub.called);
+
+    // Setting the mode to something else does not save prefs if saveOnChange
+    // is false.
+    element.saveOnChange = true;
+    element.setMode('SIDE_BY_SIDE');
+    assert.isTrue(saveStub.calledOnce);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
index 8f48507..b68c889 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-diff-preferences/gr-diff-preferences.js';
@@ -26,7 +24,7 @@
 import {htmlTemplate} from './gr-diff-preferences-dialog_html.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrDiffPreferencesDialog extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -36,7 +34,7 @@
 
   static get properties() {
     return {
-    /** @type {?} */
+      /** @type {?} */
       diffPrefs: Object,
 
       /**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.js
deleted file mode 100644
index d65ba1f..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.js
+++ /dev/null
@@ -1,74 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .diffHeader,
-    .diffActions {
-      padding: var(--spacing-l) var(--spacing-xl);
-    }
-    .diffHeader,
-    .diffActions {
-      background-color: var(--dialog-background-color);
-    }
-    .diffHeader {
-      border-bottom: 1px solid var(--border-color);
-      font-weight: var(--font-weight-bold);
-    }
-    .diffActions {
-      border-top: 1px solid var(--border-color);
-      display: flex;
-      justify-content: flex-end;
-    }
-    .diffPrefsOverlay gr-button {
-      margin-left: var(--spacing-l);
-    }
-    div.edited:after {
-      color: var(--deemphasized-text-color);
-      content: ' *';
-    }
-    #diffPreferences {
-      display: flex;
-      padding: var(--spacing-s) var(--spacing-xl);
-    }
-  </style>
-  <gr-overlay id="diffPrefsOverlay" with-backdrop="">
-    <div class$="diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]">
-      Diff Preferences
-    </div>
-    <gr-diff-preferences
-      id="diffPreferences"
-      diff-prefs="{{_editableDiffPrefs}}"
-      has-unsaved-changes="{{_diffPrefsChanged}}"
-    ></gr-diff-preferences>
-    <div class="diffActions">
-      <gr-button id="cancelButton" link="" on-click="_handleCancelDiff">
-        Cancel
-      </gr-button>
-      <gr-button
-        id="saveButton"
-        link=""
-        primary=""
-        on-click="_handleSaveDiffPreferences"
-        disabled$="[[!_diffPrefsChanged]]"
-      >
-        Save
-      </gr-button>
-    </div>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
new file mode 100644
index 0000000..9c942a3
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .diffHeader,
+    .diffActions {
+      padding: var(--spacing-l) var(--spacing-xl);
+    }
+    .diffHeader,
+    .diffActions {
+      background-color: var(--dialog-background-color);
+    }
+    .diffHeader {
+      border-bottom: 1px solid var(--border-color);
+      font-weight: var(--font-weight-bold);
+    }
+    .diffActions {
+      border-top: 1px solid var(--border-color);
+      display: flex;
+      justify-content: flex-end;
+    }
+    .diffPrefsOverlay gr-button {
+      margin-left: var(--spacing-l);
+    }
+    div.edited:after {
+      color: var(--deemphasized-text-color);
+      content: ' *';
+    }
+    #diffPreferences {
+      display: flex;
+      padding: var(--spacing-s) var(--spacing-xl);
+    }
+  </style>
+  <gr-overlay id="diffPrefsOverlay" with-backdrop="">
+    <div class$="diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]">
+      Diff Preferences
+    </div>
+    <gr-diff-preferences
+      id="diffPreferences"
+      diff-prefs="{{_editableDiffPrefs}}"
+      has-unsaved-changes="{{_diffPrefsChanged}}"
+    ></gr-diff-preferences>
+    <div class="diffActions">
+      <gr-button id="cancelButton" link="" on-click="_handleCancelDiff">
+        Cancel
+      </gr-button>
+      <gr-button
+        id="saveButton"
+        link=""
+        primary=""
+        on-click="_handleSaveDiffPreferences"
+        disabled$="[[!_diffPrefsChanged]]"
+      >
+        Save
+      </gr-button>
+    </div>
+  </gr-overlay>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.html
deleted file mode 100644
index d3050af..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.html
+++ /dev/null
@@ -1,69 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2020 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-diff-preferences-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script src="/components/web-component-tester/data/a11ySuite.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-preferences-dialog></gr-diff-preferences-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-diff-preferences-dialog.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-suite('gr-diff-preferences-dialog', () => {
-  let element;
-  setup(() => {
-    element = fixture('basic');
-  });
-  test('changes applies only on save', async () => {
-    const originalDiffPrefs = {
-      line_wrapping: true,
-    };
-    element.diffPrefs = originalDiffPrefs;
-
-    element.open();
-    await flush();
-    assert.isTrue(element.$.diffPreferences.$.lineWrappingInput.checked);
-
-    MockInteractions.tap(element.$.diffPreferences.$.lineWrappingInput);
-    await flush();
-    assert.isFalse(element.$.diffPreferences.$.lineWrappingInput.checked);
-    assert.isTrue(element._diffPrefsChanged);
-    assert.isTrue(element.diffPrefs.line_wrapping);
-    assert.isTrue(originalDiffPrefs.line_wrapping);
-
-    MockInteractions.tap(element.$.saveButton);
-    await flush();
-    // Original prefs must remains unchanged, dialog must expose a new object
-    assert.isTrue(originalDiffPrefs.line_wrapping);
-    assert.isFalse(element.diffPrefs.line_wrapping);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.js
new file mode 100644
index 0000000..07cca9a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.js
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+
+const basicFixture = fixtureFromElement('gr-diff-preferences-dialog');
+
+suite('gr-diff-preferences-dialog', () => {
+  let element;
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+  test('changes applies only on save', async () => {
+    const originalDiffPrefs = {
+      line_wrapping: true,
+    };
+    element.diffPrefs = originalDiffPrefs;
+
+    element.open();
+    await flush();
+    assert.isTrue(element.$.diffPreferences.$.lineWrappingInput.checked);
+
+    MockInteractions.tap(element.$.diffPreferences.$.lineWrappingInput);
+    await flush();
+    assert.isFalse(element.$.diffPreferences.$.lineWrappingInput.checked);
+    assert.isTrue(element._diffPrefsChanged);
+    assert.isTrue(element.diffPrefs.line_wrapping);
+    assert.isTrue(originalDiffPrefs.line_wrapping);
+
+    MockInteractions.tap(element.$.saveButton);
+    await flush();
+    // Original prefs must remains unchanged, dialog must expose a new object
+    assert.isTrue(originalDiffPrefs.line_wrapping);
+    assert.isFalse(element.diffPrefs.line_wrapping);
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
index 62ddfee..a4954a1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
@@ -70,7 +68,7 @@
  *    that the part that is within the context or has comments is shown, while
  *    the rest is not.
  *
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrDiffProcessor extends GestureEventListeners(
     LegacyElementMixin(
@@ -511,14 +509,20 @@
 
       if (chunk.ab) {
         result.push(...this._splitAtChunkEnds(chunk.ab, chunkEnds)
-            .map(({lines, keyLocation}) =>
-              Object.assign({}, chunk, {ab: lines, keyLocation})));
+            .map(({lines, keyLocation}) => {
+              return {
+                ...chunk,
+                ab: lines,
+                keyLocation,
+              };
+            }));
       } else if (chunk.common) {
         const aChunks = this._splitAtChunkEnds(chunk.a, chunkEnds);
         const bChunks = this._splitAtChunkEnds(chunk.b, chunkEnds);
-        result.push(...aChunks.map(({lines, keyLocation}, i) =>
-          Object.assign(
-              {}, chunk, {a: lines, b: bChunks[i].lines, keyLocation})));
+        result.push(...aChunks.map(({lines, keyLocation}, i) => {
+          return {
+            ...chunk, a: lines, b: bChunks[i].lines, keyLocation};
+        }));
       }
     }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
deleted file mode 100644
index 50bfe107..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
+++ /dev/null
@@ -1,936 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-diff-processor test</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-processor></gr-diff-processor>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-diff-processor.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
-
-suite('gr-diff-processor tests', () => {
-  const WHOLE_FILE = -1;
-  const loremIpsum =
-      'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
-      'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
-      'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
-      'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
-      'fugit assum per.';
-
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('not logged in', () => {
-    setup(() => {
-      element = fixture('basic');
-
-      element.context = 4;
-    });
-
-    test('process loaded content', () => {
-      const content = [
-        {
-          ab: [
-            '<!DOCTYPE html>',
-            '<meta charset="utf-8">',
-          ],
-        },
-        {
-          a: [
-            '  Welcome ',
-            '  to the wooorld of tomorrow!',
-          ],
-          b: [
-            '  Hello, world!',
-          ],
-        },
-        {
-          ab: [
-            'Leela: This is the only place the ship can’t hear us, so ',
-            'everyone pretend to shower.',
-            'Fry: Same as every day. Got it.',
-          ],
-        },
-      ];
-
-      return element.process(content).then(() => {
-        const groups = element.groups;
-
-        assert.equal(groups.length, 4);
-
-        let group = groups[0];
-        assert.equal(group.type, GrDiffGroup.Type.BOTH);
-        assert.equal(group.lines.length, 1);
-        assert.equal(group.lines[0].text, '');
-        assert.equal(group.lines[0].beforeNumber, GrDiffLine.FILE);
-        assert.equal(group.lines[0].afterNumber, GrDiffLine.FILE);
-
-        group = groups[1];
-        assert.equal(group.type, GrDiffGroup.Type.BOTH);
-        assert.equal(group.lines.length, 2);
-        assert.equal(group.lines.length, 2);
-
-        function beforeNumberFn(l) { return l.beforeNumber; }
-        function afterNumberFn(l) { return l.afterNumber; }
-        function textFn(l) { return l.text; }
-
-        assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
-        assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
-        assert.deepEqual(group.lines.map(textFn), [
-          '<!DOCTYPE html>',
-          '<meta charset="utf-8">',
-        ]);
-
-        group = groups[2];
-        assert.equal(group.type, GrDiffGroup.Type.DELTA);
-        assert.equal(group.lines.length, 3);
-        assert.equal(group.adds.length, 1);
-        assert.equal(group.removes.length, 2);
-        assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
-        assert.deepEqual(group.adds.map(afterNumberFn), [3]);
-        assert.deepEqual(group.removes.map(textFn), [
-          '  Welcome ',
-          '  to the wooorld of tomorrow!',
-        ]);
-        assert.deepEqual(group.adds.map(textFn), [
-          '  Hello, world!',
-        ]);
-
-        group = groups[3];
-        assert.equal(group.type, GrDiffGroup.Type.BOTH);
-        assert.equal(group.lines.length, 3);
-        assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
-        assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
-        assert.deepEqual(group.lines.map(textFn), [
-          'Leela: This is the only place the ship can’t hear us, so ',
-          'everyone pretend to shower.',
-          'Fry: Same as every day. Got it.',
-        ]);
-      });
-    });
-
-    test('first group is for file', () => {
-      const content = [
-        {b: ['foo']},
-      ];
-
-      return element.process(content).then(() => {
-        const groups = element.groups;
-
-        assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
-        assert.equal(groups[0].lines.length, 1);
-        assert.equal(groups[0].lines[0].text, '');
-        assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
-        assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
-      });
-    });
-
-    suite('context groups', () => {
-      test('at the beginning, larger than context', () => {
-        element.context = 10;
-        const content = [
-          {ab: new Array(100)
-              .fill('all work and no play make jack a dull boy')},
-          {a: ['all work and no play make andybons a dull boy']},
-        ];
-
-        return element.process(content).then(() => {
-          const groups = element.groups;
-
-          // group[0] is the file group
-
-          assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-          assert.instanceOf(groups[1].lines[0].contextGroups[0], GrDiffGroup);
-          assert.equal(groups[1].lines[0].contextGroups[0].lines.length, 90);
-          for (const l of groups[1].lines[0].contextGroups[0].lines) {
-            assert.equal(l.text, 'all work and no play make jack a dull boy');
-          }
-
-          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[2].lines.length, 10);
-          for (const l of groups[2].lines) {
-            assert.equal(l.text, 'all work and no play make jack a dull boy');
-          }
-        });
-      });
-
-      test('at the beginning, smaller than context', () => {
-        element.context = 10;
-        const content = [
-          {ab: new Array(5)
-              .fill('all work and no play make jack a dull boy')},
-          {a: ['all work and no play make andybons a dull boy']},
-        ];
-
-        return element.process(content).then(() => {
-          const groups = element.groups;
-
-          // group[0] is the file group
-
-          assert.equal(groups[1].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[1].lines.length, 5);
-          for (const l of groups[1].lines) {
-            assert.equal(l.text, 'all work and no play make jack a dull boy');
-          }
-        });
-      });
-
-      test('at the end, larger than context', () => {
-        element.context = 10;
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(100)
-              .fill('all work and no play make jill a dull girl')},
-        ];
-
-        return element.process(content).then(() => {
-          const groups = element.groups;
-
-          // group[0] is the file group
-          // group[1] is the "a" group
-
-          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[2].lines.length, 10);
-          for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-
-          assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-          assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
-          assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 90);
-          for (const l of groups[3].lines[0].contextGroups[0].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-        });
-      });
-
-      test('at the end, smaller than context', () => {
-        element.context = 10;
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(5)
-              .fill('all work and no play make jill a dull girl')},
-        ];
-
-        return element.process(content).then(() => {
-          const groups = element.groups;
-
-          // group[0] is the file group
-          // group[1] is the "a" group
-
-          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[2].lines.length, 5);
-          for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-        });
-      });
-
-      test('for interleaved ab and common: true chunks', () => {
-        element.context = 10;
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(3)
-              .fill('all work and no play make jill a dull girl')},
-          {
-            a: new Array(3).fill(
-                'all work and no play make jill a dull girl'),
-            b: new Array(3).fill(
-                '  all work and no play make jill a dull girl'),
-            common: true,
-          },
-          {ab: new Array(3)
-              .fill('all work and no play make jill a dull girl')},
-          {
-            a: new Array(3).fill(
-                'all work and no play make jill a dull girl'),
-            b: new Array(3).fill(
-                '  all work and no play make jill a dull girl'),
-            common: true,
-          },
-          {ab: new Array(3)
-              .fill('all work and no play make jill a dull girl')},
-        ];
-
-        return element.process(content).then(() => {
-          const groups = element.groups;
-
-          // group[0] is the file group
-          // group[1] is the "a" group
-
-          // The first three interleaved chunks are completely shown because
-          // they are part of the context (3 * 3 <= 10)
-
-          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[2].lines.length, 3);
-          for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-
-          assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
-          assert.equal(groups[3].lines.length, 6);
-          assert.equal(groups[3].adds.length, 3);
-          assert.equal(groups[3].removes.length, 3);
-          for (const l of groups[3].removes) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-          for (const l of groups[3].adds) {
-            assert.equal(
-                l.text, '  all work and no play make jill a dull girl');
-          }
-
-          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[4].lines.length, 3);
-          for (const l of groups[4].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-
-          // The next chunk is partially shown, so it results in two groups
-
-          assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
-          assert.equal(groups[5].lines.length, 2);
-          assert.equal(groups[5].adds.length, 1);
-          assert.equal(groups[5].removes.length, 1);
-          for (const l of groups[5].removes) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-          for (const l of groups[5].adds) {
-            assert.equal(
-                l.text, '  all work and no play make jill a dull girl');
-          }
-
-          assert.equal(groups[6].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-          assert.equal(groups[6].lines[0].contextGroups.length, 2);
-
-          assert.equal(groups[6].lines[0].contextGroups[0].lines.length, 4);
-          assert.equal(groups[6].lines[0].contextGroups[0].removes.length, 2);
-          assert.equal(groups[6].lines[0].contextGroups[0].adds.length, 2);
-          for (const l of groups[6].lines[0].contextGroups[0].removes) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-          for (const l of groups[6].lines[0].contextGroups[0].adds) {
-            assert.equal(
-                l.text, '  all work and no play make jill a dull girl');
-          }
-
-          // The final chunk is completely hidden
-          assert.equal(
-              groups[6].lines[0].contextGroups[1].type,
-              GrDiffGroup.Type.BOTH);
-          assert.equal(groups[6].lines[0].contextGroups[1].lines.length, 3);
-          for (const l of groups[6].lines[0].contextGroups[1].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-        });
-      });
-
-      test('in the middle, larger than context', () => {
-        element.context = 10;
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(100)
-              .fill('all work and no play make jill a dull girl')},
-          {a: ['all work and no play make andybons a dull boy']},
-        ];
-
-        return element.process(content).then(() => {
-          const groups = element.groups;
-
-          // group[0] is the file group
-          // group[1] is the "a" group
-
-          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[2].lines.length, 10);
-          for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-
-          assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-          assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
-          assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 80);
-          for (const l of groups[3].lines[0].contextGroups[0].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-
-          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[4].lines.length, 10);
-          for (const l of groups[4].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-        });
-      });
-
-      test('in the middle, smaller than context', () => {
-        element.context = 10;
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(5)
-              .fill('all work and no play make jill a dull girl')},
-          {a: ['all work and no play make andybons a dull boy']},
-        ];
-
-        return element.process(content).then(() => {
-          const groups = element.groups;
-
-          // group[0] is the file group
-          // group[1] is the "a" group
-
-          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-          assert.equal(groups[2].lines.length, 5);
-          for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-        });
-      });
-    });
-
-    test('break up common diff chunks', () => {
-      element.keyLocations = {
-        left: {1: true},
-        right: {10: true},
-      };
-
-      const content = [
-        {
-          ab: [
-            'Copyright (C) 2015 The Android Open Source Project',
-            '',
-            'Licensed under the Apache License, Version 2.0 (the "License");',
-            'you may not use this file except in compliance with the ' +
-                'License.',
-            'You may obtain a copy of the License at',
-            '',
-            'http://www.apache.org/licenses/LICENSE-2.0',
-            '',
-            'Unless required by applicable law or agreed to in writing, ',
-            'software distributed under the License is distributed on an ',
-            '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
-            'either express or implied. See the License for the specific ',
-            'language governing permissions and limitations under the ' +
-                'License.',
-          ],
-        },
-      ];
-      const result =
-          element._splitCommonChunksWithKeyLocations(content);
-      assert.deepEqual(result, [
-        {
-          ab: ['Copyright (C) 2015 The Android Open Source Project'],
-          keyLocation: true,
-        },
-        {
-          ab: [
-            '',
-            'Licensed under the Apache License, Version 2.0 (the "License");',
-            'you may not use this file except in compliance with the ' +
-                'License.',
-            'You may obtain a copy of the License at',
-            '',
-            'http://www.apache.org/licenses/LICENSE-2.0',
-            '',
-            'Unless required by applicable law or agreed to in writing, ',
-          ],
-          keyLocation: false,
-        },
-        {
-          ab: [
-            'software distributed under the License is distributed on an '],
-          keyLocation: true,
-        },
-        {
-          ab: [
-            '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
-            'either express or implied. See the License for the specific ',
-            'language governing permissions and limitations under the ' +
-                'License.',
-          ],
-          keyLocation: false,
-        },
-      ]);
-    });
-
-    test('breaks down shared chunks w/ whole-file', () => {
-      const size = 120 * 2 + 5;
-      const content = [{
-        ab: _.times(size, () => `${Math.random()}`),
-      }];
-      element.context = -1;
-      const result = element._splitLargeChunks(content);
-      assert.equal(result.length, 2);
-      assert.deepEqual(result[0].ab, content[0].ab.slice(0, 120));
-      assert.deepEqual(result[1].ab, content[0].ab.slice(120));
-    });
-
-    test('does not break-down common chunks w/ context', () => {
-      const content = [{
-        ab: _.times(75, () => `${Math.random()}`),
-      }];
-      element.context = 4;
-      const result =
-          element._splitCommonChunksWithKeyLocations(content);
-      assert.equal(result.length, 1);
-      assert.deepEqual(result[0].ab, content[0].ab);
-      assert.isFalse(result[0].keyLocation);
-    });
-
-    test('intraline normalization', () => {
-      // The content and highlights are in the format returned by the Gerrit
-      // REST API.
-      let content = [
-        '      <section class="summary">',
-        '        <gr-linked-text content="' +
-            '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
-        '      </section>',
-      ];
-      let highlights = [
-        [31, 34], [42, 26],
-      ];
-
-      let results = element._convertIntralineInfos(content,
-          highlights);
-      assert.deepEqual(results, [
-        {
-          contentIndex: 0,
-          startIndex: 31,
-        },
-        {
-          contentIndex: 1,
-          startIndex: 0,
-          endIndex: 33,
-        },
-        {
-          contentIndex: 1,
-          startIndex: 75,
-        },
-        {
-          contentIndex: 2,
-          startIndex: 0,
-          endIndex: 6,
-        },
-      ]);
-
-      content = [
-        '        this._path = value.path;',
-        '',
-        '        // When navigating away from the page, there is a ' +
-          'possibility that the',
-        '        // patch number is no longer a part of the URL ' +
-          '(say when navigating to',
-        '        // the top-level change info view) and therefore ' +
-          'undefined in `params`.',
-        '        if (!this._patchRange.patchNum) {',
-      ];
-      highlights = [
-        [14, 17],
-        [11, 70],
-        [12, 67],
-        [12, 67],
-        [14, 29],
-      ];
-      results = element._convertIntralineInfos(content, highlights);
-      assert.deepEqual(results, [
-        {
-          contentIndex: 0,
-          startIndex: 14,
-          endIndex: 31,
-        },
-        {
-          contentIndex: 2,
-          startIndex: 8,
-          endIndex: 78,
-        },
-        {
-          contentIndex: 3,
-          startIndex: 11,
-          endIndex: 78,
-        },
-        {
-          contentIndex: 4,
-          startIndex: 11,
-          endIndex: 78,
-        },
-        {
-          contentIndex: 5,
-          startIndex: 12,
-          endIndex: 41,
-        },
-      ]);
-    });
-
-    test('scrolling pauses rendering', () => {
-      const contentRow = {
-        ab: [
-          '<!DOCTYPE html>',
-          '<meta charset="utf-8">',
-        ],
-      };
-      const content = _.times(200, _.constant(contentRow));
-      sandbox.stub(element, 'async');
-      element._isScrolling = true;
-      element.process(content);
-      // Just the files group - no more processing during scrolling.
-      assert.equal(element.groups.length, 1);
-
-      element._isScrolling = false;
-      element.process(content);
-      // More groups have been processed. How many does not matter here.
-      assert.isAtLeast(element.groups.length, 2);
-    });
-
-    test('image diffs', () => {
-      const contentRow = {
-        ab: [
-          '<!DOCTYPE html>',
-          '<meta charset="utf-8">',
-        ],
-      };
-      const content = _.times(200, _.constant(contentRow));
-      sandbox.stub(element, 'async');
-      element.process(content, true);
-      assert.equal(element.groups.length, 1);
-
-      // Image diffs don't process content, just the 'FILE' line.
-      assert.equal(element.groups[0].lines.length, 1);
-    });
-
-    suite('_processNext', () => {
-      let rows;
-
-      setup(() => {
-        rows = loremIpsum.split(' ');
-      });
-
-      test('WHOLE_FILE', () => {
-        element.context = WHOLE_FILE;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 1,
-        };
-        const chunks = [
-          {a: ['foo']},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
-
-        // Results in one, uncollapsed group with all rows.
-        assert.equal(result.groups.length, 1);
-        assert.equal(result.groups[0].type, GrDiffGroup.Type.BOTH);
-        assert.equal(result.groups[0].lines.length, rows.length);
-
-        // Line numbers are set correctly.
-        assert.equal(
-            result.groups[0].lines[0].beforeNumber,
-            state.lineNums.left + 1);
-        assert.equal(
-            result.groups[0].lines[0].afterNumber,
-            state.lineNums.right + 1);
-
-        assert.equal(result.groups[0].lines[rows.length - 1].beforeNumber,
-            state.lineNums.left + rows.length);
-        assert.equal(result.groups[0].lines[rows.length - 1].afterNumber,
-            state.lineNums.right + rows.length);
-      });
-
-      test('with context', () => {
-        element.context = 10;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 1,
-        };
-        const chunks = [
-          {a: ['foo']},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
-        const expectedCollapseSize = rows.length - 2 * element.context;
-
-        assert.equal(result.groups.length, 3, 'Results in three groups');
-
-        // The first and last are uncollapsed context, whereas the middle has
-        // a single context-control line.
-        assert.equal(result.groups[0].lines.length, element.context);
-        assert.equal(result.groups[1].lines.length, 1);
-        assert.equal(result.groups[2].lines.length, element.context);
-
-        // The collapsed group has the hidden lines as its context group.
-        assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
-            expectedCollapseSize);
-      });
-
-      test('first', () => {
-        element.context = 10;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 0,
-        };
-        const chunks = [
-          {ab: rows},
-          {a: ['foo']},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
-        const expectedCollapseSize = rows.length - element.context;
-
-        assert.equal(result.groups.length, 2, 'Results in two groups');
-
-        // Only the first group is collapsed.
-        assert.equal(result.groups[0].lines.length, 1);
-        assert.equal(result.groups[1].lines.length, element.context);
-
-        // The collapsed group has the hidden lines as its context group.
-        assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
-            expectedCollapseSize);
-      });
-
-      test('few-rows', () => {
-        // Only ten rows.
-        rows = rows.slice(0, 10);
-        element.context = 10;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 0,
-        };
-        const chunks = [
-          {ab: rows},
-          {a: ['foo']},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
-
-        // Results in one uncollapsed group with all rows.
-        assert.equal(result.groups.length, 1, 'Results in one group');
-        assert.equal(result.groups[0].lines.length, rows.length);
-      });
-
-      test('no single line collapse', () => {
-        rows = rows.slice(0, 7);
-        element.context = 3;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 1,
-        };
-        const chunks = [
-          {a: ['foo']},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
-
-        // Results in one uncollapsed group with all rows.
-        assert.equal(result.groups.length, 1, 'Results in one group');
-        assert.equal(result.groups[0].lines.length, rows.length);
-      });
-
-      suite('with key location', () => {
-        let state;
-        let chunks;
-
-        setup(() => {
-          state = {
-            lineNums: {left: 10, right: 100},
-          };
-          element.context = 10;
-          chunks = [
-            {ab: rows},
-            {ab: ['foo'], keyLocation: true},
-            {ab: rows},
-          ];
-        });
-
-        test('context before', () => {
-          state.chunkIndex = 0;
-          const result = element._processNext(state, chunks);
-
-          // The first chunk is split into two groups:
-          // 1) A context-control, hiding everything but the context before
-          //    the key location.
-          // 2) The context before the key location.
-          // The key location is not processed in this call to _processNext
-          assert.equal(result.groups.length, 2);
-          assert.equal(result.groups[0].lines.length, 1);
-          // The collapsed group has the hidden lines as its context group.
-          assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
-              rows.length - element.context);
-          assert.equal(result.groups[1].lines.length, element.context);
-        });
-
-        test('key location itself', () => {
-          state.chunkIndex = 1;
-          const result = element._processNext(state, chunks);
-
-          // The second chunk results in a single group, that is just the
-          // line with the key location
-          assert.equal(result.groups.length, 1);
-          assert.equal(result.groups[0].lines.length, 1);
-          assert.equal(result.lineDelta.left, 1);
-          assert.equal(result.lineDelta.right, 1);
-        });
-
-        test('context after', () => {
-          state.chunkIndex = 2;
-          const result = element._processNext(state, chunks);
-
-          // The last chunk is split into two groups:
-          // 1) The context after the key location.
-          // 1) A context-control, hiding everything but the context after the
-          //    key location.
-          assert.equal(result.groups.length, 2);
-          assert.equal(result.groups[0].lines.length, element.context);
-          assert.equal(result.groups[1].lines.length, 1);
-          // The collapsed group has the hidden lines as its context group.
-          assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
-              rows.length - element.context);
-        });
-      });
-    });
-
-    suite('gr-diff-processor helpers', () => {
-      let rows;
-
-      setup(() => {
-        rows = loremIpsum.split(' ');
-      });
-
-      test('_linesFromRows', () => {
-        const startLineNum = 10;
-        let result = element._linesFromRows(GrDiffLine.Type.ADD, rows,
-            startLineNum + 1);
-
-        assert.equal(result.length, rows.length);
-        assert.equal(result[0].type, GrDiffLine.Type.ADD);
-        assert.equal(result[0].afterNumber, startLineNum + 1);
-        assert.notOk(result[0].beforeNumber);
-        assert.equal(result[result.length - 1].afterNumber,
-            startLineNum + rows.length);
-        assert.notOk(result[result.length - 1].beforeNumber);
-
-        result = element._linesFromRows(GrDiffLine.Type.REMOVE, rows,
-            startLineNum + 1);
-
-        assert.equal(result.length, rows.length);
-        assert.equal(result[0].type, GrDiffLine.Type.REMOVE);
-        assert.equal(result[0].beforeNumber, startLineNum + 1);
-        assert.notOk(result[0].afterNumber);
-        assert.equal(result[result.length - 1].beforeNumber,
-            startLineNum + rows.length);
-        assert.notOk(result[result.length - 1].afterNumber);
-      });
-    });
-
-    suite('_breakdown*', () => {
-      test('_breakdownChunk breaks down additions', () => {
-        sandbox.spy(element, '_breakdown');
-        const chunk = {b: ['blah', 'blah', 'blah']};
-        const result = element._breakdownChunk(chunk);
-        assert.deepEqual(result, [chunk]);
-        assert.isTrue(element._breakdown.called);
-      });
-
-      test('_breakdownChunk keeps due_to_rebase for broken down additions',
-          () => {
-            sandbox.spy(element, '_breakdown');
-            const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
-            const result = element._breakdownChunk(chunk);
-            for (const subResult of result) {
-              assert.isTrue(subResult.due_to_rebase);
-            }
-          });
-
-      test('_breakdown common case', () => {
-        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
-            .split(' ');
-        const size = 3;
-
-        const result = element._breakdown(array, size);
-
-        for (const subResult of result) {
-          assert.isAtMost(subResult.length, size);
-        }
-        const flattened = result
-            .reduce((a, b) => a.concat(b), []);
-        assert.deepEqual(flattened, array);
-      });
-
-      test('_breakdown smaller than size', () => {
-        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
-            .split(' ');
-        const size = 10;
-        const expected = [array];
-
-        const result = element._breakdown(array, size);
-
-        assert.deepEqual(result, expected);
-      });
-
-      test('_breakdown empty', () => {
-        const array = [];
-        const size = 10;
-        const expected = [];
-
-        const result = element._breakdown(array, size);
-
-        assert.deepEqual(result, expected);
-      });
-    });
-  });
-
-  test('detaching cancels', () => {
-    element = fixture('basic');
-    sandbox.stub(element, 'cancel');
-    element.detached();
-    assert(element.cancel.called);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
new file mode 100644
index 0000000..8634173
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
@@ -0,0 +1,911 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-diff-processor.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
+
+const basicFixture = fixtureFromElement('gr-diff-processor');
+
+suite('gr-diff-processor tests', () => {
+  const WHOLE_FILE = -1;
+  const loremIpsum =
+      'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
+      'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
+      'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
+      'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
+      'fugit assum per.';
+
+  let element;
+
+  setup(() => {
+
+  });
+
+  suite('not logged in', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+
+      element.context = 4;
+    });
+
+    test('process loaded content', () => {
+      const content = [
+        {
+          ab: [
+            '<!DOCTYPE html>',
+            '<meta charset="utf-8">',
+          ],
+        },
+        {
+          a: [
+            '  Welcome ',
+            '  to the wooorld of tomorrow!',
+          ],
+          b: [
+            '  Hello, world!',
+          ],
+        },
+        {
+          ab: [
+            'Leela: This is the only place the ship can’t hear us, so ',
+            'everyone pretend to shower.',
+            'Fry: Same as every day. Got it.',
+          ],
+        },
+      ];
+
+      return element.process(content).then(() => {
+        const groups = element.groups;
+
+        assert.equal(groups.length, 4);
+
+        let group = groups[0];
+        assert.equal(group.type, GrDiffGroup.Type.BOTH);
+        assert.equal(group.lines.length, 1);
+        assert.equal(group.lines[0].text, '');
+        assert.equal(group.lines[0].beforeNumber, GrDiffLine.FILE);
+        assert.equal(group.lines[0].afterNumber, GrDiffLine.FILE);
+
+        group = groups[1];
+        assert.equal(group.type, GrDiffGroup.Type.BOTH);
+        assert.equal(group.lines.length, 2);
+
+        function beforeNumberFn(l) { return l.beforeNumber; }
+        function afterNumberFn(l) { return l.afterNumber; }
+        function textFn(l) { return l.text; }
+
+        assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
+        assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
+        assert.deepEqual(group.lines.map(textFn), [
+          '<!DOCTYPE html>',
+          '<meta charset="utf-8">',
+        ]);
+
+        group = groups[2];
+        assert.equal(group.type, GrDiffGroup.Type.DELTA);
+        assert.equal(group.lines.length, 3);
+        assert.equal(group.adds.length, 1);
+        assert.equal(group.removes.length, 2);
+        assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
+        assert.deepEqual(group.adds.map(afterNumberFn), [3]);
+        assert.deepEqual(group.removes.map(textFn), [
+          '  Welcome ',
+          '  to the wooorld of tomorrow!',
+        ]);
+        assert.deepEqual(group.adds.map(textFn), [
+          '  Hello, world!',
+        ]);
+
+        group = groups[3];
+        assert.equal(group.type, GrDiffGroup.Type.BOTH);
+        assert.equal(group.lines.length, 3);
+        assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
+        assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
+        assert.deepEqual(group.lines.map(textFn), [
+          'Leela: This is the only place the ship can’t hear us, so ',
+          'everyone pretend to shower.',
+          'Fry: Same as every day. Got it.',
+        ]);
+      });
+    });
+
+    test('first group is for file', () => {
+      const content = [
+        {b: ['foo']},
+      ];
+
+      return element.process(content).then(() => {
+        const groups = element.groups;
+
+        assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
+        assert.equal(groups[0].lines.length, 1);
+        assert.equal(groups[0].lines[0].text, '');
+        assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
+        assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+      });
+    });
+
+    suite('context groups', () => {
+      test('at the beginning, larger than context', () => {
+        element.context = 10;
+        const content = [
+          {ab: new Array(100)
+              .fill('all work and no play make jack a dull boy')},
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+
+          assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[1].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[1].contextGroups[0].lines.length, 90);
+          for (const l of groups[1].contextGroups[0].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+        });
+      });
+
+      test('at the beginning, smaller than context', () => {
+        element.context = 10;
+        const content = [
+          {ab: new Array(5)
+              .fill('all work and no play make jack a dull boy')},
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+
+          assert.equal(groups[1].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[1].lines.length, 5);
+          for (const l of groups[1].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+        });
+      });
+
+      test('at the end, larger than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(100)
+              .fill('all work and no play make jill a dull girl')},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[3].contextGroups[0].lines.length, 90);
+          for (const l of groups[3].contextGroups[0].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('at the end, smaller than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(5)
+              .fill('all work and no play make jill a dull girl')},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 5);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('for interleaved ab and common: true chunks', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(3)
+              .fill('all work and no play make jill a dull girl')},
+          {
+            a: new Array(3).fill(
+                'all work and no play make jill a dull girl'),
+            b: new Array(3).fill(
+                '  all work and no play make jill a dull girl'),
+            common: true,
+          },
+          {ab: new Array(3)
+              .fill('all work and no play make jill a dull girl')},
+          {
+            a: new Array(3).fill(
+                'all work and no play make jill a dull girl'),
+            b: new Array(3).fill(
+                '  all work and no play make jill a dull girl'),
+            common: true,
+          },
+          {ab: new Array(3)
+              .fill('all work and no play make jill a dull girl')},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          // The first three interleaved chunks are completely shown because
+          // they are part of the context (3 * 3 <= 10)
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 3);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[3].lines.length, 6);
+          assert.equal(groups[3].adds.length, 3);
+          assert.equal(groups[3].removes.length, 3);
+          for (const l of groups[3].removes) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[3].adds) {
+            assert.equal(
+                l.text, '  all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[4].lines.length, 3);
+          for (const l of groups[4].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          // The next chunk is partially shown, so it results in two groups
+
+          assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[5].lines.length, 2);
+          assert.equal(groups[5].adds.length, 1);
+          assert.equal(groups[5].removes.length, 1);
+          for (const l of groups[5].removes) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[5].adds) {
+            assert.equal(
+                l.text, '  all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[6].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.equal(groups[6].contextGroups.length, 2);
+
+          assert.equal(groups[6].contextGroups[0].lines.length, 4);
+          assert.equal(groups[6].contextGroups[0].removes.length, 2);
+          assert.equal(groups[6].contextGroups[0].adds.length, 2);
+          for (const l of groups[6].contextGroups[0].removes) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[6].contextGroups[0].adds) {
+            assert.equal(
+                l.text, '  all work and no play make jill a dull girl');
+          }
+
+          // The final chunk is completely hidden
+          assert.equal(
+              groups[6].contextGroups[1].type,
+              GrDiffGroup.Type.BOTH);
+          assert.equal(groups[6].contextGroups[1].lines.length, 3);
+          for (const l of groups[6].contextGroups[1].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('in the middle, larger than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(100)
+              .fill('all work and no play make jill a dull girl')},
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[3].contextGroups[0].lines.length, 80);
+          for (const l of groups[3].contextGroups[0].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[4].lines.length, 10);
+          for (const l of groups[4].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('in the middle, smaller than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(5)
+              .fill('all work and no play make jill a dull girl')},
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content).then(() => {
+          const groups = element.groups;
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, 5);
+          for (const l of groups[2].lines) {
+            assert.equal(
+                l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+    });
+
+    test('break up common diff chunks', () => {
+      element.keyLocations = {
+        left: {1: true},
+        right: {10: true},
+      };
+
+      const content = [
+        {
+          ab: [
+            'Copyright (C) 2015 The Android Open Source Project',
+            '',
+            'Licensed under the Apache License, Version 2.0 (the "License");',
+            'you may not use this file except in compliance with the ' +
+                'License.',
+            'You may obtain a copy of the License at',
+            '',
+            'http://www.apache.org/licenses/LICENSE-2.0',
+            '',
+            'Unless required by applicable law or agreed to in writing, ',
+            'software distributed under the License is distributed on an ',
+            '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
+            'either express or implied. See the License for the specific ',
+            'language governing permissions and limitations under the ' +
+                'License.',
+          ],
+        },
+      ];
+      const result =
+          element._splitCommonChunksWithKeyLocations(content);
+      assert.deepEqual(result, [
+        {
+          ab: ['Copyright (C) 2015 The Android Open Source Project'],
+          keyLocation: true,
+        },
+        {
+          ab: [
+            '',
+            'Licensed under the Apache License, Version 2.0 (the "License");',
+            'you may not use this file except in compliance with the ' +
+                'License.',
+            'You may obtain a copy of the License at',
+            '',
+            'http://www.apache.org/licenses/LICENSE-2.0',
+            '',
+            'Unless required by applicable law or agreed to in writing, ',
+          ],
+          keyLocation: false,
+        },
+        {
+          ab: [
+            'software distributed under the License is distributed on an '],
+          keyLocation: true,
+        },
+        {
+          ab: [
+            '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
+            'either express or implied. See the License for the specific ',
+            'language governing permissions and limitations under the ' +
+                'License.',
+          ],
+          keyLocation: false,
+        },
+      ]);
+    });
+
+    test('breaks down shared chunks w/ whole-file', () => {
+      const size = 120 * 2 + 5;
+      const content = [{
+        ab: _.times(size, () => `${Math.random()}`),
+      }];
+      element.context = -1;
+      const result = element._splitLargeChunks(content);
+      assert.equal(result.length, 2);
+      assert.deepEqual(result[0].ab, content[0].ab.slice(0, 120));
+      assert.deepEqual(result[1].ab, content[0].ab.slice(120));
+    });
+
+    test('does not break-down common chunks w/ context', () => {
+      const content = [{
+        ab: _.times(75, () => `${Math.random()}`),
+      }];
+      element.context = 4;
+      const result =
+          element._splitCommonChunksWithKeyLocations(content);
+      assert.equal(result.length, 1);
+      assert.deepEqual(result[0].ab, content[0].ab);
+      assert.isFalse(result[0].keyLocation);
+    });
+
+    test('intraline normalization', () => {
+      // The content and highlights are in the format returned by the Gerrit
+      // REST API.
+      let content = [
+        '      <section class="summary">',
+        '        <gr-linked-text content="' +
+            '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
+        '      </section>',
+      ];
+      let highlights = [
+        [31, 34], [42, 26],
+      ];
+
+      let results = element._convertIntralineInfos(content,
+          highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 31,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+          endIndex: 33,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 75,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 0,
+          endIndex: 6,
+        },
+      ]);
+
+      content = [
+        '        this._path = value.path;',
+        '',
+        '        // When navigating away from the page, there is a ' +
+          'possibility that the',
+        '        // patch number is no longer a part of the URL ' +
+          '(say when navigating to',
+        '        // the top-level change info view) and therefore ' +
+          'undefined in `params`.',
+        '        if (!this._patchRange.patchNum) {',
+      ];
+      highlights = [
+        [14, 17],
+        [11, 70],
+        [12, 67],
+        [12, 67],
+        [14, 29],
+      ];
+      results = element._convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 14,
+          endIndex: 31,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 8,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 3,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 4,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 5,
+          startIndex: 12,
+          endIndex: 41,
+        },
+      ]);
+    });
+
+    test('scrolling pauses rendering', () => {
+      const contentRow = {
+        ab: [
+          '',
+          '',
+        ],
+      };
+      const content = _.times(200, _.constant(contentRow));
+      sinon.stub(element, 'async');
+      element._isScrolling = true;
+      element.process(content);
+      // Just the files group - no more processing during scrolling.
+      assert.equal(element.groups.length, 1);
+
+      element._isScrolling = false;
+      element.process(content);
+      // More groups have been processed. How many does not matter here.
+      assert.isAtLeast(element.groups.length, 2);
+    });
+
+    test('image diffs', () => {
+      const contentRow = {
+        ab: [
+          '',
+          '',
+        ],
+      };
+      const content = _.times(200, _.constant(contentRow));
+      sinon.stub(element, 'async');
+      element.process(content, true);
+      assert.equal(element.groups.length, 1);
+
+      // Image diffs don't process content, just the 'FILE' line.
+      assert.equal(element.groups[0].lines.length, 1);
+    });
+
+    suite('_processNext', () => {
+      let rows;
+
+      setup(() => {
+        rows = loremIpsum.split(' ');
+      });
+
+      test('WHOLE_FILE', () => {
+        element.context = WHOLE_FILE;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [
+          {a: ['foo']},
+          {ab: rows},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+
+        // Results in one, uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1);
+        assert.equal(result.groups[0].type, GrDiffGroup.Type.BOTH);
+        assert.equal(result.groups[0].lines.length, rows.length);
+
+        // Line numbers are set correctly.
+        assert.equal(
+            result.groups[0].lines[0].beforeNumber,
+            state.lineNums.left + 1);
+        assert.equal(
+            result.groups[0].lines[0].afterNumber,
+            state.lineNums.right + 1);
+
+        assert.equal(result.groups[0].lines[rows.length - 1].beforeNumber,
+            state.lineNums.left + rows.length);
+        assert.equal(result.groups[0].lines[rows.length - 1].afterNumber,
+            state.lineNums.right + rows.length);
+      });
+
+      test('with context', () => {
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [
+          {a: ['foo']},
+          {ab: rows},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+        const expectedCollapseSize = rows.length - 2 * element.context;
+
+        assert.equal(result.groups.length, 3, 'Results in three groups');
+
+        // The first and last are uncollapsed context, whereas the middle has
+        // a single context-control line.
+        assert.equal(result.groups[0].lines.length, element.context);
+        assert.equal(result.groups[2].lines.length, element.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(result.groups[1].contextGroups[0].lines.length,
+            expectedCollapseSize);
+      });
+
+      test('first', () => {
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 0,
+        };
+        const chunks = [
+          {ab: rows},
+          {a: ['foo']},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+        const expectedCollapseSize = rows.length - element.context;
+
+        assert.equal(result.groups.length, 2, 'Results in two groups');
+
+        // Only the first group is collapsed.
+        assert.equal(result.groups[1].lines.length, element.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(result.groups[0].contextGroups[0].lines.length,
+            expectedCollapseSize);
+      });
+
+      test('few-rows', () => {
+        // Only ten rows.
+        rows = rows.slice(0, 10);
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 0,
+        };
+        const chunks = [
+          {ab: rows},
+          {a: ['foo']},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+
+        // Results in one uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1, 'Results in one group');
+        assert.equal(result.groups[0].lines.length, rows.length);
+      });
+
+      test('no single line collapse', () => {
+        rows = rows.slice(0, 7);
+        element.context = 3;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [
+          {a: ['foo']},
+          {ab: rows},
+          {a: ['bar']},
+        ];
+        const result = element._processNext(state, chunks);
+
+        // Results in one uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1, 'Results in one group');
+        assert.equal(result.groups[0].lines.length, rows.length);
+      });
+
+      suite('with key location', () => {
+        let state;
+        let chunks;
+
+        setup(() => {
+          state = {
+            lineNums: {left: 10, right: 100},
+          };
+          element.context = 10;
+          chunks = [
+            {ab: rows},
+            {ab: ['foo'], keyLocation: true},
+            {ab: rows},
+          ];
+        });
+
+        test('context before', () => {
+          state.chunkIndex = 0;
+          const result = element._processNext(state, chunks);
+
+          // The first chunk is split into two groups:
+          // 1) A context-control, hiding everything but the context before
+          //    the key location.
+          // 2) The context before the key location.
+          // The key location is not processed in this call to _processNext
+          assert.equal(result.groups.length, 2);
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result.groups[0].contextGroups[0].lines.length,
+              rows.length - element.context);
+          assert.equal(result.groups[1].lines.length, element.context);
+        });
+
+        test('key location itself', () => {
+          state.chunkIndex = 1;
+          const result = element._processNext(state, chunks);
+
+          // The second chunk results in a single group, that is just the
+          // line with the key location
+          assert.equal(result.groups.length, 1);
+          assert.equal(result.groups[0].lines.length, 1);
+          assert.equal(result.lineDelta.left, 1);
+          assert.equal(result.lineDelta.right, 1);
+        });
+
+        test('context after', () => {
+          state.chunkIndex = 2;
+          const result = element._processNext(state, chunks);
+
+          // The last chunk is split into two groups:
+          // 1) The context after the key location.
+          // 1) A context-control, hiding everything but the context after the
+          //    key location.
+          assert.equal(result.groups.length, 2);
+          assert.equal(result.groups[0].lines.length, element.context);
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result.groups[1].contextGroups[0].lines.length,
+              rows.length - element.context);
+        });
+      });
+    });
+
+    suite('gr-diff-processor helpers', () => {
+      let rows;
+
+      setup(() => {
+        rows = loremIpsum.split(' ');
+      });
+
+      test('_linesFromRows', () => {
+        const startLineNum = 10;
+        let result = element._linesFromRows(GrDiffLine.Type.ADD, rows,
+            startLineNum + 1);
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLine.Type.ADD);
+        assert.equal(result[0].afterNumber, startLineNum + 1);
+        assert.notOk(result[0].beforeNumber);
+        assert.equal(result[result.length - 1].afterNumber,
+            startLineNum + rows.length);
+        assert.notOk(result[result.length - 1].beforeNumber);
+
+        result = element._linesFromRows(GrDiffLine.Type.REMOVE, rows,
+            startLineNum + 1);
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLine.Type.REMOVE);
+        assert.equal(result[0].beforeNumber, startLineNum + 1);
+        assert.notOk(result[0].afterNumber);
+        assert.equal(result[result.length - 1].beforeNumber,
+            startLineNum + rows.length);
+        assert.notOk(result[result.length - 1].afterNumber);
+      });
+    });
+
+    suite('_breakdown*', () => {
+      test('_breakdownChunk breaks down additions', () => {
+        sinon.spy(element, '_breakdown');
+        const chunk = {b: ['blah', 'blah', 'blah']};
+        const result = element._breakdownChunk(chunk);
+        assert.deepEqual(result, [chunk]);
+        assert.isTrue(element._breakdown.called);
+      });
+
+      test('_breakdownChunk keeps due_to_rebase for broken down additions',
+          () => {
+            sinon.spy(element, '_breakdown');
+            const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
+            const result = element._breakdownChunk(chunk);
+            for (const subResult of result) {
+              assert.isTrue(subResult.due_to_rebase);
+            }
+          });
+
+      test('_breakdown common case', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
+            .split(' ');
+        const size = 3;
+
+        const result = element._breakdown(array, size);
+
+        for (const subResult of result) {
+          assert.isAtMost(subResult.length, size);
+        }
+        const flattened = result
+            .reduce((a, b) => a.concat(b), []);
+        assert.deepEqual(flattened, array);
+      });
+
+      test('_breakdown smaller than size', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
+            .split(' ');
+        const size = 10;
+        const expected = [array];
+
+        const result = element._breakdown(array, size);
+
+        assert.deepEqual(result, expected);
+      });
+
+      test('_breakdown empty', () => {
+        const array = [];
+        const size = 10;
+        const expected = [];
+
+        const result = element._breakdown(array, size);
+
+        assert.deepEqual(result, expected);
+      });
+    });
+  });
+
+  test('detaching cancels', () => {
+    element = basicFixture.instantiate();
+    sinon.stub(element, 'cancel');
+    element.detached();
+    assert(element.cancel.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index 08967e8..6d9060f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -14,19 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-diff-selection_html.js';
-import {DomUtilBehavior} from '../../../behaviors/dom-util-behavior/dom-util-behavior.js';
 import {GrRangeNormalizer} from '../gr-diff-highlight/gr-range-normalizer.js';
-import {util} from '../../../scripts/util.js';
+import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util.js';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -42,13 +38,11 @@
 const getNewCache = () => { return {left: null, right: null}; };
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrDiffSelection extends mixinBehaviors( [
-  DomUtilBehavior,
-], GestureEventListeners(
+class GrDiffSelection extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-diff-selection'; }
@@ -180,7 +174,7 @@
    * @return {boolean}
    */
   _elementDescendedFromClass(element, className) {
-    return this.descendedFromClass(element, className,
+    return descendedFromClass(element, className,
         this.diffBuilder.diffElement);
   }
 
@@ -205,7 +199,7 @@
   }
 
   _getSelection() {
-    const diffHosts = util.querySelectorAll(document.body, 'gr-diff');
+    const diffHosts = querySelectorAll(document.body, 'gr-diff');
     if (!diffHosts.length) return window.getSelection();
 
     const curDiffHost = diffHosts.find(diffHost => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.js
deleted file mode 100644
index 620ef02..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.ts
new file mode 100644
index 0000000..bd0e034
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.ts
@@ -0,0 +1,23 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <div class="contentWrapper">
+    <slot></slot>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
deleted file mode 100644
index 1221b58..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ /dev/null
@@ -1,401 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-diff-selection</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-selection>
-      <table id="diffTable" class="side-by-side">
-        <tr class="diff-row">
-          <td class="blame" data-line-number="1"></td>
-          <td class="lineNum left" data-value="1">1</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ba ba</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is a comment</span>
-                </div>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="1">1</td>
-          <td class="content">
-            <div class="contentText" data-side="right">some other text</div>
-          </td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="2"></td>
-          <td class="lineNum left" data-value="2">2</td>
-          <td class="content">
-            <div class="contentText" data-side="left">zin</div>
-          </td>
-          <td class="lineNum right" data-value="2">2</td>
-          <td class="content">
-            <div class="contentText" data-side="right">more more more</div>
-            <div data-side="right">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is a comment on the right</span>
-                </div>
-              </div>
-            </div>
-          </td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="3"></td>
-          <td class="lineNum left" data-value="3">3</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ga ga</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is <a>a</a> different comment 💩 unicode is fun</span>
-                </div>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="3">3</td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="4"></td>
-          <td class="lineNum left" data-value="4">4</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ga ga</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <textarea data-side="right">test for textarea copying</textarea>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="4">4</td>
-        </tr>
-        <tr class="not-diff-row">
-          <td class="other">
-            <div class="contentText" data-side="right">some other text</div>
-          </td>
-        </tr>
-      </table>
-    </gr-diff-selection>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-diff-selection.js';
-suite('gr-diff-selection', () => {
-  let element;
-  let sandbox;
-
-  const emulateCopyOn = function(target) {
-    const fakeEvent = {
-      target,
-      preventDefault: sandbox.stub(),
-      clipboardData: {
-        setData: sandbox.stub(),
-      },
-    };
-    element._getCopyEventTarget.returns(target);
-    element._handleCopy(fakeEvent);
-    return fakeEvent;
-  };
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    sandbox.stub(element, '_getCopyEventTarget');
-    element._cachedDiffBuilder = {
-      getLineElByChild: sandbox.stub().returns({}),
-      getSideByLineEl: sandbox.stub(),
-      diffElement: element.querySelector('#diffTable'),
-    };
-    element.diff = {
-      content: [
-        {
-          a: ['ba ba'],
-          b: ['some other text'],
-        },
-        {
-          a: ['zin'],
-          b: ['more more more'],
-        },
-        {
-          a: ['ga ga'],
-          b: ['some other text'],
-        },
-      ],
-    };
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('applies selected-left on left side click', () => {
-    element.classList.add('selected-right');
-    element._cachedDiffBuilder.getSideByLineEl.returns('left');
-    MockInteractions.down(element);
-    assert.isTrue(
-        element.classList.contains('selected-left'), 'adds selected-left');
-    assert.isFalse(
-        element.classList.contains('selected-right'),
-        'removes selected-right');
-  });
-
-  test('applies selected-right on right side click', () => {
-    element.classList.add('selected-left');
-    element._cachedDiffBuilder.getSideByLineEl.returns('right');
-    MockInteractions.down(element);
-    assert.isTrue(
-        element.classList.contains('selected-right'), 'adds selected-right');
-    assert.isFalse(
-        element.classList.contains('selected-left'), 'removes selected-left');
-  });
-
-  test('applies selected-blame on blame click', () => {
-    element.classList.add('selected-left');
-    element.diffBuilder.getLineElByChild.returns(null);
-    sandbox.stub(element, '_elementDescendedFromClass',
-        (el, className) => className === 'blame');
-    MockInteractions.down(element);
-    assert.isTrue(
-        element.classList.contains('selected-blame'), 'adds selected-right');
-    assert.isFalse(
-        element.classList.contains('selected-left'), 'removes selected-left');
-  });
-
-  test('ignores copy for non-content Element', () => {
-    sandbox.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('.not-diff-row'));
-    assert.isFalse(element._getSelectedText.called);
-  });
-
-  test('asks for text for left side Elements', () => {
-    element._cachedDiffBuilder.getSideByLineEl.returns('left');
-    sandbox.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('div.contentText'));
-    assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
-  });
-
-  test('reacts to copy for content Elements', () => {
-    sandbox.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('div.contentText'));
-    assert.isTrue(element._getSelectedText.called);
-  });
-
-  test('copy event is prevented for content Elements', () => {
-    sandbox.stub(element, '_getSelectedText');
-    element._cachedDiffBuilder.getSideByLineEl.returns('left');
-    element._getSelectedText.returns('test');
-    const event = emulateCopyOn(element.querySelector('div.contentText'));
-    assert.isTrue(event.preventDefault.called);
-  });
-
-  test('inserts text into clipboard on copy', () => {
-    sandbox.stub(element, '_getSelectedText').returns('the text');
-    const event = emulateCopyOn(element.querySelector('div.contentText'));
-    assert.deepEqual(
-        ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
-  });
-
-  test('_setClasses adds given SelectionClass values, removes others', () => {
-    element.classList.add('selected-right');
-    element._setClasses(['selected-comment', 'selected-left']);
-    assert.isTrue(element.classList.contains('selected-comment'));
-    assert.isTrue(element.classList.contains('selected-left'));
-    assert.isFalse(element.classList.contains('selected-right'));
-    assert.isFalse(element.classList.contains('selected-blame'));
-
-    element._setClasses(['selected-blame']);
-    assert.isFalse(element.classList.contains('selected-comment'));
-    assert.isFalse(element.classList.contains('selected-left'));
-    assert.isFalse(element.classList.contains('selected-right'));
-    assert.isTrue(element.classList.contains('selected-blame'));
-  });
-
-  test('_setClasses removes before it ads', () => {
-    element.classList.add('selected-right');
-    const addStub = sandbox.stub(element.classList, 'add');
-    const removeStub = sandbox.stub(element.classList, 'remove', () => {
-      assert.isFalse(addStub.called);
-    });
-    element._setClasses(['selected-comment', 'selected-left']);
-    assert.isTrue(addStub.called);
-    assert.isTrue(removeStub.called);
-  });
-
-  test('copies content correctly', () => {
-    // Fetch the line number.
-    element._cachedDiffBuilder.getLineElByChild = function(child) {
-      while (!child.classList.contains('content') && child.parentElement) {
-        child = child.parentElement;
-      }
-      return child.previousElementSibling;
-    };
-
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-
-    const selection = window.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(element.querySelector('div.contentText').firstChild, 3);
-    range.setEnd(
-        element.querySelectorAll('div.contentText')[4].firstChild, 2);
-    selection.addRange(range);
-    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
-  });
-
-  test('copies comments', () => {
-    element.classList.add('selected-left');
-    element.classList.add('selected-comment');
-    element.classList.remove('selected-right');
-    const selection = window.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(
-        element.querySelector('.gr-formatted-text *').firstChild, 3);
-    range.setEnd(
-        element.querySelectorAll('.gr-formatted-text *')[2].childNodes[2], 7);
-    selection.addRange(range);
-    assert.equal('s is a comment\nThis is a differ',
-        element._getSelectedText('left', true));
-  });
-
-  test('respects astral chars in comments', () => {
-    element.classList.add('selected-left');
-    element.classList.add('selected-comment');
-    element.classList.remove('selected-right');
-    const selection = window.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    const nodes = element.querySelectorAll('.gr-formatted-text *');
-    range.setStart(nodes[2].childNodes[2], 13);
-    range.setEnd(nodes[2].childNodes[2], 23);
-    selection.addRange(range);
-    assert.equal('mment 💩 u',
-        element._getSelectedText('left', true));
-  });
-
-  test('defers to default behavior for textarea', () => {
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-    const selectedTextSpy = sandbox.spy(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('textarea'));
-    assert.isFalse(selectedTextSpy.called);
-  });
-
-  test('regression test for 4794', () => {
-    element._cachedDiffBuilder.getLineElByChild = function(child) {
-      while (!child.classList.contains('content') && child.parentElement) {
-        child = child.parentElement;
-      }
-      return child.previousElementSibling;
-    };
-
-    element.classList.add('selected-right');
-    element.classList.remove('selected-left');
-
-    const selection = window.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(
-        element.querySelectorAll('div.contentText')[1].firstChild, 4);
-    range.setEnd(
-        element.querySelectorAll('div.contentText')[1].firstChild, 10);
-    selection.addRange(range);
-    assert.equal(element._getSelectedText('right'), ' other');
-  });
-
-  test('copies to end of side (issue 7895)', () => {
-    element._cachedDiffBuilder.getLineElByChild = function(child) {
-      // Return null for the end container.
-      if (child.textContent === 'ga ga') { return null; }
-      while (!child.classList.contains('content') && child.parentElement) {
-        child = child.parentElement;
-      }
-      return child.previousElementSibling;
-    };
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-    const selection = window.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(element.querySelector('div.contentText').firstChild, 3);
-    range.setEnd(
-        element.querySelectorAll('div.contentText')[4].firstChild, 2);
-    selection.addRange(range);
-    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
-  });
-
-  suite('_getTextContentForRange', () => {
-    let selection;
-    let range;
-    let nodes;
-
-    setup(() => {
-      element.classList.add('selected-left');
-      element.classList.add('selected-comment');
-      element.classList.remove('selected-right');
-      selection = window.getSelection();
-      selection.removeAllRanges();
-      range = document.createRange();
-      nodes = element.querySelectorAll('.gr-formatted-text *');
-    });
-
-    test('multi level element contained in range', () => {
-      range.setStart(nodes[2].childNodes[0], 1);
-      range.setEnd(nodes[2].childNodes[2], 7);
-      selection.addRange(range);
-      assert.equal(element._getTextContentForRange(element, selection, range),
-          'his is a differ');
-    });
-
-    test('multi level element as startContainer of range', () => {
-      range.setStart(nodes[2].childNodes[1], 0);
-      range.setEnd(nodes[2].childNodes[2], 7);
-      selection.addRange(range);
-      assert.equal(element._getTextContentForRange(element, selection, range),
-          'a differ');
-    });
-
-    test('startContainer === endContainer', () => {
-      range.setStart(nodes[0].firstChild, 2);
-      range.setEnd(nodes[0].firstChild, 12);
-      selection.addRange(range);
-      assert.equal(element._getTextContentForRange(element, selection, range),
-          'is is a co');
-    });
-  });
-
-  test('cache is reset when diff changes', () => {
-    element._linesCache = {left: 'test', right: 'test'};
-    element.diff = {};
-    flushAsynchronousOperations();
-    assert.deepEqual(element._linesCache, {left: null, right: null});
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
new file mode 100644
index 0000000..c37ac93
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
@@ -0,0 +1,389 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../test/common-test-setup-karma.js';
+import './gr-diff-selection.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+// Splitting long lines in html into shorter rows breaks tests:
+// zero-length text nodes and new lines are not expected in some places
+/* eslint-disable max-len */
+const basicFixture = fixtureFromTemplate(html`
+<gr-diff-selection>
+      <table id="diffTable" class="side-by-side">
+        <tr class="diff-row">
+          <td class="blame" data-line-number="1"></td>
+          <td class="lineNum left" data-value="1">1</td>
+          <td class="content">
+            <div class="contentText" data-side="left">ba ba</div>
+            <div data-side="left">
+              <div class="comment-thread">
+                <div class="gr-formatted-text message">
+                  <span id="output" class="gr-linked-text">This is a comment</span>
+                </div>
+              </div>
+            </div>
+          </td>
+          <td class="lineNum right" data-value="1">1</td>
+          <td class="content">
+            <div class="contentText" data-side="right">some other text</div>
+          </td>
+        </tr>
+        <tr class="diff-row">
+          <td class="blame" data-line-number="2"></td>
+          <td class="lineNum left" data-value="2">2</td>
+          <td class="content">
+            <div class="contentText" data-side="left">zin</div>
+          </td>
+          <td class="lineNum right" data-value="2">2</td>
+          <td class="content">
+            <div class="contentText" data-side="right">more more more</div>
+            <div data-side="right">
+              <div class="comment-thread">
+                <div class="gr-formatted-text message">
+                  <span id="output" class="gr-linked-text">This is a comment on the right</span>
+                </div>
+              </div>
+            </div>
+          </td>
+        </tr>
+        <tr class="diff-row">
+          <td class="blame" data-line-number="3"></td>
+          <td class="lineNum left" data-value="3">3</td>
+          <td class="content">
+            <div class="contentText" data-side="left">ga ga</div>
+            <div data-side="left">
+              <div class="comment-thread">
+                <div class="gr-formatted-text message">
+                  <span id="output" class="gr-linked-text">This is <a>a</a> different comment 💩 unicode is fun</span>
+                </div>
+              </div>
+            </div>
+          </td>
+          <td class="lineNum right" data-value="3">3</td>
+        </tr>
+        <tr class="diff-row">
+          <td class="blame" data-line-number="4"></td>
+          <td class="lineNum left" data-value="4">4</td>
+          <td class="content">
+            <div class="contentText" data-side="left">ga ga</div>
+            <div data-side="left">
+              <div class="comment-thread">
+                <textarea data-side="right">test for textarea copying</textarea>
+              </div>
+            </div>
+          </td>
+          <td class="lineNum right" data-value="4">4</td>
+        </tr>
+        <tr class="not-diff-row">
+          <td class="other">
+            <div class="contentText" data-side="right">some other text</div>
+          </td>
+        </tr>
+      </table>
+    </gr-diff-selection>
+`);
+/* eslint-enable max-len */
+
+suite('gr-diff-selection', () => {
+  let element;
+
+  const emulateCopyOn = function(target) {
+    const fakeEvent = {
+      target,
+      preventDefault: sinon.stub(),
+      clipboardData: {
+        setData: sinon.stub(),
+      },
+    };
+    element._getCopyEventTarget.returns(target);
+    element._handleCopy(fakeEvent);
+    return fakeEvent;
+  };
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    sinon.stub(element, '_getCopyEventTarget');
+    element._cachedDiffBuilder = {
+      getLineElByChild: sinon.stub().returns({}),
+      getSideByLineEl: sinon.stub(),
+      diffElement: element.querySelector('#diffTable'),
+    };
+    element.diff = {
+      content: [
+        {
+          a: ['ba ba'],
+          b: ['some other text'],
+        },
+        {
+          a: ['zin'],
+          b: ['more more more'],
+        },
+        {
+          a: ['ga ga'],
+          b: ['some other text'],
+        },
+      ],
+    };
+  });
+
+  test('applies selected-left on left side click', () => {
+    element.classList.add('selected-right');
+    element._cachedDiffBuilder.getSideByLineEl.returns('left');
+    MockInteractions.down(element);
+    assert.isTrue(
+        element.classList.contains('selected-left'), 'adds selected-left');
+    assert.isFalse(
+        element.classList.contains('selected-right'),
+        'removes selected-right');
+  });
+
+  test('applies selected-right on right side click', () => {
+    element.classList.add('selected-left');
+    element._cachedDiffBuilder.getSideByLineEl.returns('right');
+    MockInteractions.down(element);
+    assert.isTrue(
+        element.classList.contains('selected-right'), 'adds selected-right');
+    assert.isFalse(
+        element.classList.contains('selected-left'), 'removes selected-left');
+  });
+
+  test('applies selected-blame on blame click', () => {
+    element.classList.add('selected-left');
+    element.diffBuilder.getLineElByChild.returns(null);
+    sinon.stub(element, '_elementDescendedFromClass').callsFake(
+        (el, className) => className === 'blame');
+    MockInteractions.down(element);
+    assert.isTrue(
+        element.classList.contains('selected-blame'), 'adds selected-right');
+    assert.isFalse(
+        element.classList.contains('selected-left'), 'removes selected-left');
+  });
+
+  test('ignores copy for non-content Element', () => {
+    sinon.stub(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('.not-diff-row'));
+    assert.isFalse(element._getSelectedText.called);
+  });
+
+  test('asks for text for left side Elements', () => {
+    element._cachedDiffBuilder.getSideByLineEl.returns('left');
+    sinon.stub(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('div.contentText'));
+    assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
+  });
+
+  test('reacts to copy for content Elements', () => {
+    sinon.stub(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('div.contentText'));
+    assert.isTrue(element._getSelectedText.called);
+  });
+
+  test('copy event is prevented for content Elements', () => {
+    sinon.stub(element, '_getSelectedText');
+    element._cachedDiffBuilder.getSideByLineEl.returns('left');
+    element._getSelectedText.returns('test');
+    const event = emulateCopyOn(element.querySelector('div.contentText'));
+    assert.isTrue(event.preventDefault.called);
+  });
+
+  test('inserts text into clipboard on copy', () => {
+    sinon.stub(element, '_getSelectedText').returns('the text');
+    const event = emulateCopyOn(element.querySelector('div.contentText'));
+    assert.deepEqual(
+        ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
+  });
+
+  test('_setClasses adds given SelectionClass values, removes others', () => {
+    element.classList.add('selected-right');
+    element._setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(element.classList.contains('selected-comment'));
+    assert.isTrue(element.classList.contains('selected-left'));
+    assert.isFalse(element.classList.contains('selected-right'));
+    assert.isFalse(element.classList.contains('selected-blame'));
+
+    element._setClasses(['selected-blame']);
+    assert.isFalse(element.classList.contains('selected-comment'));
+    assert.isFalse(element.classList.contains('selected-left'));
+    assert.isFalse(element.classList.contains('selected-right'));
+    assert.isTrue(element.classList.contains('selected-blame'));
+  });
+
+  test('_setClasses removes before it ads', () => {
+    element.classList.add('selected-right');
+    const addStub = sinon.stub(element.classList, 'add');
+    const removeStub = sinon.stub(element.classList, 'remove').callsFake(
+        () => {
+          assert.isFalse(addStub.called);
+        });
+    element._setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(addStub.called);
+    assert.isTrue(removeStub.called);
+  });
+
+  test('copies content correctly', () => {
+    // Fetch the line number.
+    element._cachedDiffBuilder.getLineElByChild = function(child) {
+      while (!child.classList.contains('content') && child.parentElement) {
+        child = child.parentElement;
+      }
+      return child.previousElementSibling;
+    };
+
+    element.classList.add('selected-left');
+    element.classList.remove('selected-right');
+
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(element.querySelector('div.contentText').firstChild, 3);
+    range.setEnd(
+        element.querySelectorAll('div.contentText')[4].firstChild, 2);
+    selection.addRange(range);
+    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
+  });
+
+  test('copies comments', () => {
+    element.classList.add('selected-left');
+    element.classList.add('selected-comment');
+    element.classList.remove('selected-right');
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(
+        element.querySelector('.gr-formatted-text *').firstChild, 3);
+    range.setEnd(
+        element.querySelectorAll('.gr-formatted-text *')[2].childNodes[2], 7);
+    selection.addRange(range);
+    assert.equal('s is a comment\nThis is a differ',
+        element._getSelectedText('left', true));
+  });
+
+  test('respects astral chars in comments', () => {
+    element.classList.add('selected-left');
+    element.classList.add('selected-comment');
+    element.classList.remove('selected-right');
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    const nodes = element.querySelectorAll('.gr-formatted-text *');
+    range.setStart(nodes[2].childNodes[2], 13);
+    range.setEnd(nodes[2].childNodes[2], 23);
+    selection.addRange(range);
+    assert.equal('mment 💩 u',
+        element._getSelectedText('left', true));
+  });
+
+  test('defers to default behavior for textarea', () => {
+    element.classList.add('selected-left');
+    element.classList.remove('selected-right');
+    const selectedTextSpy = sinon.spy(element, '_getSelectedText');
+    emulateCopyOn(element.querySelector('textarea'));
+    assert.isFalse(selectedTextSpy.called);
+  });
+
+  test('regression test for 4794', () => {
+    element._cachedDiffBuilder.getLineElByChild = function(child) {
+      while (!child.classList.contains('content') && child.parentElement) {
+        child = child.parentElement;
+      }
+      return child.previousElementSibling;
+    };
+
+    element.classList.add('selected-right');
+    element.classList.remove('selected-left');
+
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(
+        element.querySelectorAll('div.contentText')[1].firstChild, 4);
+    range.setEnd(
+        element.querySelectorAll('div.contentText')[1].firstChild, 10);
+    selection.addRange(range);
+    assert.equal(element._getSelectedText('right'), ' other');
+  });
+
+  test('copies to end of side (issue 7895)', () => {
+    element._cachedDiffBuilder.getLineElByChild = function(child) {
+      // Return null for the end container.
+      if (child.textContent === 'ga ga') { return null; }
+      while (!child.classList.contains('content') && child.parentElement) {
+        child = child.parentElement;
+      }
+      return child.previousElementSibling;
+    };
+    element.classList.add('selected-left');
+    element.classList.remove('selected-right');
+    const selection = window.getSelection();
+    selection.removeAllRanges();
+    const range = document.createRange();
+    range.setStart(element.querySelector('div.contentText').firstChild, 3);
+    range.setEnd(
+        element.querySelectorAll('div.contentText')[4].firstChild, 2);
+    selection.addRange(range);
+    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
+  });
+
+  suite('_getTextContentForRange', () => {
+    let selection;
+    let range;
+    let nodes;
+
+    setup(() => {
+      element.classList.add('selected-left');
+      element.classList.add('selected-comment');
+      element.classList.remove('selected-right');
+      selection = window.getSelection();
+      selection.removeAllRanges();
+      range = document.createRange();
+      nodes = element.querySelectorAll('.gr-formatted-text *');
+    });
+
+    test('multi level element contained in range', () => {
+      range.setStart(nodes[2].childNodes[0], 1);
+      range.setEnd(nodes[2].childNodes[2], 7);
+      selection.addRange(range);
+      assert.equal(element._getTextContentForRange(element, selection, range),
+          'his is a differ');
+    });
+
+    test('multi level element as startContainer of range', () => {
+      range.setStart(nodes[2].childNodes[1], 0);
+      range.setEnd(nodes[2].childNodes[2], 7);
+      selection.addRange(range);
+      assert.equal(element._getTextContentForRange(element, selection, range),
+          'a differ');
+    });
+
+    test('startContainer === endContainer', () => {
+      range.setStart(nodes[0].firstChild, 2);
+      range.setEnd(nodes[0].firstChild, 12);
+      selection.addRange(range);
+      assert.equal(element._getTextContentForRange(element, selection, range),
+          'is is a co');
+    });
+  });
+
+  test('cache is reset when diff changes', () => {
+    element._linesCache = {left: 'test', right: 'test'};
+    element.diff = {};
+    flushAsynchronousOperations();
+    assert.deepEqual(element._linesCache, {left: null, right: null});
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index e434e65..fd17147 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -14,12 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-dropdown/iron-dropdown.js';
 import '@polymer/iron-input/iron-input.js';
 import '../../../styles/shared-styles.js';
-import '../../core/gr-reporting/gr-reporting.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-dropdown/gr-dropdown.js';
 import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
@@ -36,18 +33,27 @@
 import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
 import '../gr-patch-range-select/gr-patch-range-select.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-diff-view_html.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
-import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
+import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
+import {appContext} from '../../../services/app-context.js';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  patchNumEquals,
+  SPECIAL_PATCH_SET_NUM,
+} from '../../../utils/patch-set-util.js';
+import {
+  addUnmodifiedFiles, computeDisplayPath, computeTruncatedPath,
+  isMagicPath, specialFilePathCompare,
+} from '../../../utils/path-list-util.js';
+import {changeBaseURL, changeIsOpen} from '../../../utils/change-util.js';
+import {KnownExperimentId} from '../../../services/flags/flags.js';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const MSG_LOADING_BLAME = 'Loading blame...';
@@ -66,17 +72,10 @@
 };
 
 /**
- * @appliesMixin PatchSetMixin
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrDiffView extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-  PatchSetBehavior,
-  PathListBehavior,
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrDiffView extends KeyboardShortcutMixin(
+    GestureEventListeners(LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-diff-view'; }
@@ -164,6 +163,12 @@
         value() { return {sortedFileList: [], changeFilesByPath: {}}; },
       },
 
+      /** @type {Gerrit.FileRange} */
+      _file: {
+        type: Object,
+        computed: '_getCurrentFile(_files, _path)',
+      },
+
       _path: {
         type: String,
         observer: '_pathChanged',
@@ -181,7 +186,6 @@
         value: true,
       },
       _prefs: Object,
-      _localPrefs: Object,
       _projectConfig: Object,
       _userPrefs: Object,
       _diffMode: {
@@ -222,7 +226,7 @@
       },
       _allPatchSets: {
         type: Array,
-        computed: 'computeAllPatchSets(_change, _change.revisions.*)',
+        computed: '_computeAllPatchSets(_change, _change.revisions.*)',
       },
       _revisionInfo: {
         type: Object,
@@ -237,9 +241,9 @@
        * gr-diff-view has gr-fixed-panel on top. The panel can
        * intersect a main element and partially hides a content of
        * the main element. To correctly calculates visibility of an
-       * element, the cursor must know how much height occuped by a fixed
+       * element, the cursor must know how much height occupied by a fixed
        * panel.
-       * The scrollTopMargin defines margin occuped by fixed panel.
+       * The scrollTopMargin defines margin occupied by fixed panel.
        */
       _scrollTopMargin: {
         type: Number,
@@ -252,7 +256,7 @@
     return [
       '_getProjectConfig(_change.project)',
       '_getFiles(_changeNum, _patchRange.*, _changeComments)',
-      '_setReviewedObserver(_loggedIn, params.*, _prefs)',
+      '_setReviewedObserver(_loggedIn, params.*, _prefs, _patchRange.*)',
       '_recomputeComments(_files.changeFilesByPath,' +
       '_path, _patchRange, _projectConfig)',
     ];
@@ -266,44 +270,61 @@
 
   keyboardShortcuts() {
     return {
-      [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
-      [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
-      [this.Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
-      [this.Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
-      [this.Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
-      [this.Shortcut.NEXT_FILE_WITH_COMMENTS]:
+      [Shortcut.LEFT_PANE]: '_handleLeftPane',
+      [Shortcut.RIGHT_PANE]: '_handleRightPane',
+      [Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
+      [Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
+      [Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
+      [Shortcut.NEXT_FILE_WITH_COMMENTS]:
           '_handleNextLineOrFileWithComments',
-      [this.Shortcut.PREV_FILE_WITH_COMMENTS]:
+      [Shortcut.PREV_FILE_WITH_COMMENTS]:
           '_handlePrevLineOrFileWithComments',
-      [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
-      [this.Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
-      [this.Shortcut.NEXT_FILE]: '_handleNextFile',
-      [this.Shortcut.PREV_FILE]: '_handlePrevFile',
-      [this.Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
-      [this.Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
-      [this.Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
-      [this.Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
-      [this.Shortcut.OPEN_REPLY_DIALOG]:
+      [Shortcut.NEW_COMMENT]: '_handleNewComment',
+      [Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
+      [Shortcut.NEXT_FILE]: '_handleNextFile',
+      [Shortcut.PREV_FILE]: '_handlePrevFile',
+      [Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
+      [Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
+      [Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
+      [Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
+      [Shortcut.OPEN_REPLY_DIALOG]:
           '_handleOpenReplyDialogOrToggleLeftPane',
-      [this.Shortcut.TOGGLE_LEFT_PANE]:
+      [Shortcut.TOGGLE_LEFT_PANE]:
           '_handleOpenReplyDialogOrToggleLeftPane',
-      [this.Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
-      [this.Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
-      [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-      [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
-      [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
-      [this.Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
-      [this.Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
+      [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
+      [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
+      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+      [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+      [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
+      [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
+      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+          '_handleToggleHideAllCommentThreads',
+      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
+      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
+      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]:
+        '_handleDiffRightAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LATEST]:
+        '_handleDiffBaseAgainstLatest',
 
       // Final two are actually handled by gr-comment-thread.
-      [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-      [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
     };
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+    this.flagsService = appContext.flagsService;
+  }
+
   /** @override */
   attached() {
     super.attached();
+    this._isChangeCommentsLinkExperimentEnabled = this.flagsService
+        .isEnabled(KnownExperimentId.PATCHSET_CHOICE_FOR_COMMENT_LINKS);
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
@@ -324,6 +345,7 @@
   }
 
   _getProjectConfig(project) {
+    if (!project) return;
     return this.$.restAPI.getProjectConfig(project).then(
         config => {
           this._projectConfig = config;
@@ -342,9 +364,25 @@
   }
 
   _getSortedFileList(files) {
+    if (!files) return [];
     return files.sortedFileList;
   }
 
+  /**
+   * @param {!Object} files
+   * @param {string} path
+   * @returns {!Gerrit.FileRange}
+   */
+  _getCurrentFile(files, path) {
+    if ([files, path].includes(undefined)) return;
+    const fileInfo = files.changeFilesByPath[path];
+    const fileRange = {path};
+    if (fileInfo && fileInfo.old_path) {
+      fileRange.basePath = fileInfo.old_path;
+    }
+    return fileRange;
+  }
+
   _getFiles(changeNum, patchRangeRecord, changeComments) {
     // Polymer 2: check for undefined
     if ([changeNum, patchRangeRecord, patchRangeRecord.base, changeComments]
@@ -352,18 +390,19 @@
       return Promise.resolve();
     }
 
+    if (!patchRangeRecord.base.patchNum) {
+      return Promise.resolve();
+    }
+
     const patchRange = patchRangeRecord.base;
     return this.$.restAPI.getChangeFiles(
         changeNum, patchRange).then(changeFiles => {
       if (!changeFiles) return;
       const commentedPaths = changeComments.getPaths(patchRange);
-      const files = Object.assign({}, changeFiles);
-      Object.keys(commentedPaths).forEach(commentedPath => {
-        if (files.hasOwnProperty(commentedPath)) { return; }
-        files[commentedPath] = {status: 'U'};
-      });
+      const files = {...changeFiles};
+      addUnmodifiedFiles(files, commentedPaths);
       this._files = {
-        sortedFileList: Object.keys(files).sort(this.specialFilePathCompare),
+        sortedFileList: Object.keys(files).sort(specialFilePathCompare),
         changeFilesByPath: files,
       };
     });
@@ -390,6 +429,7 @@
   _setReviewed(reviewed) {
     if (this._editMode) { return; }
     this.$.reviewed.checked = reviewed;
+    if (!this._patchRange.patchNum) return;
     this._saveReviewedState(reviewed).catch(err => {
       this.dispatchEvent(new CustomEvent('show-alert', {
         detail: {message: ERR_REVIEW_STATUS},
@@ -533,7 +573,9 @@
       this.$.cursor.moveToNextCommentThread();
     } else {
       if (this.modifierPressed(e)) { return; }
-      this.$.cursor.moveToNextChunk();
+      // navigate to next file if key is not being held down
+      this.$.cursor.moveToNextChunk(/* opt_clipToTop = */false,
+          /* opt_navigateToNextFile = */!e.detail.keyboardEvent.repeat);
     }
   }
 
@@ -646,8 +688,13 @@
 
   _goToEditFile() {
     // TODO(taoalpha): add a shortcut for editing
+    const cursorAddress = this.$.cursor.getAddress();
     const editUrl = GerritNav.getEditUrlForDiff(
-        this._change, this._path, this._patchRange.patchNum);
+        this._change,
+        this._path,
+        this._patchRange.patchNum,
+        cursorAddress && cursorAddress.number
+    );
     return GerritNav.navigateToRelativeUrl(editUrl);
   }
 
@@ -705,37 +752,154 @@
         .then(files => files.has(path));
   }
 
+  _initLineOfInterestAndCursor(lineNum, leftSide) {
+    this.$.diffHost.lineOfInterest =
+      this._getLineOfInterest({
+        lineNum,
+        leftSide,
+      });
+    this._initCursor({
+      lineNum,
+      leftSide,
+    });
+  }
+
+  _displayDiffBaseAgainstLeftToast() {
+    this.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {
+        // \u2190 = ←
+        message: `Patchset ${this._patchRange.basePatchNum} vs ` +
+          `${this._patchRange.patchNum} selected. Press v + \u2190 to view `
+          + `Base vs ${this._patchRange.basePatchNum}`,
+      },
+      composed: true, bubbles: true,
+    }));
+  }
+
+  _displayDiffAgainstLatestToast(latestPatchNum) {
+    const leftPatchset = patchNumEquals(
+        this._patchRange.basePatchNum, 'PARENT')
+      ? 'Base' : `Patchset ${this._patchRange.basePatchNum}`;
+    this.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {
+        // \u2191 = ↑
+        message: `${leftPatchset} vs
+            ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
+            ${leftPatchset} vs Patchset ${latestPatchNum}`,
+      },
+      composed: true, bubbles: true,
+    }));
+  }
+
+  _displayToasts() {
+    if (!patchNumEquals(this._patchRange.basePatchNum, 'PARENT')) {
+      this._displayDiffBaseAgainstLeftToast();
+      return;
+    }
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (!patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this._displayDiffAgainstLatestToast(latestPatchNum);
+      return;
+    }
+  }
+
+  _initCommitRange() {
+    let commit;
+    let baseCommit;
+    if (!this._patchRange || !this._patchRange.patchNum) return;
+    for (const commitSha in this._change.revisions) {
+      if (!this._change.revisions.hasOwnProperty(commitSha)) continue;
+      const revision = this._change.revisions[commitSha];
+      const patchNum = revision._number.toString();
+      if (patchNum === this._patchRange.patchNum) {
+        commit = commitSha;
+        const commitObj = revision.commit || {};
+        const parents = commitObj.parents || [];
+        if (this._patchRange.basePatchNum === PARENT && parents.length) {
+          baseCommit = parents[parents.length - 1].commit;
+        }
+      } else if (patchNum === this._patchRange.basePatchNum) {
+        baseCommit = commitSha;
+      }
+    }
+    this._commitRange = {commit, baseCommit};
+  }
+
+  _initPatchRange() {
+    let lineNum; let leftSide;
+    if (this.params.commentId) {
+      const comment = this._changeComments.findCommentById(
+          this.params.commentId);
+      if (!comment) {
+        this.dispatchEvent(new CustomEvent('show-alert', {
+          detail: {
+            message: 'comment not found',
+          },
+          composed: true, bubbles: true,
+        }));
+        GerritNav.navigateToChange(this._change);
+        return;
+      }
+      this._path = comment.path;
+      const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+      if (patchNumEquals(latestPatchNum, comment.patch_set)) {
+        this._patchRange = {
+          patchNum: latestPatchNum,
+          basePatchNum: PARENT,
+        };
+      } else {
+        this._patchRange = {
+          patchNum: latestPatchNum,
+          basePatchNum: comment.patch_set,
+        };
+      }
+      lineNum = comment.line;
+      leftSide = comment.__commentSide === 'left';
+    } else {
+      if (this.params.path) {
+        this._path = this.params.path;
+      }
+      if (this.params.patchNum) {
+        this._patchRange = {
+          patchNum: this.params.patchNum,
+          basePatchNum: this.params.basePatchNum || PARENT,
+        };
+      }
+      if (this.params.lineNum) {
+        lineNum = this.params.lineNum;
+        leftSide = this.params.leftSide;
+      }
+    }
+    this._initLineOfInterestAndCursor(lineNum, leftSide);
+    this._commentMap = this._getPaths(this._patchRange);
+
+    this._commentsForDiff = this._getCommentsForPath(this._path,
+        this._patchRange, this._projectConfig);
+  }
+
   _paramsChanged(value) {
     if (value.view !== GerritNav.View.DIFF) { return; }
 
+    this._change = undefined;
+    this._files = undefined;
+    this._path = undefined;
+    this._patchRange = undefined;
+    this._commitRange = undefined;
+    this._changeComments = undefined;
+
     if (value.changeNum && value.project) {
       this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
     }
 
-    this.$.diffHost.lineOfInterest = this._getLineOfInterest(this.params);
-    this._initCursor(this.params);
-
     this._changeNum = value.changeNum;
-    this._path = value.path;
-    this._patchRange = {
-      patchNum: value.patchNum,
-      basePatchNum: value.basePatchNum || PARENT,
-    };
-
-    // NOTE: This may be called before attachment (e.g. while parentElement is
-    // null). Fire title-change in an async so that, if attachment to the DOM
-    // has been queued, the event can bubble up to the handler in gr-app.
-    this.async(() => {
-      this.dispatchEvent(new CustomEvent('title-change', {
-        detail: {title: this.computeTruncatedPath(this._path)},
-        composed: true, bubbles: true,
-      }));
-    });
+    this.classList.remove('hideComments');
 
     // When navigating away from the page, there is a possibility that the
     // patch number is no longer a part of the URL (say when navigating to
     // the top-level change info view) and therefore undefined in `params`.
-    if (!this._patchRange.patchNum) {
+    // If route is of type /comment/<commentId>/ then no patchNum is present
+    if (!value.patchNum && !value.commentLink) {
+      console.warn('invalid url, no patchNum found');
       return;
     }
 
@@ -747,52 +911,43 @@
       this._userPrefs = prefs;
     }));
 
-    promises.push(this._getChangeDetail(this._changeNum).then(change => {
-      let commit;
-      let baseCommit;
-      if (change) {
-        for (const commitSha in change.revisions) {
-          if (!change.revisions.hasOwnProperty(commitSha)) continue;
-          const revision = change.revisions[commitSha];
-          const patchNum = revision._number.toString();
-          if (patchNum === this._patchRange.patchNum) {
-            commit = commitSha;
-            const commitObj = revision.commit || {};
-            const parents = commitObj.parents || [];
-            if (this._patchRange.basePatchNum === PARENT && parents.length) {
-              baseCommit = parents[parents.length - 1].commit;
-            }
-          } else if (patchNum === this._patchRange.basePatchNum) {
-            baseCommit = commitSha;
-          }
-        }
-        this._commitRange = {commit, baseCommit};
-      }
-    }));
-
+    promises.push(this._getChangeDetail(this._changeNum));
     promises.push(this._loadComments());
 
     promises.push(this._getChangeEdit(this._changeNum));
 
+    this.$.diffHost.cancel();
+    this.$.diffHost.clearDiffContent();
     this._loading = true;
     return Promise.all(promises)
         .then(r => {
+          this._loading = false;
+          this._initPatchRange();
+          this._initCommitRange();
+          this.$.diffHost.comments = this._commentsForDiff;
           const edit = r[4];
           if (edit) {
             this.set('_change.revisions.' + edit.commit.commit, {
-              _number: this.EDIT_NAME,
+              _number: SPECIAL_PATCH_SET_NUM.EDIT,
               basePatchNum: edit.base_patch_set_number,
               commit: edit.commit,
             });
           }
-          this._loading = false;
-          this.$.diffHost.comments = this._commentsForDiff;
           return this.$.diffHost.reload(true);
         })
         .then(() => {
-          this.$.reporting.diffViewFullyLoaded();
+          this.reporting.diffViewFullyLoaded();
           // If diff view displayed has not ended yet, it ends here.
-          this.$.reporting.diffViewDisplayed();
+          this.reporting.diffViewDisplayed();
+        })
+        .then(() => {
+          // If the blame was loaded for a previous file and user navigates to
+          // another file, then we load the blame for this file too
+          if (this._isBlameLoaded) this._loadBlame();
+          if (this._isChangeCommentsLinkExperimentEnabled &&
+            !!value.commentLink) {
+            this._displayToasts();
+          }
         });
   }
 
@@ -805,22 +960,27 @@
     }
   }
 
-  _setReviewedObserver(_loggedIn, paramsRecord, _prefs) {
+  _setReviewedObserver(_loggedIn, paramsRecord, _prefs, patchRangeRecord) {
     // Polymer 2: check for undefined
-    if ([_loggedIn, paramsRecord, _prefs].some(arg => arg === undefined)) {
+    if ([_loggedIn, paramsRecord, _prefs, patchRangeRecord,
+      patchRangeRecord.base].includes(
+        undefined)) {
       return;
     }
-
+    const patchRange = patchRangeRecord.base;
     const params = paramsRecord.base || {};
     if (!_loggedIn) { return; }
 
     if (_prefs.manual_review) {
       // Checkbox state needs to be set explicitly only when manual_review
       // is specified.
-      this._getReviewedStatus(this.editMode, this._changeNum,
-          this._patchRange.patchNum, this._path).then(status => {
-        this.$.reviewed.checked = status;
-      });
+
+      if (patchRange.patchNum) {
+        this._getReviewedStatus(this.editMode, this._changeNum,
+            patchRange.patchNum, this._path).then(status => {
+          this.$.reviewed.checked = status;
+        });
+      }
       return;
     }
 
@@ -852,19 +1012,19 @@
   _pathChanged(path) {
     if (path) {
       this.dispatchEvent(new CustomEvent('title-change', {
-        detail: {title: this.computeTruncatedPath(path)},
+        detail: {title: computeTruncatedPath(path)},
         composed: true, bubbles: true,
       }));
     }
 
-    if (this._fileList.length == 0) { return; }
+    if (!this._fileList || this._fileList.length == 0) { return; }
 
     this.set('changeViewState.selectedFileIndex',
         this._fileList.indexOf(path));
   }
 
   _getDiffUrl(change, patchRange, path) {
-    if ([change, patchRange, path].some(arg => arg === undefined)) {
+    if ([change, patchRange, path].includes(undefined)) {
       return '';
     }
     return GerritNav.getUrlForDiff(change, path, patchRange.patchNum,
@@ -905,7 +1065,7 @@
   }
 
   _getChangePath(change, patchRange, revisions) {
-    if ([change, patchRange].some(arg => arg === undefined)) {
+    if ([change, patchRange].includes(undefined)) {
       return '';
     }
     const range = this._getChangeUrlRange(patchRange, revisions);
@@ -928,7 +1088,7 @@
       files,
       patchNum,
       changeComments,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return;
     }
 
@@ -936,8 +1096,8 @@
     const dropdownContent = [];
     for (const path of files.sortedFileList) {
       dropdownContent.push({
-        text: this.computeDisplayPath(path),
-        mobileText: this.computeTruncatedPath(path),
+        text: computeDisplayPath(path),
+        mobileText: computeTruncatedPath(path),
         value: path,
         bottomText: this._computeCommentString(changeComments, patchNum,
             path, files.changeFilesByPath[path]),
@@ -989,8 +1149,8 @@
 
   _handlePatchChange(e) {
     const {basePatchNum, patchNum} = e.detail;
-    if (this.patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
-        this.patchNumEquals(patchNum, this._patchRange.patchNum)) { return; }
+    if (patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
+        patchNumEquals(patchNum, this._patchRange.patchNum)) { return; }
     GerritNav.navigateToDiff(
         this._change, this._path, patchNum, basePatchNum);
   }
@@ -1025,8 +1185,8 @@
     }
   }
 
-  _computeModeSelectHideClass(isImageDiff) {
-    return isImageDiff ? 'hide' : '';
+  _computeModeSelectHideClass(_diff) {
+    return _diff.binary ? 'hide' : '';
   }
 
   _onLineSelected(e, detail) {
@@ -1089,7 +1249,7 @@
       patchNum = patchRange.basePatchNum;
     }
 
-    let url = this.changeBaseURL(project, changeNum, patchNum) +
+    let url = changeBaseURL(project, changeNum, patchNum) +
         `/files/${encodeURIComponent(path)}/download`;
 
     if (isBase && comparedAgainsParent) {
@@ -1100,7 +1260,7 @@
   }
 
   _computeDownloadPatchLink(project, changeNum, patchRange, path) {
-    let url = this.changeBaseURL(project, changeNum, patchRange.patchNum);
+    let url = changeBaseURL(project, changeNum, patchRange.patchNum);
     url += '/patch?zip&path=' + encodeURIComponent(path);
     return url;
   }
@@ -1108,10 +1268,6 @@
   _loadComments() {
     return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
       this._changeComments = comments;
-      this._commentMap = this._getPaths(this._patchRange);
-
-      this._commentsForDiff = this._getCommentsForPath(this._path,
-          this._patchRange, this._projectConfig);
     });
   }
 
@@ -1122,14 +1278,14 @@
       path,
       patchRange,
       projectConfig,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return undefined;
     }
 
     const file = files[path];
     if (file && file.old_path) {
       this._commentsForDiff = this._changeComments.getCommentsBySideForFile(
-          {path, oldPath: file.old_path},
+          {path, basePath: file.old_path},
           patchRange,
           projectConfig);
 
@@ -1156,7 +1312,7 @@
       commentMap,
       fileList,
       path,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return undefined;
     }
 
@@ -1194,7 +1350,7 @@
    */
   _computeEditMode(patchRangeRecord) {
     const patchRange = patchRangeRecord.base || {};
-    return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
+    return patchNumEquals(patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
   }
 
   /**
@@ -1209,16 +1365,7 @@
     return 'Show blame';
   }
 
-  /**
-   * Load and display blame information if it has not already been loaded.
-   * Otherwise hide it.
-   */
-  _toggleBlame() {
-    if (this._isBlameLoaded) {
-      this.$.diffHost.clearBlame();
-      return;
-    }
-
+  _loadBlame() {
     this._isBlameLoading = true;
     this.dispatchEvent(new CustomEvent('show-alert', {
       detail: {message: MSG_LOADING_BLAME},
@@ -1237,14 +1384,116 @@
         });
   }
 
+  /**
+   * Load and display blame information if it has not already been loaded.
+   * Otherwise hide it.
+   */
+  _toggleBlame() {
+    if (this._isBlameLoaded) {
+      this.$.diffHost.clearBlame();
+      return;
+    }
+    this._loadBlame();
+  }
+
   _handleToggleBlame(e) {
     if (this.shouldSuppressKeyboardShortcut(e) ||
       this.modifierPressed(e)) { return; }
     this._toggleBlame();
   }
 
+  _handleToggleHideAllCommentThreads(e) {
+    if (this.shouldSuppressKeyboardShortcut(e) ||
+      this.modifierPressed(e)) { return; }
+    this.toggleClass('hideComments');
+  }
+
+  _handleDiffAgainstBase(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    if (patchNumEquals(this._patchRange.basePatchNum,
+        SPECIAL_PATCH_SET_NUM.PARENT)) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {
+          message: 'Base is already selected.',
+        },
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    GerritNav.navigateToDiff(
+        this._change, this._path, this._patchRange.patchNum);
+  }
+
+  _handleDiffBaseAgainstLeft(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    if (patchNumEquals(this._patchRange.basePatchNum,
+        SPECIAL_PATCH_SET_NUM.PARENT)) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {
+          message: 'Left is already base.',
+        },
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    GerritNav.navigateToDiff(this._change, this._path,
+        this._patchRange.basePatchNum);
+  }
+
+  _handleDiffAgainstLatest(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {
+          message: 'Latest is already selected.',
+        },
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+
+    GerritNav.navigateToDiff(
+        this._change, this._path, latestPatchNum,
+        this._patchRange.basePatchNum);
+  }
+
+  _handleDiffRightAgainstLatest(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {
+          message: 'Right is already latest.',
+        },
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    GerritNav.navigateToDiff(this._change, this._path, latestPatchNum,
+        this._patchRange.patchNum);
+  }
+
+  _handleDiffBaseAgainstLatest(e) {
+    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
+      patchNumEquals(this._patchRange.basePatchNum,
+          SPECIAL_PATCH_SET_NUM.PARENT)) {
+      this.dispatchEvent(new CustomEvent('show-alert', {
+        detail: {
+          message: 'Already diffing base against latest.',
+        },
+        composed: true, bubbles: true,
+      }));
+      return;
+    }
+    GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
+  }
+
   _computeBlameLoaderClass(isImageDiff, path) {
-    return !this.isMagicPath(path) && !isImageDiff ? 'show' : '';
+    return !isMagicPath(path) && !isImageDiff ? 'show' : '';
   }
 
   _getRevisionInfo(change) {
@@ -1253,7 +1502,7 @@
 
   _computeFileNum(file, files) {
     // Polymer 2: check for undefined
-    if ([file, files].some(arg => arg === undefined)) {
+    if ([file, files].includes(undefined)) {
       return undefined;
     }
 
@@ -1305,7 +1554,21 @@
         .some(arg => arg === undefined)) {
       return false;
     }
-    return loggedIn && this.changeIsOpen(changeChangeRecord.base);
+    return loggedIn && changeIsOpen(changeChangeRecord.base);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeAllPatchSets(change) {
+    return computeAllPatchSets(change);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeDisplayPath(path) {
+    return computeDisplayPath(path);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
deleted file mode 100644
index c74a192..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.js
+++ /dev/null
@@ -1,424 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      background-color: var(--view-background-color);
-    }
-    .hidden {
-      display: none;
-    }
-    gr-patch-range-select {
-      display: block;
-    }
-    gr-diff {
-      border: none;
-      --diff-container-styles: {
-        border-bottom: 1px solid var(--border-color);
-      }
-    }
-    gr-fixed-panel {
-      background-color: var(--view-background-color);
-      border-bottom: 1px solid var(--border-color);
-      z-index: 1;
-    }
-    header,
-    .subHeader {
-      align-items: center;
-      display: flex;
-      justify-content: space-between;
-    }
-    header {
-      padding: var(--spacing-s) var(--spacing-xl);
-      border-bottom: 1px solid var(--border-color);
-    }
-    .changeNumberColon {
-      color: transparent;
-    }
-    .headerSubject {
-      margin-right: var(--spacing-m);
-      font-weight: var(--font-weight-bold);
-    }
-    .patchRangeLeft {
-      align-items: center;
-      display: flex;
-    }
-    .navLink:not([href]) {
-      color: var(--deemphasized-text-color);
-    }
-    .navLinks {
-      align-items: center;
-      display: flex;
-      white-space: nowrap;
-    }
-    .navLink {
-      padding: 0 var(--spacing-xs);
-    }
-    .reviewed {
-      display: inline-block;
-      margin: 0 var(--spacing-xs);
-      vertical-align: 0.15em;
-    }
-    .jumpToFileContainer {
-      display: inline-block;
-    }
-    .mobile {
-      display: none;
-    }
-    gr-button {
-      padding: var(--spacing-s) 0;
-      text-decoration: none;
-    }
-    .loading {
-      color: var(--deemphasized-text-color);
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h1);
-      font-weight: var(--font-weight-h1);
-      line-height: var(--line-height-h1);
-      height: 100%;
-      padding: var(--spacing-l);
-      text-align: center;
-    }
-    .subHeader {
-      background-color: var(--background-color-secondary);
-      flex-wrap: wrap;
-      padding: 0 var(--spacing-l);
-    }
-    .prefsButton {
-      text-align: right;
-    }
-    .noOverflow {
-      display: block;
-      overflow: auto;
-    }
-    .editMode .hideOnEdit {
-      display: none;
-    }
-    .blameLoader,
-    .fileNum {
-      display: none;
-    }
-    .blameLoader.show,
-    .fileNum.show,
-    .download,
-    .preferences,
-    .rightControls {
-      align-items: center;
-      display: flex;
-    }
-    .diffModeSelector,
-    .editButton {
-      align-items: center;
-      display: flex;
-    }
-    .diffModeSelector span,
-    .editButton span {
-      margin-right: var(--spacing-xs);
-    }
-    .diffModeSelector.hide,
-    .separator.hide {
-      display: none;
-    }
-    gr-dropdown-list {
-      --trigger-style: {
-        text-transform: none;
-      }
-    }
-    .editButtona a {
-      text-decoration: none;
-    }
-    @media screen and (max-width: 50em) {
-      header {
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-      .dash {
-        display: none;
-      }
-      .desktop {
-        display: none;
-      }
-      .fileNav {
-        align-items: flex-start;
-        display: flex;
-        margin: 0 var(--spacing-xs);
-      }
-      .fullFileName {
-        display: block;
-        font-style: italic;
-        min-width: 50%;
-        padding: 0 var(--spacing-xxs);
-        text-align: center;
-        width: 100%;
-        word-wrap: break-word;
-      }
-      .reviewed {
-        vertical-align: -1px;
-      }
-      .mobileNavLink {
-        color: var(--primary-text-color);
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h2);
-        font-weight: var(--font-weight-h2);
-        line-height: var(--line-height-h2);
-        text-decoration: none;
-      }
-      .mobileNavLink:not([href]) {
-        color: var(--deemphasized-text-color);
-      }
-      .jumpToFileContainer {
-        display: block;
-        width: 100%;
-      }
-      gr-dropdown-list {
-        width: 100%;
-        --gr-select-style: {
-          display: block;
-          width: 100%;
-        }
-        --native-select-style: {
-          width: 100%;
-        }
-      }
-    }
-  </style>
-  <gr-fixed-panel
-    class$="[[_computeContainerClass(_editMode)]]"
-    floating-disabled="[[_panelFloatingDisabled]]"
-    keep-on-scroll=""
-    ready-for-measure="[[!_loading]]"
-    on-floating-height-changed="_onChangeHeaderPanelHeightChanged"
-  >
-    <header>
-      <div>
-        <a
-          href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]"
-          >[[_changeNum]]</a
-        ><!--
-       --><span class="changeNumberColon">:</span>
-        <span class="headerSubject">[[_change.subject]]</span>
-        <input
-          id="reviewed"
-          class="reviewed hideOnEdit"
-          type="checkbox"
-          on-change="_handleReviewedChange"
-          hidden$="[[!_loggedIn]]"
-          hidden=""
-        /><!--
-       -->
-        <div class="jumpToFileContainer">
-          <gr-dropdown-list
-            id="dropdown"
-            value="[[_path]]"
-            on-value-change="_handleFileChange"
-            items="[[_formattedFiles]]"
-            initial-count="75"
-          >
-          </gr-dropdown-list>
-        </div>
-      </div>
-      <div class="navLinks desktop">
-        <span
-          class$="fileNum [[_computeFileNumClass(_fileNum, _formattedFiles)]]"
-        >
-          File [[_fileNum]] of [[_formattedFiles.length]]
-          <span class="separator"></span>
-        </span>
-        <a
-          class="navLink"
-          title="[[createTitle(Shortcut.PREV_FILE,
-                    ShortcutSection.NAVIGATION)]]"
-          href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]"
-        >
-          Prev</a
-        >
-        <span class="separator"></span>
-        <a
-          class="navLink"
-          title="[[createTitle(Shortcut.UP_TO_CHANGE,
-                ShortcutSection.NAVIGATION)]]"
-          href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]"
-        >
-          Up</a
-        >
-        <span class="separator"></span>
-        <a
-          class="navLink"
-          title="[[createTitle(Shortcut.NEXT_FILE,
-                ShortcutSection.NAVIGATION)]]"
-          href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]"
-        >
-          Next</a
-        >
-      </div>
-    </header>
-    <div class="subHeader">
-      <div class="patchRangeLeft">
-        <gr-patch-range-select
-          id="rangeSelect"
-          change-num="[[_changeNum]]"
-          change-comments="[[_changeComments]]"
-          patch-num="[[_patchRange.patchNum]]"
-          base-patch-num="[[_patchRange.basePatchNum]]"
-          files-weblinks="[[_filesWeblinks]]"
-          available-patches="[[_allPatchSets]]"
-          revisions="[[_change.revisions]]"
-          revision-info="[[_revisionInfo]]"
-          on-patch-range-change="_handlePatchChange"
-        >
-        </gr-patch-range-select>
-        <span class="download desktop">
-          <span class="separator"></span>
-          <gr-dropdown
-            link=""
-            down-arrow=""
-            items="[[_computeDownloadDropdownLinks(_change.project, _changeNum, _patchRange, _path, _diff)]]"
-            horizontal-align="left"
-          >
-            <span class="downloadTitle">
-              Download
-            </span>
-          </gr-dropdown>
-        </span>
-      </div>
-      <div class="rightControls">
-        <span
-          class$="blameLoader [[_computeBlameLoaderClass(_isImageDiff, _path)]]"
-        >
-          <gr-button
-            link=""
-            id="toggleBlame"
-            title="[[createTitle(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS)]]"
-            disabled="[[_isBlameLoading]]"
-            on-click="_toggleBlame"
-            >[[_computeBlameToggleLabel(_isBlameLoaded,
-            _isBlameLoading)]]</gr-button
-          >
-        </span>
-        <template is="dom-if" if="[[_computeCanEdit(_loggedIn, _change.*)]]">
-          <span class="separator"></span>
-          <span class="editButton">
-            <gr-button
-              link=""
-              title="Edit current file"
-              on-click="_goToEditFile"
-              >edit</gr-button
-            >
-          </span>
-        </template>
-        <span class="separator"></span>
-        <div
-          class$="diffModeSelector [[_computeModeSelectHideClass(_isImageDiff)]]"
-        >
-          <span>Diff view:</span>
-          <gr-diff-mode-selector
-            id="modeSelect"
-            save-on-change="[[!_diffPrefsDisabled]]"
-            mode="{{changeViewState.diffMode}}"
-          ></gr-diff-mode-selector>
-        </div>
-        <span
-          id="diffPrefsContainer"
-          hidden$="[[_computePrefsButtonHidden(_prefs, _diffPrefsDisabled)]]"
-          hidden=""
-        >
-          <span class="preferences desktop">
-            <gr-button
-              link=""
-              class="prefsButton"
-              has-tooltip=""
-              title="Diff preferences"
-              on-click="_handlePrefsTap"
-              ><iron-icon icon="gr-icons:settings"></iron-icon
-            ></gr-button>
-          </span>
-        </span>
-        <gr-endpoint-decorator name="annotation-toggler">
-          <span hidden="" id="annotation-span">
-            <label for="annotation-checkbox" id="annotation-label"></label>
-            <iron-input type="checkbox" disabled="">
-              <input
-                is="iron-input"
-                type="checkbox"
-                id="annotation-checkbox"
-                disabled=""
-              />
-            </iron-input>
-          </span>
-        </gr-endpoint-decorator>
-      </div>
-    </div>
-    <div class="fileNav mobile">
-      <a
-        class="mobileNavLink"
-        href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]"
-      >
-        &lt;</a
-      >
-      <div class="fullFileName mobile">[[computeDisplayPath(_path)]]</div>
-      <a
-        class="mobileNavLink"
-        href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]"
-      >
-        &gt;</a
-      >
-    </div>
-  </gr-fixed-panel>
-  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-  <gr-diff-host
-    id="diffHost"
-    hidden=""
-    hidden$="[[_loading]]"
-    class$="[[_computeDiffClass(_panelFloatingDisabled)]]"
-    is-image-diff="{{_isImageDiff}}"
-    files-weblinks="{{_filesWeblinks}}"
-    diff="{{_diff}}"
-    change-num="[[_changeNum]]"
-    commit-range="[[_commitRange]]"
-    patch-range="[[_patchRange]]"
-    path="[[_path]]"
-    prefs="[[_prefs]]"
-    project-name="[[_change.project]]"
-    view-mode="[[_diffMode]]"
-    is-blame-loaded="{{_isBlameLoaded}}"
-    on-comment-anchor-tap="_onLineSelected"
-    on-line-selected="_onLineSelected"
-  >
-  </gr-diff-host>
-  <gr-apply-fix-dialog
-    id="applyFixDialog"
-    prefs="[[_prefs]]"
-    change="[[_change]]"
-    change-num="[[_changeNum]]"
-  >
-  </gr-apply-fix-dialog>
-  <gr-diff-preferences-dialog
-    id="diffPreferencesDialog"
-    diff-prefs="{{_prefs}}"
-    on-reload-diff-preference="_handleReloadingDiffPreference"
-  >
-  </gr-diff-preferences-dialog>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
-  <gr-diff-cursor
-    id="cursor"
-    scroll-top-margin="[[_scrollTopMargin]]"
-  ></gr-diff-cursor>
-  <gr-comment-api id="commentAPI"></gr-comment-api>
-  <gr-reporting id="reporting"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
new file mode 100644
index 0000000..e2cb880
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -0,0 +1,426 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--view-background-color);
+    }
+    .hidden {
+      display: none;
+    }
+    gr-patch-range-select {
+      display: block;
+    }
+    gr-diff {
+      border: none;
+      --diff-container-styles: {
+        border-bottom: 1px solid var(--border-color);
+      }
+    }
+    gr-fixed-panel {
+      background-color: var(--view-background-color);
+      border-bottom: 1px solid var(--border-color);
+      z-index: 1;
+    }
+    header,
+    .subHeader {
+      align-items: center;
+      display: flex;
+      justify-content: space-between;
+    }
+    header {
+      padding: var(--spacing-s) var(--spacing-xl);
+      border-bottom: 1px solid var(--border-color);
+    }
+    .changeNumberColon {
+      color: transparent;
+    }
+    .headerSubject {
+      margin-right: var(--spacing-m);
+      font-weight: var(--font-weight-bold);
+    }
+    .patchRangeLeft {
+      align-items: center;
+      display: flex;
+    }
+    .navLink:not([href]) {
+      color: var(--deemphasized-text-color);
+    }
+    .navLinks {
+      align-items: center;
+      display: flex;
+      white-space: nowrap;
+    }
+    .navLink {
+      padding: 0 var(--spacing-xs);
+    }
+    .reviewed {
+      display: inline-block;
+      margin: 0 var(--spacing-xs);
+      vertical-align: 0.15em;
+    }
+    .jumpToFileContainer {
+      display: inline-block;
+    }
+    .mobile {
+      display: none;
+    }
+    gr-button {
+      padding: var(--spacing-s) 0;
+      text-decoration: none;
+    }
+    .loading {
+      color: var(--deemphasized-text-color);
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h1);
+      font-weight: var(--font-weight-h1);
+      line-height: var(--line-height-h1);
+      height: 100%;
+      padding: var(--spacing-l);
+      text-align: center;
+    }
+    .subHeader {
+      background-color: var(--background-color-secondary);
+      flex-wrap: wrap;
+      padding: 0 var(--spacing-l);
+    }
+    .prefsButton {
+      text-align: right;
+    }
+    .noOverflow {
+      display: block;
+      overflow: auto;
+    }
+    .editMode .hideOnEdit {
+      display: none;
+    }
+    .blameLoader,
+    .fileNum {
+      display: none;
+    }
+    .blameLoader.show,
+    .fileNum.show,
+    .download,
+    .preferences,
+    .rightControls {
+      align-items: center;
+      display: flex;
+    }
+    .diffModeSelector,
+    .editButton {
+      align-items: center;
+      display: flex;
+    }
+    .diffModeSelector span,
+    .editButton span {
+      margin-right: var(--spacing-xs);
+    }
+    .diffModeSelector.hide,
+    .separator.hide {
+      display: none;
+    }
+    gr-dropdown-list {
+      --trigger-style: {
+        text-transform: none;
+      }
+    }
+    .editButtona a {
+      text-decoration: none;
+    }
+    @media screen and (max-width: 50em) {
+      header {
+        padding: var(--spacing-s) var(--spacing-l);
+      }
+      .dash {
+        display: none;
+      }
+      .desktop {
+        display: none;
+      }
+      .fileNav {
+        align-items: flex-start;
+        display: flex;
+        margin: 0 var(--spacing-xs);
+      }
+      .fullFileName {
+        display: block;
+        font-style: italic;
+        min-width: 50%;
+        padding: 0 var(--spacing-xxs);
+        text-align: center;
+        width: 100%;
+        word-wrap: break-word;
+      }
+      .reviewed {
+        vertical-align: -1px;
+      }
+      .mobileNavLink {
+        color: var(--primary-text-color);
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h2);
+        font-weight: var(--font-weight-h2);
+        line-height: var(--line-height-h2);
+        text-decoration: none;
+      }
+      .mobileNavLink:not([href]) {
+        color: var(--deemphasized-text-color);
+      }
+      .jumpToFileContainer {
+        display: block;
+        width: 100%;
+      }
+      gr-dropdown-list {
+        width: 100%;
+        --gr-select-style: {
+          display: block;
+          width: 100%;
+        }
+        --native-select-style: {
+          width: 100%;
+        }
+      }
+    }
+    :host(.hideComments) {
+      --gr-comment-thread-display: none;
+    }
+  </style>
+  <gr-fixed-panel
+    class$="[[_computeContainerClass(_editMode)]]"
+    floating-disabled="[[_panelFloatingDisabled]]"
+    keep-on-scroll=""
+    ready-for-measure="[[!_loading]]"
+    on-floating-height-changed="_onChangeHeaderPanelHeightChanged"
+  >
+    <header>
+      <div>
+        <a
+          href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]"
+          >[[_changeNum]]</a
+        ><!--
+       --><span class="changeNumberColon">:</span>
+        <span class="headerSubject">[[_change.subject]]</span>
+        <input
+          id="reviewed"
+          class="reviewed hideOnEdit"
+          type="checkbox"
+          on-change="_handleReviewedChange"
+          hidden$="[[!_loggedIn]]"
+          hidden=""
+        /><!--
+       -->
+        <div class="jumpToFileContainer">
+          <gr-dropdown-list
+            id="dropdown"
+            value="[[_path]]"
+            on-value-change="_handleFileChange"
+            items="[[_formattedFiles]]"
+            initial-count="75"
+          >
+          </gr-dropdown-list>
+        </div>
+      </div>
+      <div class="navLinks desktop">
+        <span
+          class$="fileNum [[_computeFileNumClass(_fileNum, _formattedFiles)]]"
+        >
+          File [[_fileNum]] of [[_formattedFiles.length]]
+          <span class="separator"></span>
+        </span>
+        <a
+          class="navLink"
+          title="[[createTitle(Shortcut.PREV_FILE,
+                    ShortcutSection.NAVIGATION)]]"
+          href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]"
+        >
+          Prev</a
+        >
+        <span class="separator"></span>
+        <a
+          class="navLink"
+          title="[[createTitle(Shortcut.UP_TO_CHANGE,
+                ShortcutSection.NAVIGATION)]]"
+          href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]"
+        >
+          Up</a
+        >
+        <span class="separator"></span>
+        <a
+          class="navLink"
+          title="[[createTitle(Shortcut.NEXT_FILE,
+                ShortcutSection.NAVIGATION)]]"
+          href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]"
+        >
+          Next</a
+        >
+      </div>
+    </header>
+    <div class="subHeader">
+      <div class="patchRangeLeft">
+        <gr-patch-range-select
+          id="rangeSelect"
+          change-num="[[_changeNum]]"
+          change-comments="[[_changeComments]]"
+          patch-num="[[_patchRange.patchNum]]"
+          base-patch-num="[[_patchRange.basePatchNum]]"
+          files-weblinks="[[_filesWeblinks]]"
+          available-patches="[[_allPatchSets]]"
+          revisions="[[_change.revisions]]"
+          revision-info="[[_revisionInfo]]"
+          on-patch-range-change="_handlePatchChange"
+        >
+        </gr-patch-range-select>
+        <span class="download desktop">
+          <span class="separator"></span>
+          <gr-dropdown
+            link=""
+            down-arrow=""
+            items="[[_computeDownloadDropdownLinks(_change.project, _changeNum, _patchRange, _path, _diff)]]"
+            horizontal-align="left"
+          >
+            <span class="downloadTitle">
+              Download
+            </span>
+          </gr-dropdown>
+        </span>
+      </div>
+      <div class="rightControls">
+        <span
+          class$="blameLoader [[_computeBlameLoaderClass(_isImageDiff, _path)]]"
+        >
+          <gr-button
+            link=""
+            id="toggleBlame"
+            title="[[createTitle(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS)]]"
+            disabled="[[_isBlameLoading]]"
+            on-click="_toggleBlame"
+            >[[_computeBlameToggleLabel(_isBlameLoaded,
+            _isBlameLoading)]]</gr-button
+          >
+        </span>
+        <template is="dom-if" if="[[_computeCanEdit(_loggedIn, _change.*)]]">
+          <span class="separator"></span>
+          <span class="editButton">
+            <gr-button
+              link=""
+              title="Edit current file"
+              on-click="_goToEditFile"
+              >edit</gr-button
+            >
+          </span>
+        </template>
+        <span class="separator"></span>
+        <div class$="diffModeSelector [[_computeModeSelectHideClass(_diff)]]">
+          <span>Diff view:</span>
+          <gr-diff-mode-selector
+            id="modeSelect"
+            save-on-change="[[!_diffPrefsDisabled]]"
+            mode="{{changeViewState.diffMode}}"
+          ></gr-diff-mode-selector>
+        </div>
+        <span
+          id="diffPrefsContainer"
+          hidden$="[[_computePrefsButtonHidden(_prefs, _diffPrefsDisabled)]]"
+          hidden=""
+        >
+          <span class="preferences desktop">
+            <gr-button
+              link=""
+              class="prefsButton"
+              has-tooltip=""
+              title="Diff preferences"
+              on-click="_handlePrefsTap"
+              ><iron-icon icon="gr-icons:settings"></iron-icon
+            ></gr-button>
+          </span>
+        </span>
+        <gr-endpoint-decorator name="annotation-toggler">
+          <span hidden="" id="annotation-span">
+            <label for="annotation-checkbox" id="annotation-label"></label>
+            <iron-input type="checkbox" disabled="">
+              <input
+                is="iron-input"
+                type="checkbox"
+                id="annotation-checkbox"
+                disabled=""
+              />
+            </iron-input>
+          </span>
+        </gr-endpoint-decorator>
+      </div>
+    </div>
+    <div class="fileNav mobile">
+      <a
+        class="mobileNavLink"
+        href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]"
+      >
+        &lt;</a
+      >
+      <div class="fullFileName mobile">[[_computeDisplayPath(_path)]]</div>
+      <a
+        class="mobileNavLink"
+        href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]"
+      >
+        &gt;</a
+      >
+    </div>
+  </gr-fixed-panel>
+  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+  <gr-diff-host
+    id="diffHost"
+    hidden=""
+    hidden$="[[_loading]]"
+    class$="[[_computeDiffClass(_panelFloatingDisabled)]]"
+    is-image-diff="{{_isImageDiff}}"
+    files-weblinks="{{_filesWeblinks}}"
+    diff="{{_diff}}"
+    change-num="[[_changeNum]]"
+    commit-range="[[_commitRange]]"
+    patch-range="[[_patchRange]]"
+    file="[[_file]]"
+    path="[[_path]]"
+    prefs="[[_prefs]]"
+    project-name="[[_change.project]]"
+    view-mode="[[_diffMode]]"
+    is-blame-loaded="{{_isBlameLoaded}}"
+    on-comment-anchor-tap="_onLineSelected"
+    on-line-selected="_onLineSelected"
+  >
+  </gr-diff-host>
+  <gr-apply-fix-dialog
+    id="applyFixDialog"
+    prefs="[[_prefs]]"
+    change="[[_change]]"
+    change-num="[[_changeNum]]"
+  >
+  </gr-apply-fix-dialog>
+  <gr-diff-preferences-dialog
+    id="diffPreferencesDialog"
+    diff-prefs="{{_prefs}}"
+    on-reload-diff-preference="_handleReloadingDiffPreference"
+  >
+  </gr-diff-preferences-dialog>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-storage id="storage"></gr-storage>
+  <gr-diff-cursor
+    id="cursor"
+    scroll-top-margin="[[_scrollTopMargin]]"
+    on-navigate-to-next-unreviewed-file="_handleNextUnreviewedFile"
+  ></gr-diff-cursor>
+  <gr-comment-api id="commentAPI"></gr-comment-api>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
deleted file mode 100644
index f5275e2..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ /dev/null
@@ -1,1471 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-diff-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script src="/node_modules/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-view></gr-diff-view>
-  </template>
-</test-fixture>
-
-<test-fixture id="blank">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-diff-view.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {KeyboardShortcutBinder} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-diff-view tests', () => {
-  suite('basic tests', () => {
-    const kb = KeyboardShortcutBinder;
-    kb.bindShortcut(kb.Shortcut.LEFT_PANE, 'shift+left');
-    kb.bindShortcut(kb.Shortcut.RIGHT_PANE, 'shift+right');
-    kb.bindShortcut(kb.Shortcut.NEXT_LINE, 'j', 'down');
-    kb.bindShortcut(kb.Shortcut.PREV_LINE, 'k', 'up');
-    kb.bindShortcut(kb.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
-    kb.bindShortcut(kb.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
-    kb.bindShortcut(kb.Shortcut.NEW_COMMENT, 'c');
-    kb.bindShortcut(kb.Shortcut.SAVE_COMMENT, 'ctrl+s');
-    kb.bindShortcut(kb.Shortcut.NEXT_FILE, ']');
-    kb.bindShortcut(kb.Shortcut.PREV_FILE, '[');
-    kb.bindShortcut(kb.Shortcut.NEXT_CHUNK, 'n');
-    kb.bindShortcut(kb.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
-    kb.bindShortcut(kb.Shortcut.PREV_CHUNK, 'p');
-    kb.bindShortcut(kb.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
-    kb.bindShortcut(kb.Shortcut.OPEN_REPLY_DIALOG, 'a');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-    kb.bindShortcut(kb.Shortcut.UP_TO_CHANGE, 'u');
-    kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_DIFF_MODE, 'm');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-    kb.bindShortcut(kb.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
-    kb.bindShortcut(kb.Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
-    kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
-    kb.bindShortcut(kb.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
-    kb.bindShortcut(kb.Shortcut.TOGGLE_BLAME, 'b');
-
-    let element;
-    let sandbox;
-
-    const PARENT = 'PARENT';
-
-    function getFilesFromFileList(fileList) {
-      const changeFilesByPath = fileList.reduce((files, path) => {
-        files[path] = {};
-        return files;
-      }, {});
-      return {
-        sortedFileList: fileList,
-        changeFilesByPath,
-      };
-    }
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-
-      stub('gr-rest-api-interface', {
-        getConfig() {
-          return Promise.resolve({change: {}});
-        },
-        getLoggedIn() {
-          return Promise.resolve(false);
-        },
-        getProjectConfig() {
-          return Promise.resolve({});
-        },
-        getDiffChangeDetail() {
-          return Promise.resolve({});
-        },
-        getChangeFiles() {
-          return Promise.resolve({});
-        },
-        saveFileReviewed() {
-          return Promise.resolve();
-        },
-        getDiffComments() {
-          return Promise.resolve({});
-        },
-        getDiffRobotComments() {
-          return Promise.resolve({});
-        },
-        getDiffDrafts() {
-          return Promise.resolve({});
-        },
-        getReviewedFiles() {
-          return Promise.resolve([]);
-        },
-      });
-      element = fixture('basic');
-      return element._loadComments();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('params change triggers diffViewDisplayed()', () => {
-      sandbox.stub(element.$.reporting, 'diffViewDisplayed');
-      sandbox.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-      sandbox.spy(element, '_paramsChanged');
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: '2',
-        basePatchNum: '1',
-        path: '/COMMIT_MSG',
-      };
-
-      return element._paramsChanged.returnValues[0].then(() => {
-        assert.isTrue(element.$.reporting.diffViewDisplayed.calledOnce);
-      });
-    });
-
-    test('toggle left diff with a hotkey', () => {
-      const toggleLeftDiffStub = sandbox.stub(
-          element.$.diffHost, 'toggleLeftDiff');
-      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-      assert.isTrue(toggleLeftDiffStub.calledOnce);
-    });
-
-    test('keyboard shortcuts', () => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: '10',
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 10, commit: {parents: []}},
-        },
-      };
-      element._files = getFilesFromFileList(
-          ['chell.go', 'glados.txt', 'wheatley.md']);
-      element._path = 'glados.txt';
-      element.changeViewState.selectedFileIndex = 1;
-      element._loggedIn = true;
-
-      const diffNavStub = sandbox.stub(GerritNav, 'navigateToDiff');
-      const changeNavStub = sandbox.stub(GerritNav, 'navigateToChange');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(changeNavStub.lastCall.calledWith(element._change),
-          'Should navigate to /c/42/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-      assert(diffNavStub.lastCall.calledWith(element._change, 'wheatley.md',
-          '10', PARENT), 'Should navigate to /c/42/10/wheatley.md');
-      element._path = 'wheatley.md';
-      assert.equal(element.changeViewState.selectedFileIndex, 2);
-      assert.isTrue(element._loading);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWith(element._change, 'glados.txt',
-          '10', PARENT), 'Should navigate to /c/42/10/glados.txt');
-      element._path = 'glados.txt';
-      assert.equal(element.changeViewState.selectedFileIndex, 1);
-      assert.isTrue(element._loading);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWith(element._change, 'chell.go', '10',
-          PARENT), 'Should navigate to /c/42/10/chell.go');
-      element._path = 'chell.go';
-      assert.equal(element.changeViewState.selectedFileIndex, 0);
-      assert.isTrue(element._loading);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(changeNavStub.lastCall.calledWith(element._change),
-          'Should navigate to /c/42/');
-      assert.equal(element.changeViewState.selectedFileIndex, 0);
-      assert.isTrue(element._loading);
-
-      const showPrefsStub =
-          sandbox.stub(element.$.diffPreferencesDialog, 'open',
-              () => Promise.resolve());
-
-      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-      assert(showPrefsStub.calledOnce);
-
-      element.disableDiffPrefs = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-      assert(showPrefsStub.calledOnce);
-
-      let scrollStub = sandbox.stub(element.$.cursor, 'moveToNextChunk');
-      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-      assert(scrollStub.calledOnce);
-
-      scrollStub = sandbox.stub(element.$.cursor, 'moveToPreviousChunk');
-      MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-      assert(scrollStub.calledOnce);
-
-      scrollStub = sandbox.stub(element.$.cursor, 'moveToNextCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
-      assert(scrollStub.calledOnce);
-
-      scrollStub = sandbox.stub(element.$.cursor,
-          'moveToPreviousCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
-      assert(scrollStub.calledOnce);
-
-      const computeContainerClassStub = sandbox.stub(element.$.diffHost.$.diff,
-          '_computeContainerClass');
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert(computeContainerClassStub.lastCall.calledWithExactly(
-          false, 'SIDE_BY_SIDE', true));
-
-      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
-      assert(computeContainerClassStub.lastCall.calledWithExactly(
-          false, 'SIDE_BY_SIDE', false));
-
-      sandbox.stub(element, '_setReviewed');
-      element.$.reviewed.checked = false;
-      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-      assert.isFalse(element._setReviewed.called);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-      assert.isTrue(element._setReviewed.called);
-      assert.equal(element._setReviewed.lastCall.args[0], true);
-    });
-
-    test('shift+x shortcut expands all diff context', () => {
-      const expandStub = sandbox.stub(element.$.diffHost, 'expandAllContext');
-      MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
-      flushAsynchronousOperations();
-      assert.isTrue(expandStub.called);
-    });
-
-    test('keyboard shortcuts with patch range', () => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: '5',
-        patchNum: '10',
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 10, commit: {parents: []}},
-          b: {_number: 5, commit: {parents: []}},
-        },
-      };
-      element._files = getFilesFromFileList(
-          ['chell.go', 'glados.txt', 'wheatley.md']);
-      element._path = 'glados.txt';
-
-      const diffNavStub = sandbox.stub(GerritNav, 'navigateToDiff');
-      const changeNavStub = sandbox.stub(GerritNav, 'navigateToChange');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
-          'should only work when the user is logged in.');
-      assert.isNull(window.sessionStorage.getItem(
-          'changeView.showReplyDialog'));
-
-      element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(element.changeViewState.showReplyDialog);
-
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
-          '5'), 'Should navigate to /c/42/5..10');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
-          '5'), 'Should navigate to /c/42/5..10');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-      assert.isTrue(element._loading);
-      assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'wheatley.md', '10', '5'),
-      'Should navigate to /c/42/5..10/wheatley.md');
-      element._path = 'wheatley.md';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert.isTrue(element._loading);
-      assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'glados.txt', '10', '5'),
-      'Should navigate to /c/42/5..10/glados.txt');
-      element._path = 'glados.txt';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert.isTrue(element._loading);
-      assert(diffNavStub.lastCall.calledWithExactly(
-          element._change,
-          'chell.go',
-          '10',
-          '5'),
-      'Should navigate to /c/42/5..10/chell.go');
-      element._path = 'chell.go';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert.isTrue(element._loading);
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
-          '5'),
-      'Should navigate to /c/42/5..10');
-    });
-
-    test('keyboard shortcuts with old patch number', () => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: '1',
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 1, commit: {parents: []}},
-          b: {_number: 2, commit: {parents: []}},
-        },
-      };
-      element._files = getFilesFromFileList(
-          ['chell.go', 'glados.txt', 'wheatley.md']);
-      element._path = 'glados.txt';
-
-      const diffNavStub = sandbox.stub(GerritNav, 'navigateToDiff');
-      const changeNavStub = sandbox.stub(GerritNav, 'navigateToChange');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
-          'should only work when the user is logged in.');
-      assert.isNull(window.sessionStorage.getItem(
-          'changeView.showReplyDialog'));
-
-      element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(element.changeViewState.showReplyDialog);
-
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
-          PARENT), 'Should navigate to /c/42/1');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
-          PARENT), 'Should navigate to /c/42/1');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-      assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'wheatley.md', '1', PARENT),
-      'Should navigate to /c/42/1/wheatley.md');
-      element._path = 'wheatley.md';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'glados.txt', '1', PARENT),
-      'Should navigate to /c/42/1/glados.txt');
-      element._path = 'glados.txt';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWithExactly(
-          element._change,
-          'chell.go',
-          '1',
-          PARENT), 'Should navigate to /c/42/1/chell.go');
-      element._path = 'chell.go';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
-          PARENT), 'Should navigate to /c/42/1');
-    });
-
-    test('edit should redirect to edit page', done => {
-      element._loggedIn = true;
-      element._path = 't.txt';
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: '1',
-      };
-      element._change = {
-        _number: 42,
-        status: 'NEW',
-        revisions: {
-          a: {_number: 1, commit: {parents: []}},
-          b: {_number: 2, commit: {parents: []}},
-        },
-      };
-      const redirectStub = sandbox.stub(GerritNav, 'navigateToRelativeUrl');
-      flush(() => {
-        const editBtn = element.shadowRoot
-            .querySelector('.editButton gr-button');
-        assert.isTrue(!!editBtn);
-        MockInteractions.tap(editBtn);
-        assert.isTrue(redirectStub.called);
-        done();
-      });
-    });
-
-    function isEditVisibile({loggedIn, changeStatus}) {
-      return new Promise(resolve => {
-        element._loggedIn = loggedIn;
-        element._path = 't.txt';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: '1',
-        };
-        element._change = {
-          _number: 42,
-          status: changeStatus,
-          revisions: {
-            a: {_number: 1, commit: {parents: []}},
-            b: {_number: 2, commit: {parents: []}},
-          },
-        };
-        flush(() => {
-          const editBtn = element.shadowRoot
-              .querySelector('.editButton gr-button');
-          resolve(!!editBtn);
-        });
-      });
-    }
-
-    test('edit visible only when logged and status NEW', async () => {
-      for (const changeStatus in element.ChangeStatus) {
-        if (!element.ChangeStatus.hasOwnProperty(changeStatus)) {
-          continue;
-        }
-        assert.isFalse(await isEditVisibile({loggedIn: false, changeStatus}),
-            `loggedIn: false, changeStatus: ${changeStatus}`);
-
-        if (changeStatus !== element.ChangeStatus.NEW) {
-          assert.isFalse(await isEditVisibile({loggedIn: true, changeStatus}),
-              `loggedIn: true, changeStatus: ${changeStatus}`);
-        } else {
-          assert.isTrue(await isEditVisibile({loggedIn: true, changeStatus}),
-              `loggedIn: true, changeStatus: ${changeStatus}`);
-        }
-      }
-    });
-
-    test('edit visible when logged and status NEW', async () => {
-      assert.isTrue(await isEditVisibile(
-          {loggedIn: true, changeStatus: element.ChangeStatus.NEW}));
-    });
-
-    test('edit hidden when logged and status ABANDONED', async () => {
-      assert.isFalse(await isEditVisibile(
-          {loggedIn: true, changeStatus: element.ChangeStatus.ABANDONED}));
-    });
-
-    test('edit hidden when logged and status MERGED', async () => {
-      assert.isFalse(await isEditVisibile(
-          {loggedIn: true, changeStatus: element.ChangeStatus.MERGED}));
-    });
-
-    suite('diff prefs hidden', () => {
-      test('when no prefs or logged out', () => {
-        element.disableDiffPrefs = false;
-        element._loggedIn = false;
-        flushAsynchronousOperations();
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-        element._loggedIn = true;
-        flushAsynchronousOperations();
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-        element._loggedIn = false;
-        element._prefs = {font_size: '12'};
-        flushAsynchronousOperations();
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-        element._loggedIn = true;
-        flushAsynchronousOperations();
-        assert.isFalse(element.$.diffPrefsContainer.hidden);
-      });
-
-      test('when disableDiffPrefs is set', () => {
-        element._loggedIn = true;
-        element._prefs = {font_size: '12'};
-        element.disableDiffPrefs = false;
-        flushAsynchronousOperations();
-
-        assert.isFalse(element.$.diffPrefsContainer.hidden);
-        element.disableDiffPrefs = true;
-        flushAsynchronousOperations();
-
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-      });
-    });
-
-    test('prefsButton opens gr-diff-preferences', () => {
-      const handlePrefsTapSpy = sandbox.spy(element, '_handlePrefsTap');
-      const overlayOpenStub = sandbox.stub(element.$.diffPreferencesDialog,
-          'open');
-      const prefsButton =
-          dom(element.root).querySelector('.prefsButton');
-
-      MockInteractions.tap(prefsButton);
-
-      assert.isTrue(handlePrefsTapSpy.called);
-      assert.isTrue(overlayOpenStub.called);
-    });
-
-    test('_computeCommentString', done => {
-      const path = '/test';
-      element.$.commentAPI.loadAll().then(comments => {
-        const commentCountStub =
-            sandbox.stub(comments, 'computeCommentCount');
-        const unresolvedCountStub =
-            sandbox.stub(comments, 'computeUnresolvedNum');
-        commentCountStub.withArgs({patchNum: 1, path}).returns(0);
-        commentCountStub.withArgs({patchNum: 2, path}).returns(1);
-        commentCountStub.withArgs({patchNum: 3, path}).returns(2);
-        commentCountStub.withArgs({patchNum: 4, path}).returns(0);
-        unresolvedCountStub.withArgs({patchNum: 1, path}).returns(1);
-        unresolvedCountStub.withArgs({patchNum: 2, path}).returns(0);
-        unresolvedCountStub.withArgs({patchNum: 3, path}).returns(2);
-        unresolvedCountStub.withArgs({patchNum: 4, path}).returns(0);
-
-        assert.equal(element._computeCommentString(comments, 1, path, {}),
-            '1 unresolved');
-        assert.equal(
-            element._computeCommentString(comments, 2, path, {status: 'M'}),
-            '1 comment');
-        assert.equal(
-            element._computeCommentString(comments, 2, path, {status: 'U'}),
-            'no changes, 1 comment');
-        assert.equal(
-            element._computeCommentString(comments, 3, path, {status: 'A'}),
-            '2 comments, 2 unresolved');
-        assert.equal(
-            element._computeCommentString(
-                comments, 4, path, {status: 'M'}
-            ), '');
-        assert.equal(
-            element._computeCommentString(comments, 4, path, {status: 'U'}),
-            'no changes');
-        done();
-      });
-    });
-
-    suite('url params', () => {
-      setup(() => {
-        sandbox.stub(
-            GerritNav,
-            'getUrlForDiff',
-            (c, p, pn, bpn) => `${c._number}-${p}-${pn}-${bpn}`);
-        sandbox.stub(
-            GerritNav
-            , 'getUrlForChange',
-            (c, pn, bpn) => `${c._number}-${pn}-${bpn}`);
-      });
-
-      test('_formattedFiles', () => {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: '10',
-        };
-        element._change = {_number: 42};
-        element._files = getFilesFromFileList(
-            ['chell.go', 'glados.txt', 'wheatley.md',
-              '/COMMIT_MSG', '/MERGE_LIST']);
-        element._path = 'glados.txt';
-        const expectedFormattedFiles = [
-          {
-            text: 'chell.go',
-            mobileText: 'chell.go',
-            value: 'chell.go',
-            bottomText: '',
-          }, {
-            text: 'glados.txt',
-            mobileText: 'glados.txt',
-            value: 'glados.txt',
-            bottomText: '',
-          }, {
-            text: 'wheatley.md',
-            mobileText: 'wheatley.md',
-            value: 'wheatley.md',
-            bottomText: '',
-          },
-          {
-            text: 'Commit message',
-            mobileText: 'Commit message',
-            value: '/COMMIT_MSG',
-            bottomText: '',
-          },
-          {
-            text: 'Merge list',
-            mobileText: 'Merge list',
-            value: '/MERGE_LIST',
-            bottomText: '',
-          },
-        ];
-
-        assert.deepEqual(element._formattedFiles, expectedFormattedFiles);
-        assert.equal(element._formattedFiles[1].value, element._path);
-      });
-
-      test('prev/up/next links', () => {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: '10',
-        };
-        element._change = {
-          _number: 42,
-          revisions: {
-            a: {_number: 10, commit: {parents: []}},
-          },
-        };
-        element._files = getFilesFromFileList(
-            ['chell.go', 'glados.txt', 'wheatley.md']);
-        element._path = 'glados.txt';
-        flushAsynchronousOperations();
-        const linkEls = dom(element.root).querySelectorAll('.navLink');
-        assert.equal(linkEls.length, 3);
-        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-PARENT');
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.equal(linkEls[2].getAttribute('href'),
-            '42-wheatley.md-10-PARENT');
-        element._path = 'wheatley.md';
-        flushAsynchronousOperations();
-        assert.equal(linkEls[0].getAttribute('href'),
-            '42-glados.txt-10-PARENT');
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.isFalse(linkEls[2].hasAttribute('href'));
-        element._path = 'chell.go';
-        flushAsynchronousOperations();
-        assert.isFalse(linkEls[0].hasAttribute('href'));
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.equal(linkEls[2].getAttribute('href'),
-            '42-glados.txt-10-PARENT');
-        element._path = 'not_a_real_file';
-        flushAsynchronousOperations();
-        assert.equal(linkEls[0].getAttribute('href'),
-            '42-wheatley.md-10-PARENT');
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.equal(linkEls[2].getAttribute('href'), '42-chell.go-10-PARENT');
-      });
-
-      test('prev/up/next links with patch range', () => {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: '5',
-          patchNum: '10',
-        };
-        element._change = {
-          _number: 42,
-          revisions: {
-            a: {_number: 5, commit: {parents: []}},
-            b: {_number: 10, commit: {parents: []}},
-          },
-        };
-        element._files = getFilesFromFileList(
-            ['chell.go', 'glados.txt', 'wheatley.md']);
-        element._path = 'glados.txt';
-        flushAsynchronousOperations();
-        const linkEls = dom(element.root).querySelectorAll('.navLink');
-        assert.equal(linkEls.length, 3);
-        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-5');
-        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-        assert.equal(linkEls[2].getAttribute('href'), '42-wheatley.md-10-5');
-        element._path = 'wheatley.md';
-        flushAsynchronousOperations();
-        assert.equal(linkEls[0].getAttribute('href'), '42-glados.txt-10-5');
-        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-        assert.isFalse(linkEls[2].hasAttribute('href'));
-        element._path = 'chell.go';
-        flushAsynchronousOperations();
-        assert.isFalse(linkEls[0].hasAttribute('href'));
-        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-        assert.equal(linkEls[2].getAttribute('href'), '42-glados.txt-10-5');
-      });
-    });
-
-    test('_handlePatchChange calls navigateToDiff correctly', () => {
-      const navigateStub = sandbox.stub(GerritNav, 'navigateToDiff');
-      element._change = {_number: 321, project: 'foo/bar'};
-      element._path = 'path/to/file.txt';
-
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '3',
-      };
-
-      const detail = {
-        basePatchNum: 'PARENT',
-        patchNum: '1',
-      };
-
-      element.$.rangeSelect.dispatchEvent(
-          new CustomEvent('patch-range-change', {detail, bubbles: false}));
-
-      assert(navigateStub.lastCall.calledWithExactly(element._change,
-          element._path, '1', 'PARENT'));
-    });
-
-    test('_prefs.manual_review is respected', () => {
-      const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
-          () => Promise.resolve());
-      const getReviewedStub = sandbox.stub(element, '_getReviewedStatus',
-          () => Promise.resolve());
-
-      sandbox.stub(element.$.diffHost, 'reload');
-      element._loggedIn = true;
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: '2',
-        basePatchNum: '1',
-        path: '/COMMIT_MSG',
-      };
-      element._prefs = {manual_review: true};
-      flushAsynchronousOperations();
-
-      assert.isFalse(saveReviewedStub.called);
-      assert.isTrue(getReviewedStub.called);
-
-      element._prefs = {};
-      flushAsynchronousOperations();
-
-      assert.isTrue(saveReviewedStub.called);
-      assert.isTrue(getReviewedStub.calledOnce);
-    });
-
-    test('file review status', () => {
-      const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
-          () => Promise.resolve());
-      sandbox.stub(element.$.diffHost, 'reload');
-
-      element._loggedIn = true;
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: '2',
-        basePatchNum: '1',
-        path: '/COMMIT_MSG',
-      };
-      element._prefs = {};
-      flushAsynchronousOperations();
-
-      const commitMsg = dom(element.root).querySelector(
-          'input[type="checkbox"]');
-
-      assert.isTrue(commitMsg.checked);
-      MockInteractions.tap(commitMsg);
-      assert.isFalse(commitMsg.checked);
-      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(false));
-
-      MockInteractions.tap(commitMsg);
-      assert.isTrue(commitMsg.checked);
-      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(true));
-      const callCount = saveReviewedStub.callCount;
-
-      element.set('params.view', GerritNav.View.CHANGE);
-      flushAsynchronousOperations();
-
-      // saveReviewedState observer observes params, but should not fire when
-      // view !== GerritNav.View.DIFF.
-      assert.equal(saveReviewedStub.callCount, callCount);
-    });
-
-    test('file review status with edit loaded', () => {
-      const saveReviewedStub = sandbox.stub(element, '_saveReviewedState');
-
-      element._patchRange = {patchNum: element.EDIT_NAME};
-      flushAsynchronousOperations();
-
-      assert.isTrue(element._editMode);
-      element._setReviewed();
-      assert.isFalse(saveReviewedStub.called);
-    });
-
-    test('hash is determined from params', done => {
-      sandbox.stub(element.$.diffHost, 'reload');
-      sandbox.stub(element, '_initCursor');
-
-      element._loggedIn = true;
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: '2',
-        basePatchNum: '1',
-        path: '/COMMIT_MSG',
-        hash: 10,
-      };
-
-      flush(() => {
-        assert.isTrue(element._initCursor.calledOnce);
-        done();
-      });
-    });
-
-    test('diff mode selector correctly toggles the diff', () => {
-      const select = element.$.modeSelect;
-      const diffDisplay = element.$.diffHost;
-      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
-
-      // The mode selected in the view state reflects the selected option.
-      assert.equal(element._getDiffViewMode(), select.mode);
-
-      // The mode selected in the view state reflects the view rednered in the
-      // diff.
-      assert.equal(select.mode, diffDisplay.viewMode);
-
-      // We will simulate a user change of the selected mode.
-      const newMode = 'UNIFIED_DIFF';
-
-      // Set the mode, and simulate the change event.
-      element.set('changeViewState.diffMode', newMode);
-
-      // Make sure the handler was called and the state is still coherent.
-      assert.equal(element._getDiffViewMode(), newMode);
-      assert.equal(element._getDiffViewMode(), select.mode);
-      assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
-    });
-
-    test('diff mode selector initializes from preferences', () => {
-      let resolvePrefs;
-      const prefsPromise = new Promise(resolve => {
-        resolvePrefs = resolve;
-      });
-      sandbox.stub(element.$.restAPI, 'getPreferences', () => prefsPromise);
-
-      // Attach a new gr-diff-view so we can intercept the preferences fetch.
-      const view = document.createElement('gr-diff-view');
-      fixture('blank').appendChild(view);
-      flushAsynchronousOperations();
-
-      // At this point the diff mode doesn't yet have the user's preference.
-      assert.equal(view._getDiffViewMode(), 'SIDE_BY_SIDE');
-
-      // Receive the overriding preference.
-      resolvePrefs({default_diff_view: 'UNIFIED'});
-      flushAsynchronousOperations();
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-    });
-
-    suite('_commitRange', () => {
-      setup(() => {
-        sandbox.stub(element.$.diffHost, 'reload');
-        sandbox.stub(element, '_initCursor');
-        sandbox.stub(element, '_getChangeDetail').returns(Promise.resolve({
-          _number: 42,
-          revisions: {
-            'commit-sha-1': {
-              _number: 1,
-              commit: {
-                parents: [{commit: 'sha-1-parent'}],
-              },
-            },
-            'commit-sha-2': {_number: 2},
-            'commit-sha-3': {_number: 3},
-            'commit-sha-4': {_number: 4},
-            'commit-sha-5': {
-              _number: 5,
-              commit: {
-                parents: [{commit: 'sha-5-parent'}],
-              },
-            },
-          },
-        }));
-      });
-
-      test('uses the patchNum and basePatchNum ', done => {
-        element.params = {
-          view: GerritNav.View.DIFF,
-          changeNum: '42',
-          patchNum: '4',
-          basePatchNum: '2',
-          path: '/COMMIT_MSG',
-        };
-        flush(() => {
-          assert.deepEqual(element._commitRange, {
-            baseCommit: 'commit-sha-2',
-            commit: 'commit-sha-4',
-          });
-          done();
-        });
-      });
-
-      test('uses the parent when there is no base patch num ', done => {
-        element.params = {
-          view: GerritNav.View.DIFF,
-          changeNum: '42',
-          patchNum: '5',
-          path: '/COMMIT_MSG',
-        };
-        flush(() => {
-          assert.deepEqual(element._commitRange, {
-            commit: 'commit-sha-5',
-            baseCommit: 'sha-5-parent',
-          });
-          done();
-        });
-      });
-    });
-
-    test('_initCursor', () => {
-      assert.isNotOk(element.$.cursor.initialLineNumber);
-
-      // Does nothing when params specify no cursor address:
-      element._initCursor({});
-      assert.isNotOk(element.$.cursor.initialLineNumber);
-
-      // Does nothing when params specify side but no number:
-      element._initCursor({leftSide: true});
-      assert.isNotOk(element.$.cursor.initialLineNumber);
-
-      // Revision hash: specifies lineNum but not side.
-      element._initCursor({lineNum: 234});
-      assert.equal(element.$.cursor.initialLineNumber, 234);
-      assert.equal(element.$.cursor.side, 'right');
-
-      // Base hash: specifies lineNum and side.
-      element._initCursor({leftSide: true, lineNum: 345});
-      assert.equal(element.$.cursor.initialLineNumber, 345);
-      assert.equal(element.$.cursor.side, 'left');
-
-      // Specifies right side:
-      element._initCursor({leftSide: false, lineNum: 123});
-      assert.equal(element.$.cursor.initialLineNumber, 123);
-      assert.equal(element.$.cursor.side, 'right');
-    });
-
-    test('_getLineOfInterest', () => {
-      assert.isNull(element._getLineOfInterest({}));
-
-      let result = element._getLineOfInterest({lineNum: 12});
-      assert.equal(result.number, 12);
-      assert.isNotOk(result.leftSide);
-
-      result = element._getLineOfInterest({lineNum: 12, leftSide: true});
-      assert.equal(result.number, 12);
-      assert.isOk(result.leftSide);
-    });
-
-    test('_onLineSelected', () => {
-      const getUrlStub = sandbox.stub(GerritNav, 'getUrlForDiffById');
-      const replaceStateStub = sandbox.stub(history, 'replaceState');
-      sandbox.stub(element.$.cursor, 'getAddress')
-          .returns({number: 123, isLeftSide: false});
-
-      element._changeNum = 321;
-      element._change = {_number: 321, project: 'foo/bar'};
-      element._patchRange = {
-        basePatchNum: '3',
-        patchNum: '5',
-      };
-      const e = {};
-      const detail = {number: 123, side: 'right'};
-
-      element._onLineSelected(e, detail);
-
-      assert.isTrue(replaceStateStub.called);
-      assert.isTrue(getUrlStub.called);
-    });
-
-    test('_onLineSelected w/o line address', () => {
-      const getUrlStub = sandbox.stub(GerritNav, 'getUrlForDiffById');
-      sandbox.stub(history, 'replaceState');
-      sandbox.stub(element.$.cursor, 'moveToLineNumber');
-      sandbox.stub(element.$.cursor, 'getAddress').returns(null);
-      element._changeNum = 321;
-      element._change = {_number: 321, project: 'foo/bar'};
-      element._patchRange = {basePatchNum: '3', patchNum: '5'};
-      element._onLineSelected({}, {number: 123, side: 'right'});
-      assert.isTrue(getUrlStub.calledOnce);
-      assert.isUndefined(getUrlStub.lastCall.args[5]);
-      assert.isUndefined(getUrlStub.lastCall.args[6]);
-    });
-
-    test('_getDiffViewMode', () => {
-      // No user prefs or change view state set.
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-
-      // User prefs but no change view state set.
-      element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
-      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
-
-      // User prefs and change view state set.
-      element.changeViewState = {diffMode: 'SIDE_BY_SIDE'};
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-    });
-
-    test('_handleToggleDiffMode', () => {
-      sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      const e = {preventDefault: () => {}};
-      // Initial state.
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-
-      element._handleToggleDiffMode(e);
-      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
-
-      element._handleToggleDiffMode(e);
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-    });
-
-    suite('_loadComments', () => {
-      test('empty', done => {
-        element._loadComments().then(() => {
-          assert.equal(Object.keys(element._commentMap).length, 0);
-          done();
-        });
-      });
-
-      test('has paths', done => {
-        sandbox.stub(element, '_getPaths').returns({
-          'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
-          'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
-        });
-        sandbox.stub(element, '_getCommentsForPath').returns({meta: {}});
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: '3',
-          patchNum: '5',
-        };
-        element._loadComments().then(() => {
-          assert.deepEqual(Object.keys(element._commentMap),
-              ['path/to/file/one.cpp', 'path-to/file/two.py']);
-          done();
-        });
-      });
-    });
-
-    suite('_computeCommentSkips', () => {
-      test('empty file list', () => {
-        const commentMap = {
-          'path/one.jpg': true,
-          'path/three.wav': true,
-        };
-        const path = 'path/two.m4v';
-        const fileList = [];
-        const result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.isNull(result.previous);
-        assert.isNull(result.next);
-      });
-
-      test('finds skips', () => {
-        const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
-        let path = fileList[1];
-        const commentMap = {};
-        commentMap[fileList[0]] = true;
-        commentMap[fileList[1]] = false;
-        commentMap[fileList[2]] = true;
-
-        let result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.equal(result.previous, fileList[0]);
-        assert.equal(result.next, fileList[2]);
-
-        commentMap[fileList[1]] = true;
-
-        result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.equal(result.previous, fileList[0]);
-        assert.equal(result.next, fileList[2]);
-
-        path = fileList[0];
-
-        result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.isNull(result.previous);
-        assert.equal(result.next, fileList[1]);
-
-        path = fileList[2];
-
-        result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.equal(result.previous, fileList[1]);
-        assert.isNull(result.next);
-      });
-
-      suite('skip next/previous', () => {
-        let navToChangeStub;
-        let navToDiffStub;
-
-        setup(() => {
-          navToChangeStub = sandbox.stub(element, '_navToChangeView');
-          navToDiffStub = sandbox.stub(GerritNav, 'navigateToDiff');
-          element._files = getFilesFromFileList([
-            'path/one.jpg', 'path/two.m4v', 'path/three.wav',
-          ]);
-          element._patchRange = {patchNum: '2', basePatchNum: '1'};
-        });
-
-        suite('_moveToPreviousFileWithComment', () => {
-          test('no skips', () => {
-            element._moveToPreviousFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('no previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = false;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = true;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToPreviousFileWithComment();
-            assert.isTrue(navToChangeStub.calledOnce);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('w/ previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = true;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = true;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToPreviousFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isTrue(navToDiffStub.calledOnce);
-          });
-        });
-
-        suite('_moveToNextFileWithComment', () => {
-          test('no skips', () => {
-            element._moveToNextFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('no previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = true;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = false;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToNextFileWithComment();
-            assert.isTrue(navToChangeStub.calledOnce);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('w/ previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = true;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = true;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToNextFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isTrue(navToDiffStub.calledOnce);
-          });
-        });
-      });
-    });
-
-    test('_computeEditMode', () => {
-      const callCompute = range => element._computeEditMode({base: range});
-      assert.isFalse(callCompute({}));
-      assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}));
-      assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}));
-      assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}));
-    });
-
-    test('_computeFileNum', () => {
-      assert.equal(element._computeFileNum('/foo',
-          [{value: '/foo'}, {value: '/bar'}]), 1);
-      assert.equal(element._computeFileNum('/bar',
-          [{value: '/foo'}, {value: '/bar'}]), 2);
-    });
-
-    test('_computeFileNumClass', () => {
-      assert.equal(element._computeFileNumClass(0, []), '');
-      assert.equal(element._computeFileNumClass(1,
-          [{value: '/foo'}, {value: '/bar'}]), 'show');
-    });
-
-    test('_getReviewedStatus', () => {
-      const promises = [];
-      element.$.restAPI.getReviewedFiles.restore();
-
-      sandbox.stub(element.$.restAPI, 'getReviewedFiles')
-          .returns(Promise.resolve(['path']));
-
-      promises.push(element._getReviewedStatus(true, null, null, 'path')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, null, null, 'otherPath')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, null, null, 'path')
-          .then(reviewed => assert.isTrue(reviewed)));
-
-      return Promise.all(promises);
-    });
-
-    suite('blame', () => {
-      test('toggle blame with button', () => {
-        const toggleBlame = sandbox.stub(
-            element.$.diffHost, 'loadBlame', () => Promise.resolve());
-        MockInteractions.tap(element.$.toggleBlame);
-        assert.isTrue(toggleBlame.calledOnce);
-      });
-      test('toggle blame with shortcut', () => {
-        const toggleBlame = sandbox.stub(
-            element.$.diffHost, 'loadBlame', () => Promise.resolve());
-        MockInteractions.pressAndReleaseKeyOn(element, 66, null, 'b');
-        assert.isTrue(toggleBlame.calledOnce);
-      });
-    });
-
-    suite('editMode behavior', () => {
-      setup(() => {
-        element._loggedIn = true;
-      });
-
-      const isVisible = el => {
-        assert.ok(el);
-        return getComputedStyle(el).getPropertyValue('display') !== 'none';
-      };
-
-      test('reviewed checkbox', () => {
-        sandbox.stub(element, '_handlePatchChange');
-        element._patchRange = {patchNum: '1'};
-        // Reviewed checkbox should be shown.
-        assert.isTrue(isVisible(element.$.reviewed));
-        element.set('_patchRange.patchNum', element.EDIT_NAME);
-        flushAsynchronousOperations();
-
-        assert.isFalse(isVisible(element.$.reviewed));
-      });
-    });
-
-    test('_paramsChanged sets in projectLookup', () => {
-      sandbox.stub(element, '_getLineOfInterest');
-      sandbox.stub(element, '_initCursor');
-      const setStub = sandbox.stub(element.$.restAPI, 'setInProjectLookup');
-      element._paramsChanged({
-        view: GerritNav.View.DIFF,
-        changeNum: 101,
-        project: 'test-project',
-        path: '',
-      });
-      assert.isTrue(setStub.calledOnce);
-      assert.isTrue(setStub.calledWith(101, 'test-project'));
-    });
-
-    test('shift+m navigates to next unreviewed file', () => {
-      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      element._reviewedFiles = new Set(['file1', 'file2']);
-      element._path = 'file1';
-      const reviewedStub = sandbox.stub(element, '_setReviewed');
-      const navStub = sandbox.stub(element, '_navToFile');
-      MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
-      flushAsynchronousOperations();
-
-      assert.isTrue(reviewedStub.lastCall.args[0]);
-      assert.deepEqual(navStub.lastCall.args, [
-        'file1',
-        ['file1', 'file3'],
-        1,
-      ]);
-    });
-
-    test('File change should trigger navigateToDiff once', () => {
-      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      sandbox.stub(element, '_getLineOfInterest');
-      sandbox.stub(element, '_initCursor');
-      sandbox.stub(GerritNav, 'navigateToDiff');
-
-      // Load file1
-      element._paramsChanged({
-        view: GerritNav.View.DIFF,
-        patchNum: 1,
-        changeNum: 101,
-        project: 'test-project',
-        path: 'file1',
-      });
-      assert.isTrue(GerritNav.navigateToDiff.notCalled);
-
-      // Switch to file2
-      element.$.dropdown.value = 'file2';
-      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
-
-      // This is to mock the param change triggered by above navigate
-      element._paramsChanged({
-        view: GerritNav.View.DIFF,
-        patchNum: 1,
-        changeNum: 101,
-        project: 'test-project',
-        path: 'file2',
-      });
-
-      // No extra call
-      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
-    });
-
-    test('_computeDownloadDropdownLinks', () => {
-      const downloadLinks = [
-        {
-          url: '/changes/test~12/revisions/1/patch?zip&path=index.php',
-          name: 'Patch',
-        },
-        {
-          url: '/changes/test~12/revisions/1' +
-              '/files/index.php/download?parent=1',
-          name: 'Left Content',
-        },
-        {
-          url: '/changes/test~12/revisions/1' +
-              '/files/index.php/download',
-          name: 'Right Content',
-        },
-      ];
-
-      const side = {
-        meta_a: true,
-        meta_b: true,
-      };
-
-      const base = {
-        patchNum: 1,
-        basePatchNum: 'PARENT',
-      };
-
-      assert.deepEqual(
-          element._computeDownloadDropdownLinks(
-              'test', 12, base, 'index.php', side),
-          downloadLinks);
-    });
-
-    test('_computeDownloadDropdownLinks diff returns renamed', () => {
-      const downloadLinks = [
-        {
-          url: '/changes/test~12/revisions/3/patch?zip&path=index.php',
-          name: 'Patch',
-        },
-        {
-          url: '/changes/test~12/revisions/2' +
-              '/files/index2.php/download',
-          name: 'Left Content',
-        },
-        {
-          url: '/changes/test~12/revisions/3' +
-              '/files/index.php/download',
-          name: 'Right Content',
-        },
-      ];
-
-      const side = {
-        change_type: 'RENAMED',
-        meta_a: {
-          name: 'index2.php',
-        },
-        meta_b: true,
-      };
-
-      const base = {
-        patchNum: 3,
-        basePatchNum: 2,
-      };
-
-      assert.deepEqual(
-          element._computeDownloadDropdownLinks(
-              'test', 12, base, 'index.php', side),
-          downloadLinks);
-    });
-
-    test('_computeDownloadFileLink', () => {
-      const base = {
-        patchNum: 1,
-        basePatchNum: 'PARENT',
-      };
-
-      assert.equal(
-          element._computeDownloadFileLink(
-              'test', 12, base, 'index.php', true),
-          '/changes/test~12/revisions/1/files/index.php/download?parent=1');
-
-      assert.equal(
-          element._computeDownloadFileLink(
-              'test', 12, base, 'index.php', false),
-          '/changes/test~12/revisions/1/files/index.php/download');
-    });
-
-    test('_computeDownloadPatchLink', () => {
-      assert.equal(
-          element._computeDownloadPatchLink(
-              'test', 12, {patchNum: 1}, 'index.php'),
-          '/changes/test~12/revisions/1/patch?zip&path=index.php');
-    });
-  });
-
-  suite('gr-diff-view tests unmodified files with comments', () => {
-    let sandbox;
-    let element;
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      const changedFiles = {
-        'file1.txt': {},
-        'a/b/test.c': {},
-      };
-      stub('gr-rest-api-interface', {
-        getConfig() { return Promise.resolve({change: {}}); },
-        getLoggedIn() { return Promise.resolve(false); },
-        getProjectConfig() { return Promise.resolve({}); },
-        getDiffChangeDetail() { return Promise.resolve({}); },
-        getChangeFiles() { return Promise.resolve(changedFiles); },
-        saveFileReviewed() { return Promise.resolve(); },
-        getDiffComments() { return Promise.resolve({}); },
-        getDiffRobotComments() { return Promise.resolve({}); },
-        getDiffDrafts() { return Promise.resolve({}); },
-        getReviewedFiles() { return Promise.resolve([]); },
-      });
-      element = fixture('basic');
-      return element._loadComments();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('_getFiles add files with comments without changes', () => {
-      const patchChangeRecord = {
-        base: {
-          basePatchNum: '5',
-          patchNum: '10',
-        },
-      };
-      const changeComments = {
-        getPaths: sandbox.stub().returns({
-          'file2.txt': {},
-          'file1.txt': {},
-        }),
-      };
-      return element._getFiles(23, patchChangeRecord, changeComments)
-          .then(() => {
-            assert.deepEqual(element._files, {
-              sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
-              changeFilesByPath: {
-                'file1.txt': {},
-                'file2.txt': {status: 'U'},
-                'a/b/test.c': {},
-              },
-            });
-          });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
new file mode 100644
index 0000000..eba7050
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -0,0 +1,1694 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-diff-view.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {ChangeStatus} from '../../../constants/constants.js';
+import {generateChange, TestKeyboardShortcutBinder} from '../../../test/test-utils';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {_testOnly_findCommentById} from '../gr-comment-api/gr-comment-api.js';
+import {appContext} from '../../../services/app-context.js';
+
+const basicFixture = fixtureFromElement('gr-diff-view');
+
+const blankFixture = fixtureFromElement('div');
+
+suite('gr-diff-view tests', () => {
+  suite('basic tests', () => {
+    let element;
+
+    suiteSetup(() => {
+      const kb = TestKeyboardShortcutBinder.push();
+      kb.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
+      kb.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
+      kb.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
+      kb.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
+      kb.bindShortcut(Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+      kb.bindShortcut(Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+      kb.bindShortcut(Shortcut.NEW_COMMENT, 'c');
+      kb.bindShortcut(Shortcut.SAVE_COMMENT, 'ctrl+s');
+      kb.bindShortcut(Shortcut.NEXT_FILE, ']');
+      kb.bindShortcut(Shortcut.PREV_FILE, '[');
+      kb.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
+      kb.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+      kb.bindShortcut(Shortcut.PREV_CHUNK, 'p');
+      kb.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+      kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
+      kb.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+      kb.bindShortcut(Shortcut.UP_TO_CHANGE, 'u');
+      kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
+      kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
+      kb.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r');
+      kb.bindShortcut(Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+      kb.bindShortcut(Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
+      kb.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
+      kb.bindShortcut(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
+      kb.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+      kb.bindShortcut(Shortcut.TOGGLE_BLAME, 'b');
+    });
+
+    suiteTeardown(() => {
+      TestKeyboardShortcutBinder.pop();
+    });
+
+    const PARENT = 'PARENT';
+
+    function getFilesFromFileList(fileList) {
+      const changeFilesByPath = fileList.reduce((files, path) => {
+        files[path] = {};
+        return files;
+      }, {});
+      return {
+        sortedFileList: fileList,
+        changeFilesByPath,
+      };
+    }
+
+    setup(() => {
+      sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
+      stub('gr-rest-api-interface', {
+        getConfig() {
+          return Promise.resolve({change: {}});
+        },
+        getLoggedIn() {
+          return Promise.resolve(false);
+        },
+        getProjectConfig() {
+          return Promise.resolve({});
+        },
+        getDiffChangeDetail() {
+          return Promise.resolve({});
+        },
+        getChangeFiles() {
+          return Promise.resolve({});
+        },
+        saveFileReviewed() {
+          return Promise.resolve();
+        },
+        getDiffComments() {
+          return Promise.resolve({});
+        },
+        getDiffRobotComments() {
+          return Promise.resolve({});
+        },
+        getDiffDrafts() {
+          return Promise.resolve({});
+        },
+        getReviewedFiles() {
+          return Promise.resolve([]);
+        },
+      });
+      element = basicFixture.instantiate();
+      sinon.stub(element.$.commentAPI, 'loadAll').returns(Promise.resolve({
+        _comments: {'/COMMIT_MSG': [{id: 'c1', line: 10, patch_set: 2,
+          __commentSide: 'left'}]},
+        computeCommentCount: () => {},
+        computeUnresolvedNum: () => {},
+        getPaths: () => {},
+        getCommentsBySideForPath: () => {},
+        findCommentById: _testOnly_findCommentById,
+      }));
+      return element._loadComments();
+    });
+
+    test('params change triggers diffViewDisplayed()', () => {
+      sinon.stub(element.reporting, 'diffViewDisplayed');
+      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+      sinon.stub(element, '_initPatchRange');
+      sinon.stub(element, '_getFiles');
+      sinon.spy(element, '_paramsChanged');
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        patchNum: '2',
+        basePatchNum: '1',
+        path: '/COMMIT_MSG',
+      };
+      return element._paramsChanged.returnValues[0].then(() => {
+        assert.isTrue(element.reporting.diffViewDisplayed.calledOnce);
+      });
+    });
+
+    test('comment route', () => {
+      const initLineOfInterestAndCursorStub =
+        sinon.stub(element, '_initLineOfInterestAndCursor');
+      sinon.stub(element, '_getFiles');
+      sinon.stub(element.reporting, 'diffViewDisplayed');
+      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+      sinon.spy(element, '_paramsChanged');
+      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
+          generateChange({revisionsCount: 11})));
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        commentLink: true,
+        commentId: 'c1',
+      };
+      sinon.stub(element.$.diffHost, '_commentsChanged');
+      sinon.stub(element, '_getCommentsForPath').returns({
+        left: [{id: 'c1', __commentSide: 'left', line: 10}],
+        right: [{id: 'c2', __commentSide: 'right', line: 11}],
+      });
+      element._change = generateChange({revisionsCount: 11});
+      return element._paramsChanged.returnValues[0].then(() => {
+        assert.isTrue(initLineOfInterestAndCursorStub.
+            calledWithExactly(10, true));
+        assert.equal(element._patchRange.patchNum, 11);
+        assert.equal(element._patchRange.basePatchNum, 2);
+      });
+    });
+
+    test('params change causes blame to load if it was set to true', () => {
+      // Blame loads for subsequent files if it was loaded for one file
+      element._isBlameLoaded = true;
+      sinon.stub(element.reporting, 'diffViewDisplayed');
+      sinon.stub(element, '_loadBlame');
+      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+      sinon.spy(element, '_paramsChanged');
+      sinon.stub(element, '_initPatchRange');
+      sinon.stub(element, '_getFiles');
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        patchNum: '2',
+        basePatchNum: '1',
+        path: '/COMMIT_MSG',
+      };
+
+      return element._paramsChanged.returnValues[0].then(() => {
+        assert.isTrue(element._isBlameLoaded);
+        assert.isTrue(element._loadBlame.calledOnce);
+      });
+    });
+
+    test('diff toast to go to base is shown', () => {
+      sinon.stub(element.reporting, 'diffViewDisplayed');
+      sinon.stub(element, '_loadBlame');
+      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+      sinon.spy(element, '_paramsChanged');
+      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
+          generateChange({revisionsCount: 11})));
+      element._isChangeCommentsLinkExperimentEnabled = true;
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        project: 'p',
+        commentId: 'c1',
+        commentLink: true,
+      };
+      element._change = generateChange({revisionsCount: 11});
+      const toastStub =
+        sinon.stub(element, '_displayDiffBaseAgainstLeftToast');
+      return element._paramsChanged.returnValues[0].then(() => {
+        assert.isTrue(toastStub.called);
+      });
+    });
+
+    test('toggle left diff with a hotkey', () => {
+      const toggleLeftDiffStub = sinon.stub(
+          element.$.diffHost, 'toggleLeftDiff');
+      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+      assert.isTrue(toggleLeftDiffStub.calledOnce);
+    });
+
+    test('keyboard shortcuts', () => {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: '10',
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 10, commit: {parents: []}},
+        },
+      };
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
+      element._path = 'glados.txt';
+      element.changeViewState.selectedFileIndex = 1;
+      element._loggedIn = true;
+
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert(changeNavStub.lastCall.calledWith(element._change),
+          'Should navigate to /c/42/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
+      assert(diffNavStub.lastCall.calledWith(element._change, 'wheatley.md',
+          '10', PARENT), 'Should navigate to /c/42/10/wheatley.md');
+      element._path = 'wheatley.md';
+      assert.equal(element.changeViewState.selectedFileIndex, 2);
+      assert.isTrue(element._loading);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(diffNavStub.lastCall.calledWith(element._change, 'glados.txt',
+          '10', PARENT), 'Should navigate to /c/42/10/glados.txt');
+      element._path = 'glados.txt';
+      assert.equal(element.changeViewState.selectedFileIndex, 1);
+      assert.isTrue(element._loading);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(diffNavStub.lastCall.calledWith(element._change, 'chell.go', '10',
+          PARENT), 'Should navigate to /c/42/10/chell.go');
+      element._path = 'chell.go';
+      assert.equal(element.changeViewState.selectedFileIndex, 0);
+      assert.isTrue(element._loading);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(changeNavStub.lastCall.calledWith(element._change),
+          'Should navigate to /c/42/');
+      assert.equal(element.changeViewState.selectedFileIndex, 0);
+      assert.isTrue(element._loading);
+
+      const showPrefsStub =
+          sinon.stub(element.$.diffPreferencesDialog, 'open').callsFake(
+              () => Promise.resolve());
+
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert(showPrefsStub.calledOnce);
+
+      element.disableDiffPrefs = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
+      assert(showPrefsStub.calledOnce);
+
+      let scrollStub = sinon.stub(element.$.cursor, 'moveToNextChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sinon.stub(element.$.cursor, 'moveToPreviousChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sinon.stub(element.$.cursor, 'moveToNextCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sinon.stub(element.$.cursor,
+          'moveToPreviousCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
+      assert(scrollStub.calledOnce);
+
+      const computeContainerClassStub = sinon.stub(element.$.diffHost.$.diff,
+          '_computeContainerClass');
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert(computeContainerClassStub.lastCall.calledWithExactly(
+          false, 'SIDE_BY_SIDE', true));
+
+      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+      assert(computeContainerClassStub.lastCall.calledWithExactly(
+          false, 'SIDE_BY_SIDE', false));
+
+      sinon.stub(element, '_setReviewed');
+      sinon.spy(element, '_handleToggleFileReviewed');
+      element.$.reviewed.checked = false;
+      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      assert.isFalse(element._setReviewed.called);
+      assert.isTrue(element._handleToggleFileReviewed.calledOnce);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      assert.isTrue(element._handleToggleFileReviewed.calledTwice);
+      assert.isTrue(element._setReviewed.called);
+      assert.equal(element._setReviewed.lastCall.args[0], true);
+    });
+
+    test('shift+x shortcut expands all diff context', () => {
+      const expandStub = sinon.stub(element.$.diffHost, 'expandAllContext');
+      MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
+      flushAsynchronousOperations();
+      assert.isTrue(expandStub.called);
+    });
+
+    test('diff against base', () => {
+      element._patchRange = {
+        basePatchNum: '5',
+        patchNum: '10',
+      };
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      element._handleDiffAgainstBase(new CustomEvent(''));
+      const args = diffNavStub.getCall(0).args;
+      assert.equal(args[2], 10);
+      assert.isNotOk(args[3]);
+    });
+
+    test('diff against latest', () => {
+      element._change = generateChange({revisionsCount: 12});
+      element._patchRange = {
+        basePatchNum: '5',
+        patchNum: '10',
+      };
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      element._handleDiffAgainstLatest(new CustomEvent(''));
+      const args = diffNavStub.getCall(0).args;
+      assert.equal(args[2], 12);
+      assert.equal(args[3], 5);
+    });
+
+    test('_handleDiffBaseAgainstLeft', () => {
+      element._change = generateChange({revisionsCount: 10});
+      element._patchRange = {
+        patchNum: 3,
+        basePatchNum: 1,
+      };
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      element._handleDiffBaseAgainstLeft(new CustomEvent(''));
+      assert(diffNavStub.called);
+      const args = diffNavStub.getCall(0).args;
+      assert.equal(args[2], 1);
+      assert.isNotOk(args[3]);
+    });
+
+    test('_handleDiffRightAgainstLatest', () => {
+      element._change = generateChange({revisionsCount: 10});
+      element._patchRange = {
+        basePatchNum: 1,
+        patchNum: 3,
+      };
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      element._handleDiffRightAgainstLatest(new CustomEvent(''));
+      assert(diffNavStub.called);
+      const args = diffNavStub.getCall(0).args;
+      assert.equal(args[2], 10);
+      assert.equal(args[3], 3);
+    });
+
+    test('_handleDiffBaseAgainstLatest', () => {
+      element._change = generateChange({revisionsCount: 10});
+      element._patchRange = {
+        basePatchNum: 1,
+        patchNum: 3,
+      };
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      element._handleDiffBaseAgainstLatest(new CustomEvent(''));
+      assert(diffNavStub.called);
+      const args = diffNavStub.getCall(0).args;
+      assert.equal(args[2], 10);
+      assert.isNotOk(args[3]);
+    });
+
+    test('keyboard shortcuts with patch range', () => {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '5',
+        patchNum: '10',
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 10, commit: {parents: []}},
+          b: {_number: 5, commit: {parents: []}},
+        },
+      };
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
+      element._path = 'glados.txt';
+
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
+          'should only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
+          '5'), 'Should navigate to /c/42/5..10');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
+          '5'), 'Should navigate to /c/42/5..10');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
+      assert.isTrue(element._loading);
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'wheatley.md', '10', '5'),
+      'Should navigate to /c/42/5..10/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert.isTrue(element._loading);
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'glados.txt', '10', '5'),
+      'Should navigate to /c/42/5..10/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert.isTrue(element._loading);
+      assert(diffNavStub.lastCall.calledWithExactly(
+          element._change,
+          'chell.go',
+          '10',
+          '5'),
+      'Should navigate to /c/42/5..10/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert.isTrue(element._loading);
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
+          '5'),
+      'Should navigate to /c/42/5..10');
+    });
+
+    test('keyboard shortcuts with old patch number', () => {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: '1',
+      };
+      element._change = {
+        _number: 42,
+        revisions: {
+          a: {_number: 1, commit: {parents: []}},
+          b: {_number: 2, commit: {parents: []}},
+        },
+      };
+      element._files = getFilesFromFileList(
+          ['chell.go', 'glados.txt', 'wheatley.md']);
+      element._path = 'glados.txt';
+
+      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
+          'should only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+          PARENT), 'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+          PARENT), 'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'wheatley.md', '1', PARENT),
+      'Should navigate to /c/42/1/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'glados.txt', '1', PARENT),
+      'Should navigate to /c/42/1/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(diffNavStub.lastCall.calledWithExactly(
+          element._change,
+          'chell.go',
+          '1',
+          PARENT), 'Should navigate to /c/42/1/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+          PARENT), 'Should navigate to /c/42/1');
+    });
+
+    test('edit should redirect to edit page', done => {
+      element._loggedIn = true;
+      element._path = 't.txt';
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: '1',
+      };
+      element._change = {
+        _number: 42,
+        project: 'gerrit',
+        status: ChangeStatus.NEW,
+        revisions: {
+          a: {_number: 1, commit: {parents: []}},
+          b: {_number: 2, commit: {parents: []}},
+        },
+      };
+      const redirectStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      flush(() => {
+        const editBtn = element.shadowRoot
+            .querySelector('.editButton gr-button');
+        assert.isTrue(!!editBtn);
+        MockInteractions.tap(editBtn);
+        assert.isTrue(redirectStub.called);
+        assert.isTrue(redirectStub.lastCall.calledWithExactly(
+            GerritNav.getEditUrlForDiff(
+                element._change,
+                element._path,
+                element._patchRange.patchNum
+            )));
+        done();
+      });
+    });
+
+    test('edit should redirect to edit page with line number', done => {
+      const lineNumber = 42;
+      element._loggedIn = true;
+      element._path = 't.txt';
+      element._patchRange = {
+        basePatchNum: PARENT,
+        patchNum: '1',
+      };
+      element._change = {
+        _number: 42,
+        project: 'gerrit',
+        status: ChangeStatus.NEW,
+        revisions: {
+          a: {_number: 1, commit: {parents: []}},
+          b: {_number: 2, commit: {parents: []}},
+        },
+      };
+      sinon.stub(element.$.cursor, 'getAddress')
+          .returns({number: lineNumber, isLeftSide: false});
+      const redirectStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      flush(() => {
+        const editBtn = element.shadowRoot
+            .querySelector('.editButton gr-button');
+        assert.isTrue(!!editBtn);
+        MockInteractions.tap(editBtn);
+        assert.isTrue(redirectStub.called);
+        assert.isTrue(redirectStub.lastCall.calledWithExactly(
+            GerritNav.getEditUrlForDiff(
+                element._change,
+                element._path,
+                element._patchRange.patchNum,
+                lineNumber
+            )));
+        done();
+      });
+    });
+
+    function isEditVisibile({loggedIn, changeStatus}) {
+      return new Promise(resolve => {
+        element._loggedIn = loggedIn;
+        element._path = 't.txt';
+        element._patchRange = {
+          basePatchNum: PARENT,
+          patchNum: '1',
+        };
+        element._change = {
+          _number: 42,
+          status: changeStatus,
+          revisions: {
+            a: {_number: 1, commit: {parents: []}},
+            b: {_number: 2, commit: {parents: []}},
+          },
+        };
+        flush(() => {
+          const editBtn = element.shadowRoot
+              .querySelector('.editButton gr-button');
+          resolve(!!editBtn);
+        });
+      });
+    }
+
+    test('edit visible only when logged and status NEW', async () => {
+      for (const changeStatus in ChangeStatus) {
+        if (!ChangeStatus.hasOwnProperty(changeStatus)) {
+          continue;
+        }
+        assert.isFalse(await isEditVisibile({loggedIn: false, changeStatus}),
+            `loggedIn: false, changeStatus: ${changeStatus}`);
+
+        if (changeStatus !== ChangeStatus.NEW) {
+          assert.isFalse(await isEditVisibile({loggedIn: true, changeStatus}),
+              `loggedIn: true, changeStatus: ${changeStatus}`);
+        } else {
+          assert.isTrue(await isEditVisibile({loggedIn: true, changeStatus}),
+              `loggedIn: true, changeStatus: ${changeStatus}`);
+        }
+      }
+    });
+
+    test('edit visible when logged and status NEW', async () => {
+      assert.isTrue(await isEditVisibile(
+          {loggedIn: true, changeStatus: ChangeStatus.NEW}));
+    });
+
+    test('edit hidden when logged and status ABANDONED', async () => {
+      assert.isFalse(await isEditVisibile(
+          {loggedIn: true, changeStatus: ChangeStatus.ABANDONED}));
+    });
+
+    test('edit hidden when logged and status MERGED', async () => {
+      assert.isFalse(await isEditVisibile(
+          {loggedIn: true, changeStatus: ChangeStatus.MERGED}));
+    });
+
+    suite('diff prefs hidden', () => {
+      test('when no prefs or logged out', () => {
+        element.disableDiffPrefs = false;
+        element._loggedIn = false;
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+        element._loggedIn = true;
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+        element._loggedIn = false;
+        element._prefs = {font_size: '12'};
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+        element._loggedIn = true;
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.diffPrefsContainer.hidden);
+      });
+
+      test('when disableDiffPrefs is set', () => {
+        element._loggedIn = true;
+        element._prefs = {font_size: '12'};
+        element.disableDiffPrefs = false;
+        flushAsynchronousOperations();
+
+        assert.isFalse(element.$.diffPrefsContainer.hidden);
+        element.disableDiffPrefs = true;
+        flushAsynchronousOperations();
+
+        assert.isTrue(element.$.diffPrefsContainer.hidden);
+      });
+    });
+
+    test('prefsButton opens gr-diff-preferences', () => {
+      const handlePrefsTapSpy = sinon.spy(element, '_handlePrefsTap');
+      const overlayOpenStub = sinon.stub(element.$.diffPreferencesDialog,
+          'open');
+      const prefsButton =
+          dom(element.root).querySelector('.prefsButton');
+
+      MockInteractions.tap(prefsButton);
+
+      assert.isTrue(handlePrefsTapSpy.called);
+      assert.isTrue(overlayOpenStub.called);
+    });
+
+    test('_computeCommentString', done => {
+      const path = '/test';
+      element.$.commentAPI.loadAll().then(comments => {
+        const commentCountStub =
+            sinon.stub(comments, 'computeCommentCount');
+        const unresolvedCountStub =
+            sinon.stub(comments, 'computeUnresolvedNum');
+        commentCountStub.withArgs({patchNum: 1, path}).returns(0);
+        commentCountStub.withArgs({patchNum: 2, path}).returns(1);
+        commentCountStub.withArgs({patchNum: 3, path}).returns(2);
+        commentCountStub.withArgs({patchNum: 4, path}).returns(0);
+        unresolvedCountStub.withArgs({patchNum: 1, path}).returns(1);
+        unresolvedCountStub.withArgs({patchNum: 2, path}).returns(0);
+        unresolvedCountStub.withArgs({patchNum: 3, path}).returns(2);
+        unresolvedCountStub.withArgs({patchNum: 4, path}).returns(0);
+
+        assert.equal(element._computeCommentString(comments, 1, path, {}),
+            '1 unresolved');
+        assert.equal(
+            element._computeCommentString(comments, 2, path, {status: 'M'}),
+            '1 comment');
+        assert.equal(
+            element._computeCommentString(comments, 2, path, {status: 'U'}),
+            'no changes, 1 comment');
+        assert.equal(
+            element._computeCommentString(comments, 3, path, {status: 'A'}),
+            '2 comments, 2 unresolved');
+        assert.equal(
+            element._computeCommentString(
+                comments, 4, path, {status: 'M'}
+            ), '');
+        assert.equal(
+            element._computeCommentString(comments, 4, path, {status: 'U'}),
+            'no changes');
+        done();
+      });
+    });
+
+    suite('url params', () => {
+      setup(() => {
+        sinon.stub(element, '_getFiles');
+        sinon.stub(
+            GerritNav,
+            'getUrlForDiff')
+            .callsFake((c, p, pn, bpn) => `${c._number}-${p}-${pn}-${bpn}`);
+        sinon.stub(
+            GerritNav
+            , 'getUrlForChange')
+            .callsFake((c, pn, bpn) => `${c._number}-${pn}-${bpn}`);
+      });
+
+      test('_formattedFiles', () => {
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: PARENT,
+          patchNum: '10',
+        };
+        // computeCommentCount is an empty function hence stubbing function
+        // that depends on it's return value
+        sinon.stub(element, '_computeCommentString').returns('');
+        element._change = {_number: 42};
+        element._files = getFilesFromFileList(
+            ['chell.go', 'glados.txt', 'wheatley.md',
+              '/COMMIT_MSG', '/MERGE_LIST']);
+        element._path = 'glados.txt';
+        const expectedFormattedFiles = [
+          {
+            text: 'chell.go',
+            mobileText: 'chell.go',
+            value: 'chell.go',
+            bottomText: '',
+          }, {
+            text: 'glados.txt',
+            mobileText: 'glados.txt',
+            value: 'glados.txt',
+            bottomText: '',
+          }, {
+            text: 'wheatley.md',
+            mobileText: 'wheatley.md',
+            value: 'wheatley.md',
+            bottomText: '',
+          },
+          {
+            text: 'Commit message',
+            mobileText: 'Commit message',
+            value: '/COMMIT_MSG',
+            bottomText: '',
+          },
+          {
+            text: 'Merge list',
+            mobileText: 'Merge list',
+            value: '/MERGE_LIST',
+            bottomText: '',
+          },
+        ];
+
+        assert.deepEqual(element._formattedFiles, expectedFormattedFiles);
+        assert.equal(element._formattedFiles[1].value, element._path);
+      });
+
+      test('prev/up/next links', () => {
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: PARENT,
+          patchNum: '10',
+        };
+        element._change = {
+          _number: 42,
+          revisions: {
+            a: {_number: 10, commit: {parents: []}},
+          },
+        };
+        element._files = getFilesFromFileList(
+            ['chell.go', 'glados.txt', 'wheatley.md']);
+        element._path = 'glados.txt';
+        flushAsynchronousOperations();
+        const linkEls = dom(element.root).querySelectorAll('.navLink');
+        assert.equal(linkEls.length, 3);
+        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-PARENT');
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.equal(linkEls[2].getAttribute('href'),
+            '42-wheatley.md-10-PARENT');
+        element._path = 'wheatley.md';
+        flushAsynchronousOperations();
+        assert.equal(linkEls[0].getAttribute('href'),
+            '42-glados.txt-10-PARENT');
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.isFalse(linkEls[2].hasAttribute('href'));
+        element._path = 'chell.go';
+        flushAsynchronousOperations();
+        assert.isFalse(linkEls[0].hasAttribute('href'));
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.equal(linkEls[2].getAttribute('href'),
+            '42-glados.txt-10-PARENT');
+        element._path = 'not_a_real_file';
+        flushAsynchronousOperations();
+        assert.equal(linkEls[0].getAttribute('href'),
+            '42-wheatley.md-10-PARENT');
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.equal(linkEls[2].getAttribute('href'), '42-chell.go-10-PARENT');
+      });
+
+      test('prev/up/next links with patch range', () => {
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: '5',
+          patchNum: '10',
+        };
+        element._change = {
+          _number: 42,
+          revisions: {
+            a: {_number: 5, commit: {parents: []}},
+            b: {_number: 10, commit: {parents: []}},
+          },
+        };
+        element._files = getFilesFromFileList(
+            ['chell.go', 'glados.txt', 'wheatley.md']);
+        element._path = 'glados.txt';
+        flushAsynchronousOperations();
+        const linkEls = dom(element.root).querySelectorAll('.navLink');
+        assert.equal(linkEls.length, 3);
+        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-5');
+        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
+        assert.equal(linkEls[2].getAttribute('href'), '42-wheatley.md-10-5');
+        element._path = 'wheatley.md';
+        flushAsynchronousOperations();
+        assert.equal(linkEls[0].getAttribute('href'), '42-glados.txt-10-5');
+        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
+        assert.isFalse(linkEls[2].hasAttribute('href'));
+        element._path = 'chell.go';
+        flushAsynchronousOperations();
+        assert.isFalse(linkEls[0].hasAttribute('href'));
+        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
+        assert.equal(linkEls[2].getAttribute('href'), '42-glados.txt-10-5');
+      });
+    });
+
+    test('_handlePatchChange calls navigateToDiff correctly', () => {
+      const navigateStub = sinon.stub(GerritNav, 'navigateToDiff');
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._path = 'path/to/file.txt';
+
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '3',
+      };
+
+      const detail = {
+        basePatchNum: 'PARENT',
+        patchNum: '1',
+      };
+
+      element.$.rangeSelect.dispatchEvent(
+          new CustomEvent('patch-range-change', {detail, bubbles: false}));
+
+      assert(navigateStub.lastCall.calledWithExactly(element._change,
+          element._path, '1', 'PARENT'));
+    });
+
+    test('_prefs.manual_review is respected', () => {
+      const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
+          .callsFake(() => Promise.resolve());
+      const getReviewedStub = sinon.stub(element, '_getReviewedStatus')
+          .callsFake(() => Promise.resolve());
+
+      sinon.stub(element.$.diffHost, 'reload');
+      element._loggedIn = true;
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        patchNum: '2',
+        basePatchNum: '1',
+        path: '/COMMIT_MSG',
+      };
+      element._patchRange = {
+        patchNum: '2',
+        basePatchNum: '1',
+      };
+      element._prefs = {manual_review: true};
+      flushAsynchronousOperations();
+
+      assert.isFalse(saveReviewedStub.called);
+      assert.isTrue(getReviewedStub.called);
+
+      element._prefs = {};
+      flushAsynchronousOperations();
+
+      assert.isTrue(saveReviewedStub.called);
+      assert.isTrue(getReviewedStub.calledOnce);
+    });
+
+    test('file review status', () => {
+      const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
+          .callsFake(() => Promise.resolve());
+      sinon.stub(element.$.diffHost, 'reload');
+
+      element._loggedIn = true;
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        patchNum: '2',
+        basePatchNum: '1',
+        path: '/COMMIT_MSG',
+      };
+      element._patchRange = {
+        patchNum: '2',
+        basePatchNum: '1',
+      };
+      element._prefs = {};
+      flushAsynchronousOperations();
+
+      const commitMsg = dom(element.root).querySelector(
+          'input[type="checkbox"]');
+
+      assert.isTrue(commitMsg.checked);
+      MockInteractions.tap(commitMsg);
+      assert.isFalse(commitMsg.checked);
+      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(false));
+
+      MockInteractions.tap(commitMsg);
+      assert.isTrue(commitMsg.checked);
+      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(true));
+      const callCount = saveReviewedStub.callCount;
+
+      element.set('params.view', GerritNav.View.CHANGE);
+      flushAsynchronousOperations();
+
+      // saveReviewedState observer observes params, but should not fire when
+      // view !== GerritNav.View.DIFF.
+      assert.equal(saveReviewedStub.callCount, callCount);
+    });
+
+    test('file review status with edit loaded', () => {
+      const saveReviewedStub = sinon.stub(element, '_saveReviewedState');
+
+      element._patchRange = {patchNum: SPECIAL_PATCH_SET_NUM.EDIT};
+      flushAsynchronousOperations();
+
+      assert.isTrue(element._editMode);
+      element._setReviewed();
+      assert.isFalse(saveReviewedStub.called);
+    });
+
+    test('hash is determined from params', done => {
+      sinon.stub(element.$.diffHost, 'reload');
+      sinon.stub(element, '_initLineOfInterestAndCursor');
+
+      element._loggedIn = true;
+      element.params = {
+        view: GerritNav.View.DIFF,
+        changeNum: '42',
+        patchNum: '2',
+        basePatchNum: '1',
+        path: '/COMMIT_MSG',
+        hash: 10,
+      };
+
+      flush(() => {
+        assert.isTrue(element._initLineOfInterestAndCursor.calledOnce);
+        done();
+      });
+    });
+
+    test('diff mode selector correctly toggles the diff', () => {
+      const select = element.$.modeSelect;
+      const diffDisplay = element.$.diffHost;
+      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
+
+      // The mode selected in the view state reflects the selected option.
+      assert.equal(element._getDiffViewMode(), select.mode);
+
+      // The mode selected in the view state reflects the view rednered in the
+      // diff.
+      assert.equal(select.mode, diffDisplay.viewMode);
+
+      // We will simulate a user change of the selected mode.
+      const newMode = 'UNIFIED_DIFF';
+
+      // Set the mode, and simulate the change event.
+      element.set('changeViewState.diffMode', newMode);
+
+      // Make sure the handler was called and the state is still coherent.
+      assert.equal(element._getDiffViewMode(), newMode);
+      assert.equal(element._getDiffViewMode(), select.mode);
+      assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
+    });
+
+    test('diff mode selector initializes from preferences', () => {
+      let resolvePrefs;
+      const prefsPromise = new Promise(resolve => {
+        resolvePrefs = resolve;
+      });
+      sinon.stub(element.$.restAPI, 'getPreferences')
+          .callsFake(() => prefsPromise);
+
+      // Attach a new gr-diff-view so we can intercept the preferences fetch.
+      const view = document.createElement('gr-diff-view');
+      blankFixture.instantiate().appendChild(view);
+      flushAsynchronousOperations();
+
+      // At this point the diff mode doesn't yet have the user's preference.
+      assert.equal(view._getDiffViewMode(), 'SIDE_BY_SIDE');
+
+      // Receive the overriding preference.
+      resolvePrefs({default_diff_view: 'UNIFIED'});
+      flushAsynchronousOperations();
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+    });
+
+    test('diff mode selector should be hidden for binary', done => {
+      element._diff = {binary: true, content: []};
+
+      flush(() => {
+        const diffModeSelector = element.shadowRoot
+            .querySelector('.diffModeSelector');
+        assert.isTrue(diffModeSelector.classList.contains('hide'));
+        done();
+      });
+    });
+
+    suite('_commitRange', () => {
+      const change = {
+        _number: 42,
+        revisions: {
+          'commit-sha-1': {
+            _number: 1,
+            commit: {
+              parents: [{commit: 'sha-1-parent'}],
+            },
+          },
+          'commit-sha-2': {_number: 2, commit: {parents: []}},
+          'commit-sha-3': {_number: 3, commit: {parents: []}},
+          'commit-sha-4': {_number: 4, commit: {parents: []}},
+          'commit-sha-5': {
+            _number: 5,
+            commit: {
+              parents: [{commit: 'sha-5-parent'}],
+            },
+          },
+        },
+      };
+      setup(() => {
+        sinon.stub(element.$.diffHost, 'reload');
+        sinon.stub(element, '_initCursor');
+        element._change = change;
+        sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
+            change));
+      });
+
+      test('uses the patchNum and basePatchNum ', done => {
+        element.params = {
+          view: GerritNav.View.DIFF,
+          changeNum: '42',
+          patchNum: '4',
+          basePatchNum: '2',
+          path: '/COMMIT_MSG',
+        };
+        element._change = change;
+        flush(() => {
+          assert.deepEqual(element._commitRange, {
+            baseCommit: 'commit-sha-2',
+            commit: 'commit-sha-4',
+          });
+          done();
+        });
+      });
+
+      test('uses the parent when there is no base patch num ', done => {
+        element.params = {
+          view: GerritNav.View.DIFF,
+          changeNum: '42',
+          patchNum: '5',
+          path: '/COMMIT_MSG',
+        };
+        element._change = change;
+        flush(() => {
+          assert.deepEqual(element._commitRange, {
+            commit: 'commit-sha-5',
+            baseCommit: 'sha-5-parent',
+          });
+          done();
+        });
+      });
+    });
+
+    test('_initCursor', () => {
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Does nothing when params specify no cursor address:
+      element._initCursor({});
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Does nothing when params specify side but no number:
+      element._initCursor({leftSide: true});
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Revision hash: specifies lineNum but not side.
+      element._initCursor({lineNum: 234});
+      assert.equal(element.$.cursor.initialLineNumber, 234);
+      assert.equal(element.$.cursor.side, 'right');
+
+      // Base hash: specifies lineNum and side.
+      element._initCursor({leftSide: true, lineNum: 345});
+      assert.equal(element.$.cursor.initialLineNumber, 345);
+      assert.equal(element.$.cursor.side, 'left');
+
+      // Specifies right side:
+      element._initCursor({leftSide: false, lineNum: 123});
+      assert.equal(element.$.cursor.initialLineNumber, 123);
+      assert.equal(element.$.cursor.side, 'right');
+    });
+
+    test('_getLineOfInterest', () => {
+      assert.isNull(element._getLineOfInterest({}));
+
+      let result = element._getLineOfInterest({lineNum: 12});
+      assert.equal(result.number, 12);
+      assert.isNotOk(result.leftSide);
+
+      result = element._getLineOfInterest({lineNum: 12, leftSide: true});
+      assert.equal(result.number, 12);
+      assert.isOk(result.leftSide);
+    });
+
+    test('_onLineSelected', () => {
+      const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
+      const replaceStateStub = sinon.stub(history, 'replaceState');
+      sinon.stub(element.$.cursor, 'getAddress')
+          .returns({number: 123, isLeftSide: false});
+
+      element._changeNum = 321;
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._patchRange = {
+        basePatchNum: '3',
+        patchNum: '5',
+      };
+      const e = {};
+      const detail = {number: 123, side: 'right'};
+
+      element._onLineSelected(e, detail);
+
+      assert.isTrue(replaceStateStub.called);
+      assert.isTrue(getUrlStub.called);
+    });
+
+    test('_onLineSelected w/o line address', () => {
+      const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
+      sinon.stub(history, 'replaceState');
+      sinon.stub(element.$.cursor, 'moveToLineNumber');
+      sinon.stub(element.$.cursor, 'getAddress').returns(null);
+      element._changeNum = 321;
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._patchRange = {basePatchNum: '3', patchNum: '5'};
+      element._onLineSelected({}, {number: 123, side: 'right'});
+      assert.isTrue(getUrlStub.calledOnce);
+      assert.isUndefined(getUrlStub.lastCall.args[5]);
+      assert.isUndefined(getUrlStub.lastCall.args[6]);
+    });
+
+    test('_getDiffViewMode', () => {
+      // No user prefs or change view state set.
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+
+      // User prefs but no change view state set.
+      element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
+      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
+
+      // User prefs and change view state set.
+      element.changeViewState = {diffMode: 'SIDE_BY_SIDE'};
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+    });
+
+    test('_handleToggleDiffMode', () => {
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      const e = {preventDefault: () => {}};
+      // Initial state.
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+
+      element._handleToggleDiffMode(e);
+      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
+
+      element._handleToggleDiffMode(e);
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+    });
+
+    suite('_initPatchRange', () => {
+      test('empty', () => {
+        sinon.stub(element, '_getCommentsForPath');
+        sinon.stub(element, '_getPaths').returns(new Map());
+        element.params = {};
+        element._initPatchRange();
+        assert.equal(Object.keys(element._commentMap).length, 0);
+      });
+
+      test('has paths', () => {
+        sinon.stub(element, '_getFiles');
+        sinon.stub(element, '_getPaths').returns({
+          'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
+          'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
+        });
+        sinon.stub(element, '_getCommentsForPath').returns({meta: {}});
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: '3',
+          patchNum: '5',
+        };
+        element.params = {};
+        element._initPatchRange();
+        assert.deepEqual(Object.keys(element._commentMap),
+            ['path/to/file/one.cpp', 'path-to/file/two.py']);
+      });
+    });
+
+    suite('_computeCommentSkips', () => {
+      test('empty file list', () => {
+        const commentMap = {
+          'path/one.jpg': true,
+          'path/three.wav': true,
+        };
+        const path = 'path/two.m4v';
+        const fileList = [];
+        const result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.isNull(result.previous);
+        assert.isNull(result.next);
+      });
+
+      test('finds skips', () => {
+        const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
+        let path = fileList[1];
+        const commentMap = {};
+        commentMap[fileList[0]] = true;
+        commentMap[fileList[1]] = false;
+        commentMap[fileList[2]] = true;
+
+        let result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.equal(result.previous, fileList[0]);
+        assert.equal(result.next, fileList[2]);
+
+        commentMap[fileList[1]] = true;
+
+        result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.equal(result.previous, fileList[0]);
+        assert.equal(result.next, fileList[2]);
+
+        path = fileList[0];
+
+        result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.isNull(result.previous);
+        assert.equal(result.next, fileList[1]);
+
+        path = fileList[2];
+
+        result = element._computeCommentSkips(commentMap, fileList, path);
+        assert.equal(result.previous, fileList[1]);
+        assert.isNull(result.next);
+      });
+
+      suite('skip next/previous', () => {
+        let navToChangeStub;
+        let navToDiffStub;
+
+        setup(() => {
+          navToChangeStub = sinon.stub(element, '_navToChangeView');
+          navToDiffStub = sinon.stub(GerritNav, 'navigateToDiff');
+          element._files = getFilesFromFileList([
+            'path/one.jpg', 'path/two.m4v', 'path/three.wav',
+          ]);
+          element._patchRange = {patchNum: '2', basePatchNum: '1'};
+        });
+
+        suite('_moveToPreviousFileWithComment', () => {
+          test('no skips', () => {
+            element._moveToPreviousFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('no previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = false;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = true;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToPreviousFileWithComment();
+            assert.isTrue(navToChangeStub.calledOnce);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('w/ previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = true;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = true;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToPreviousFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isTrue(navToDiffStub.calledOnce);
+          });
+        });
+
+        suite('_moveToNextFileWithComment', () => {
+          test('no skips', () => {
+            element._moveToNextFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('no previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = true;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = false;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToNextFileWithComment();
+            assert.isTrue(navToChangeStub.calledOnce);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('w/ previous', () => {
+            const commentMap = {};
+            commentMap[element._fileList[0]] = true;
+            commentMap[element._fileList[1]] = false;
+            commentMap[element._fileList[2]] = true;
+            element._commentMap = commentMap;
+            element._path = element._fileList[1];
+
+            element._moveToNextFileWithComment();
+            assert.isFalse(navToChangeStub.called);
+            assert.isTrue(navToDiffStub.calledOnce);
+          });
+        });
+      });
+    });
+
+    test('_computeEditMode', () => {
+      const callCompute = range => element._computeEditMode({base: range});
+      assert.isFalse(callCompute({}));
+      assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}));
+      assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}));
+      assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}));
+    });
+
+    test('_computeFileNum', () => {
+      assert.equal(element._computeFileNum('/foo',
+          [{value: '/foo'}, {value: '/bar'}]), 1);
+      assert.equal(element._computeFileNum('/bar',
+          [{value: '/foo'}, {value: '/bar'}]), 2);
+    });
+
+    test('_computeFileNumClass', () => {
+      assert.equal(element._computeFileNumClass(0, []), '');
+      assert.equal(element._computeFileNumClass(1,
+          [{value: '/foo'}, {value: '/bar'}]), 'show');
+    });
+
+    test('_getReviewedStatus', () => {
+      const promises = [];
+      element.$.restAPI.getReviewedFiles.restore();
+
+      sinon.stub(element.$.restAPI, 'getReviewedFiles')
+          .returns(Promise.resolve(['path']));
+
+      promises.push(element._getReviewedStatus(true, null, null, 'path')
+          .then(reviewed => assert.isFalse(reviewed)));
+
+      promises.push(element._getReviewedStatus(false, null, null, 'otherPath')
+          .then(reviewed => assert.isFalse(reviewed)));
+
+      promises.push(element._getReviewedStatus(false, null, null, 'path')
+          .then(reviewed => assert.isTrue(reviewed)));
+
+      return Promise.all(promises);
+    });
+
+    suite('blame', () => {
+      test('toggle blame with button', () => {
+        const toggleBlame = sinon.stub(
+            element.$.diffHost, 'loadBlame')
+            .callsFake(() => Promise.resolve());
+        MockInteractions.tap(element.$.toggleBlame);
+        assert.isTrue(toggleBlame.calledOnce);
+      });
+      test('toggle blame with shortcut', () => {
+        const toggleBlame = sinon.stub(
+            element.$.diffHost, 'loadBlame').callsFake(() => Promise.resolve());
+        MockInteractions.pressAndReleaseKeyOn(element, 66, null, 'b');
+        assert.isTrue(toggleBlame.calledOnce);
+      });
+    });
+
+    suite('editMode behavior', () => {
+      setup(() => {
+        element._loggedIn = true;
+      });
+
+      const isVisible = el => {
+        assert.ok(el);
+        return getComputedStyle(el).getPropertyValue('display') !== 'none';
+      };
+
+      test('reviewed checkbox', () => {
+        sinon.stub(element, '_handlePatchChange');
+        element._patchRange = {patchNum: '1'};
+        // Reviewed checkbox should be shown.
+        assert.isTrue(isVisible(element.$.reviewed));
+        element.set('_patchRange.patchNum', SPECIAL_PATCH_SET_NUM.EDIT);
+        flushAsynchronousOperations();
+
+        assert.isFalse(isVisible(element.$.reviewed));
+      });
+    });
+
+    test('_paramsChanged sets in projectLookup', () => {
+      sinon.stub(element, '_initLineOfInterestAndCursor');
+      const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
+      element._paramsChanged({
+        view: GerritNav.View.DIFF,
+        changeNum: 101,
+        project: 'test-project',
+        path: '',
+      });
+      assert.isTrue(setStub.calledOnce);
+      assert.isTrue(setStub.calledWith(101, 'test-project'));
+    });
+
+    test('shift+m navigates to next unreviewed file', () => {
+      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
+      element._reviewedFiles = new Set(['file1', 'file2']);
+      element._path = 'file1';
+      const reviewedStub = sinon.stub(element, '_setReviewed');
+      const navStub = sinon.stub(element, '_navToFile');
+      MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
+      flushAsynchronousOperations();
+
+      assert.isTrue(reviewedStub.lastCall.args[0]);
+      assert.deepEqual(navStub.lastCall.args, [
+        'file1',
+        ['file1', 'file3'],
+        1,
+      ]);
+    });
+
+    test('File change should trigger navigateToDiff once', done => {
+      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
+      sinon.stub(element, '_initLineOfInterestAndCursor');
+      sinon.stub(GerritNav, 'navigateToDiff');
+
+      // Load file1
+      element.params = {
+        view: GerritNav.View.DIFF,
+        patchNum: 1,
+        changeNum: 101,
+        project: 'test-project',
+        path: 'file1',
+      };
+      element._patchRange = {
+        patchNum: 1,
+        basePatchNum: 'PARENT',
+      };
+      flushAsynchronousOperations();
+      assert.isTrue(GerritNav.navigateToDiff.notCalled);
+
+      // Switch to file2
+      element._handleFileChange({detail: {value: 'file2'}});
+      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
+
+      // This is to mock the param change triggered by above navigate
+      element.params = {
+        view: GerritNav.View.DIFF,
+        patchNum: 1,
+        changeNum: 101,
+        project: 'test-project',
+        path: 'file2',
+      };
+      element._patchRange = {
+        patchNum: 1,
+        basePatchNum: 'PARENT',
+      };
+
+      // No extra call
+      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
+      done();
+    });
+
+    test('_computeDownloadDropdownLinks', () => {
+      const downloadLinks = [
+        {
+          url: '/changes/test~12/revisions/1/patch?zip&path=index.php',
+          name: 'Patch',
+        },
+        {
+          url: '/changes/test~12/revisions/1' +
+              '/files/index.php/download?parent=1',
+          name: 'Left Content',
+        },
+        {
+          url: '/changes/test~12/revisions/1' +
+              '/files/index.php/download',
+          name: 'Right Content',
+        },
+      ];
+
+      const side = {
+        meta_a: true,
+        meta_b: true,
+      };
+
+      const base = {
+        patchNum: 1,
+        basePatchNum: 'PARENT',
+      };
+
+      assert.deepEqual(
+          element._computeDownloadDropdownLinks(
+              'test', 12, base, 'index.php', side),
+          downloadLinks);
+    });
+
+    test('_computeDownloadDropdownLinks diff returns renamed', () => {
+      const downloadLinks = [
+        {
+          url: '/changes/test~12/revisions/3/patch?zip&path=index.php',
+          name: 'Patch',
+        },
+        {
+          url: '/changes/test~12/revisions/2' +
+              '/files/index2.php/download',
+          name: 'Left Content',
+        },
+        {
+          url: '/changes/test~12/revisions/3' +
+              '/files/index.php/download',
+          name: 'Right Content',
+        },
+      ];
+
+      const side = {
+        change_type: 'RENAMED',
+        meta_a: {
+          name: 'index2.php',
+        },
+        meta_b: true,
+      };
+
+      const base = {
+        patchNum: 3,
+        basePatchNum: 2,
+      };
+
+      assert.deepEqual(
+          element._computeDownloadDropdownLinks(
+              'test', 12, base, 'index.php', side),
+          downloadLinks);
+    });
+
+    test('_computeDownloadFileLink', () => {
+      const base = {
+        patchNum: 1,
+        basePatchNum: 'PARENT',
+      };
+
+      assert.equal(
+          element._computeDownloadFileLink(
+              'test', 12, base, 'index.php', true),
+          '/changes/test~12/revisions/1/files/index.php/download?parent=1');
+
+      assert.equal(
+          element._computeDownloadFileLink(
+              'test', 12, base, 'index.php', false),
+          '/changes/test~12/revisions/1/files/index.php/download');
+    });
+
+    test('_computeDownloadPatchLink', () => {
+      assert.equal(
+          element._computeDownloadPatchLink(
+              'test', 12, {patchNum: 1}, 'index.php'),
+          '/changes/test~12/revisions/1/patch?zip&path=index.php');
+    });
+  });
+
+  suite('gr-diff-view tests unmodified files with comments', () => {
+    let element;
+    setup(() => {
+      const changedFiles = {
+        'file1.txt': {},
+        'a/b/test.c': {},
+      };
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({change: {}}); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getProjectConfig() { return Promise.resolve({}); },
+        getDiffChangeDetail() { return Promise.resolve({}); },
+        getChangeFiles() { return Promise.resolve(changedFiles); },
+        saveFileReviewed() { return Promise.resolve(); },
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+        getReviewedFiles() { return Promise.resolve([]); },
+      });
+      element = basicFixture.instantiate();
+      return element._loadComments();
+    });
+
+    test('_getFiles add files with comments without changes', () => {
+      const patchChangeRecord = {
+        base: {
+          basePatchNum: '5',
+          patchNum: '10',
+        },
+      };
+      const changeComments = {
+        getPaths: sinon.stub().returns({
+          'file2.txt': {},
+          'file1.txt': {},
+        }),
+      };
+      return element._getFiles(23, patchChangeRecord, changeComments)
+          .then(() => {
+            assert.deepEqual(element._files, {
+              sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
+              changeFilesByPath: {
+                'file1.txt': {},
+                'file2.txt': {status: 'U'},
+                'a/b/test.c': {},
+              },
+            });
+          });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
index bfd063a..34cced1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
@@ -53,6 +53,8 @@
   this.adds = [];
   /** @type {!Array<!GrDiffLine>} */
   this.removes = [];
+  /** @type {?Array<Object>} ?Array<!GrDiffGroup> */
+  this.contextGroups = null;
 
   /** Both start and end line are inclusive. */
   this.lineRange = {
@@ -126,10 +128,9 @@
 
   const result = [...before];
   if (hidden.length) {
-    const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
-    ctxLine.contextGroups = hidden;
     const ctxGroup = new GrDiffGroup(
-        GrDiffGroup.Type.CONTEXT_CONTROL, [ctxLine]);
+        GrDiffGroup.Type.CONTEXT_CONTROL, []);
+    ctxGroup.contextGroups = hidden;
     result.push(ctxGroup);
   }
   result.push(...after);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
deleted file mode 100644
index d50a7f4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
+++ /dev/null
@@ -1,209 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-diff-group</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../../test/common-test-setup.js';
-import {GrDiffLine} from './gr-diff-line.js';
-import {GrDiffGroup} from './gr-diff-group.js';
-
-suite('gr-diff-group tests', () => {
-  test('delta line pairs', () => {
-    let group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-    const l1 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 128);
-    const l2 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 129);
-    const l3 = new GrDiffLine(GrDiffLine.Type.REMOVE, 64, 0);
-    group.addLine(l1);
-    group.addLine(l2);
-    group.addLine(l3);
-    assert.deepEqual(group.lines, [l1, l2, l3]);
-    assert.deepEqual(group.adds, [l1, l2]);
-    assert.deepEqual(group.removes, [l3]);
-    assert.deepEqual(group.lineRange, {
-      left: {start: 64, end: 64},
-      right: {start: 128, end: 129},
-    });
-
-    let pairs = group.getSideBySidePairs();
-    assert.deepEqual(pairs, [
-      {left: l3, right: l1},
-      {left: GrDiffLine.BLANK_LINE, right: l2},
-    ]);
-
-    group = new GrDiffGroup(GrDiffGroup.Type.DELTA, [l1, l2, l3]);
-    assert.deepEqual(group.lines, [l1, l2, l3]);
-    assert.deepEqual(group.adds, [l1, l2]);
-    assert.deepEqual(group.removes, [l3]);
-
-    pairs = group.getSideBySidePairs();
-    assert.deepEqual(pairs, [
-      {left: l3, right: l1},
-      {left: GrDiffLine.BLANK_LINE, right: l2},
-    ]);
-  });
-
-  test('group/header line pairs', () => {
-    const l1 = new GrDiffLine(GrDiffLine.Type.BOTH, 64, 128);
-    const l2 = new GrDiffLine(GrDiffLine.Type.BOTH, 65, 129);
-    const l3 = new GrDiffLine(GrDiffLine.Type.BOTH, 66, 130);
-
-    let group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
-
-    assert.deepEqual(group.lines, [l1, l2, l3]);
-    assert.deepEqual(group.adds, []);
-    assert.deepEqual(group.removes, []);
-
-    assert.deepEqual(group.lineRange, {
-      left: {start: 64, end: 66},
-      right: {start: 128, end: 130},
-    });
-
-    let pairs = group.getSideBySidePairs();
-    assert.deepEqual(pairs, [
-      {left: l1, right: l1},
-      {left: l2, right: l2},
-      {left: l3, right: l3},
-    ]);
-
-    group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL, [l1, l2, l3]);
-    assert.deepEqual(group.lines, [l1, l2, l3]);
-    assert.deepEqual(group.adds, []);
-    assert.deepEqual(group.removes, []);
-
-    pairs = group.getSideBySidePairs();
-    assert.deepEqual(pairs, [
-      {left: l1, right: l1},
-      {left: l2, right: l2},
-      {left: l3, right: l3},
-    ]);
-  });
-
-  test('adding delta lines to non-delta group', () => {
-    const l1 = new GrDiffLine(GrDiffLine.Type.ADD);
-    const l2 = new GrDiffLine(GrDiffLine.Type.REMOVE);
-    const l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
-
-    let group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
-    assert.throws(group.addLine.bind(group, l1));
-    assert.throws(group.addLine.bind(group, l2));
-    assert.doesNotThrow(group.addLine.bind(group, l3));
-
-    group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL);
-    assert.throws(group.addLine.bind(group, l1));
-    assert.throws(group.addLine.bind(group, l2));
-    assert.doesNotThrow(group.addLine.bind(group, l3));
-  });
-
-  suite('hideInContextControl', () => {
-    let groups;
-    setup(() => {
-      groups = [
-        new GrDiffGroup(GrDiffGroup.Type.BOTH, [
-          new GrDiffLine(GrDiffLine.Type.BOTH, 5, 7),
-          new GrDiffLine(GrDiffLine.Type.BOTH, 6, 8),
-          new GrDiffLine(GrDiffLine.Type.BOTH, 7, 9),
-        ]),
-        new GrDiffGroup(GrDiffGroup.Type.DELTA, [
-          new GrDiffLine(GrDiffLine.Type.REMOVE, 8),
-          new GrDiffLine(GrDiffLine.Type.ADD, 0, 10),
-          new GrDiffLine(GrDiffLine.Type.REMOVE, 9),
-          new GrDiffLine(GrDiffLine.Type.ADD, 0, 11),
-          new GrDiffLine(GrDiffLine.Type.REMOVE, 10),
-          new GrDiffLine(GrDiffLine.Type.ADD, 0, 12),
-        ]),
-        new GrDiffGroup(GrDiffGroup.Type.BOTH, [
-          new GrDiffLine(GrDiffLine.Type.BOTH, 11, 13),
-          new GrDiffLine(GrDiffLine.Type.BOTH, 12, 14),
-          new GrDiffLine(GrDiffLine.Type.BOTH, 13, 15),
-        ]),
-      ];
-    });
-
-    test('hides hidden groups in context control', () => {
-      const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 3, 6);
-      assert.equal(collapsedGroups.length, 3);
-
-      assert.equal(collapsedGroups[0], groups[0]);
-
-      assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-      assert.equal(collapsedGroups[1].lines.length, 1);
-      assert.equal(
-          collapsedGroups[1].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
-      assert.equal(
-          collapsedGroups[1].lines[0].contextGroups.length, 1);
-      assert.equal(
-          collapsedGroups[1].lines[0].contextGroups[0], groups[1]);
-
-      assert.equal(collapsedGroups[2], groups[2]);
-    });
-
-    test('splits partially hidden groups', () => {
-      const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 4, 7);
-      assert.equal(collapsedGroups.length, 4);
-      assert.equal(collapsedGroups[0], groups[0]);
-
-      assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.DELTA);
-      assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
-      assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
-
-      assert.equal(collapsedGroups[2].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-      assert.equal(collapsedGroups[2].lines.length, 1);
-      assert.equal(
-          collapsedGroups[2].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
-      assert.equal(
-          collapsedGroups[2].lines[0].contextGroups.length, 2);
-
-      assert.equal(
-          collapsedGroups[2].lines[0].contextGroups[0].type,
-          GrDiffGroup.Type.DELTA);
-      assert.deepEqual(
-          collapsedGroups[2].lines[0].contextGroups[0].adds,
-          groups[1].adds.slice(1));
-      assert.deepEqual(
-          collapsedGroups[2].lines[0].contextGroups[0].removes,
-          groups[1].removes.slice(1));
-
-      assert.equal(
-          collapsedGroups[2].lines[0].contextGroups[1].type,
-          GrDiffGroup.Type.BOTH);
-      assert.deepEqual(
-          collapsedGroups[2].lines[0].contextGroups[1].lines,
-          [groups[2].lines[0]]);
-
-      assert.equal(collapsedGroups[3].type, GrDiffGroup.Type.BOTH);
-      assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
-    });
-
-    test('groups unchanged if the hidden range is empty', () => {
-      assert.deepEqual(
-          GrDiffGroup.hideInContextControl(groups, 0, 0), groups);
-    });
-
-    test('groups unchanged if there is only 1 line to hide', () => {
-      assert.deepEqual(
-          GrDiffGroup.hideInContextControl(groups, 3, 4), groups);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
new file mode 100644
index 0000000..3f8512b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
@@ -0,0 +1,191 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {GrDiffLine} from './gr-diff-line.js';
+import {GrDiffGroup} from './gr-diff-group.js';
+
+suite('gr-diff-group tests', () => {
+  test('delta line pairs', () => {
+    let group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+    const l1 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 128);
+    const l2 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 129);
+    const l3 = new GrDiffLine(GrDiffLine.Type.REMOVE, 64, 0);
+    group.addLine(l1);
+    group.addLine(l2);
+    group.addLine(l3);
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, [l1, l2]);
+    assert.deepEqual(group.removes, [l3]);
+    assert.deepEqual(group.lineRange, {
+      left: {start: 64, end: 64},
+      right: {start: 128, end: 129},
+    });
+
+    let pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l3, right: l1},
+      {left: GrDiffLine.BLANK_LINE, right: l2},
+    ]);
+
+    group = new GrDiffGroup(GrDiffGroup.Type.DELTA, [l1, l2, l3]);
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, [l1, l2]);
+    assert.deepEqual(group.removes, [l3]);
+
+    pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l3, right: l1},
+      {left: GrDiffLine.BLANK_LINE, right: l2},
+    ]);
+  });
+
+  test('group/header line pairs', () => {
+    const l1 = new GrDiffLine(GrDiffLine.Type.BOTH, 64, 128);
+    const l2 = new GrDiffLine(GrDiffLine.Type.BOTH, 65, 129);
+    const l3 = new GrDiffLine(GrDiffLine.Type.BOTH, 66, 130);
+
+    let group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
+
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, []);
+    assert.deepEqual(group.removes, []);
+
+    assert.deepEqual(group.lineRange, {
+      left: {start: 64, end: 66},
+      right: {start: 128, end: 130},
+    });
+
+    let pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l1, right: l1},
+      {left: l2, right: l2},
+      {left: l3, right: l3},
+    ]);
+
+    group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL, [l1, l2, l3]);
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, []);
+    assert.deepEqual(group.removes, []);
+
+    pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l1, right: l1},
+      {left: l2, right: l2},
+      {left: l3, right: l3},
+    ]);
+  });
+
+  test('adding delta lines to non-delta group', () => {
+    const l1 = new GrDiffLine(GrDiffLine.Type.ADD);
+    const l2 = new GrDiffLine(GrDiffLine.Type.REMOVE);
+    const l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
+
+    let group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+    assert.throws(group.addLine.bind(group, l1));
+    assert.throws(group.addLine.bind(group, l2));
+    assert.doesNotThrow(group.addLine.bind(group, l3));
+
+    group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL);
+    assert.throws(group.addLine.bind(group, l1));
+    assert.throws(group.addLine.bind(group, l2));
+    assert.doesNotThrow(group.addLine.bind(group, l3));
+  });
+
+  suite('hideInContextControl', () => {
+    let groups;
+    setup(() => {
+      groups = [
+        new GrDiffGroup(GrDiffGroup.Type.BOTH, [
+          new GrDiffLine(GrDiffLine.Type.BOTH, 5, 7),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 6, 8),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 7, 9),
+        ]),
+        new GrDiffGroup(GrDiffGroup.Type.DELTA, [
+          new GrDiffLine(GrDiffLine.Type.REMOVE, 8),
+          new GrDiffLine(GrDiffLine.Type.ADD, 0, 10),
+          new GrDiffLine(GrDiffLine.Type.REMOVE, 9),
+          new GrDiffLine(GrDiffLine.Type.ADD, 0, 11),
+          new GrDiffLine(GrDiffLine.Type.REMOVE, 10),
+          new GrDiffLine(GrDiffLine.Type.ADD, 0, 12),
+        ]),
+        new GrDiffGroup(GrDiffGroup.Type.BOTH, [
+          new GrDiffLine(GrDiffLine.Type.BOTH, 11, 13),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 12, 14),
+          new GrDiffLine(GrDiffLine.Type.BOTH, 13, 15),
+        ]),
+      ];
+    });
+
+    test('hides hidden groups in context control', () => {
+      const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 3, 6);
+      assert.equal(collapsedGroups.length, 3);
+
+      assert.equal(collapsedGroups[0], groups[0]);
+
+      assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+      assert.equal(collapsedGroups[1].contextGroups.length, 1);
+      assert.equal(collapsedGroups[1].contextGroups[0], groups[1]);
+
+      assert.equal(collapsedGroups[2], groups[2]);
+    });
+
+    test('splits partially hidden groups', () => {
+      const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 4, 7);
+      assert.equal(collapsedGroups.length, 4);
+      assert.equal(collapsedGroups[0], groups[0]);
+
+      assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.DELTA);
+      assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
+      assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
+
+      assert.equal(collapsedGroups[2].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+      assert.equal(collapsedGroups[2].contextGroups.length, 2);
+
+      assert.equal(
+          collapsedGroups[2].contextGroups[0].type,
+          GrDiffGroup.Type.DELTA);
+      assert.deepEqual(
+          collapsedGroups[2].contextGroups[0].adds,
+          groups[1].adds.slice(1));
+      assert.deepEqual(
+          collapsedGroups[2].contextGroups[0].removes,
+          groups[1].removes.slice(1));
+
+      assert.equal(
+          collapsedGroups[2].contextGroups[1].type,
+          GrDiffGroup.Type.BOTH);
+      assert.deepEqual(
+          collapsedGroups[2].contextGroups[1].lines,
+          [groups[2].lines[0]]);
+
+      assert.equal(collapsedGroups[3].type, GrDiffGroup.Type.BOTH);
+      assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
+    });
+
+    test('groups unchanged if the hidden range is empty', () => {
+      assert.deepEqual(
+          GrDiffGroup.hideInContextControl(groups, 0, 0), groups);
+    });
+
+    test('groups unchanged if there is only 1 line to hide', () => {
+      assert.deepEqual(
+          GrDiffGroup.hideInContextControl(groups, 3, 4), groups);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
index 70387ca..727cb45 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
@@ -36,9 +36,6 @@
   /** @type {!Array<GrDiffLine.Highlights>} */
   this.highlights = [];
 
-  /** @type {?Array<Object>} ?Array<!GrDiffGroup> */
-  this.contextGroups = null;
-
   this.text = '';
 }
 
@@ -47,7 +44,6 @@
   ADD: 'add',
   BOTH: 'both',
   BLANK: 'blank',
-  CONTEXT_CONTROL: 'contextControl',
   REMOVE: 'remove',
 };
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 1bb3842..2c5787a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-button/gr-button.js';
 import '../gr-diff-builder/gr-diff-builder-element.js';
@@ -25,14 +23,17 @@
 import '../gr-ranged-comment-themes/gr-ranged-comment-theme.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {htmlTemplate} from './gr-diff_html.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
 import {GrDiffLine} from './gr-diff-line.js';
 import {DiffSide, rangesEqual} from './gr-diff-utils.js';
 import {getHiddenScroll} from '../../../scripts/hiddenscroll.js';
+import {
+  isMergeParent,
+  patchNumEquals,
+  SPECIAL_PATCH_SET_NUM,
+} from '../../../utils/patch-set-util.js';
 
 const ERR_COMMENT_ON_EDIT = 'You cannot comment on an edit.';
 const ERR_COMMENT_ON_EDIT_BASE = 'You cannot comment on the base patch set ' +
@@ -58,7 +59,7 @@
 
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 /**
- * 72 is the inofficial length standard for git commit messages.
+ * 72 is the unofficial length standard for git commit messages.
  * Derived from the fact that git log/show appends 4 ws in the beginning of
  * each line when displaying commit messages. To center the commit message
  * in an 80 char terminal a 4 ws border is added to the rightmost side:
@@ -69,13 +70,11 @@
 const RENDER_DIFF_TABLE_DEBOUNCE_NAME = 'renderDiffTable';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrDiff extends mixinBehaviors( [
-  PatchSetBehavior,
-], GestureEventListeners(
+class GrDiff extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-diff'; }
@@ -293,15 +292,16 @@
     this._unobserveNodes();
   }
 
-  showNoChangeMessage(loading, prefs, diffLength) {
+  showNoChangeMessage(loading, prefs, diffLength, diff) {
     return !loading &&
-      prefs && prefs.ignore_whitespace !== 'IGNORE_NONE' &&
-      diffLength === 0;
+        diff && !diff.binary &&
+        prefs && prefs.ignore_whitespace !== 'IGNORE_NONE' &&
+        diffLength === 0;
   }
 
   _enableSelectionObserver(loggedIn, isAttached) {
     // Polymer 2: check for undefined
-    if ([loggedIn, isAttached].some(arg => arg === undefined)) {
+    if ([loggedIn, isAttached].includes(undefined)) {
       return;
     }
 
@@ -354,7 +354,7 @@
     function commentRangeFromThreadEl(threadEl) {
       const side = threadEl.getAttribute('comment-side');
       const range = JSON.parse(threadEl.getAttribute('range'));
-      return {side, range, hovering: false};
+      return {side, range, hovering: false, rootId: threadEl.rootId};
     }
 
     const addedCommentRanges = addedThreadEls
@@ -435,7 +435,8 @@
     }
 
     return Array.from(
-        dom(this.root).querySelectorAll(':not(.contextControl) > .diff-row'));
+        dom(this.root).querySelectorAll(':not(.contextControl) > .diff-row'))
+        .filter(tr => tr.querySelector('button'));
   }
 
   /** @return {boolean} */
@@ -567,9 +568,9 @@
       this.patchRange.basePatchNum :
       this.patchRange.patchNum;
 
-    const isEdit = this.patchNumEquals(patchNum, this.EDIT_NAME);
-    const isEditBase = this.patchNumEquals(patchNum, this.PARENT_NAME) &&
-        this.patchNumEquals(this.patchRange.patchNum, this.EDIT_NAME);
+    const isEdit = patchNumEquals(patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
+    const isEditBase = patchNumEquals(patchNum, SPECIAL_PATCH_SET_NUM.PARENT) &&
+        patchNumEquals(this.patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
 
     if (isEdit) {
       this.dispatchEvent(new CustomEvent('show-alert', {
@@ -594,11 +595,7 @@
    * @param {!Object=} range
    */
   _createComment(lineEl, lineNum, side, range) {
-    const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
-    if (!contentText) {
-      return;
-    }
-    const contentEl = contentText.parentElement;
+    const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
     side = side ||
         this._getCommentSideByLineAndContent(lineEl, contentEl);
     const patchForNewThreads = this._getPatchNumByLineAndContent(
@@ -662,7 +659,7 @@
     if ((lineEl.classList.contains(DiffSide.LEFT) ||
         contentEl.classList.contains('remove')) &&
         this.patchRange.basePatchNum !== 'PARENT' &&
-        !this.isMergeParent(this.patchRange.basePatchNum)) {
+        !isMergeParent(this.patchRange.basePatchNum)) {
       patchNum = this.patchRange.basePatchNum;
     }
     return patchNum;
@@ -670,13 +667,10 @@
 
   /** @return {boolean} */
   _getIsParentCommentByLineAndContent(lineEl, contentEl) {
-    if ((lineEl.classList.contains(DiffSide.LEFT) ||
+    return (lineEl.classList.contains(DiffSide.LEFT) ||
         contentEl.classList.contains('remove')) &&
         (this.patchRange.basePatchNum === 'PARENT' ||
-        this.isMergeParent(this.patchRange.basePatchNum))) {
-      return true;
-    }
-    return false;
+            isMergeParent(this.patchRange.basePatchNum));
   }
 
   /** @return {string} */
@@ -836,11 +830,16 @@
         const commentSide = threadEl.getAttribute('comment-side');
         const lineEl = this.$.diffBuilder.getLineElByNumber(
             lineNumString, commentSide);
-        const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
-        if (!contentText) {
+        // When the line the comment refers to does not exist, log an error
+        // but don't crash. This can happen e.g. if the API does not fully
+        // validate e.g. (robot) comments
+        if (lineEl == undefined) {
+          console.error(
+              'thread attached to line ', commentSide, lineNumString,
+              ' which does not exist.');
           continue;
         }
-        const contentEl = contentText.parentElement;
+        const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
         const threadGroupEl = this._getOrCreateThreadGroup(
             contentEl, commentSide);
         // Create a slot for the thread and attach it to the thread group.
@@ -881,7 +880,7 @@
    */
   _getBypassPrefs() {
     if (this._safetyBypass !== null) {
-      return Object.assign({}, this.prefs, {context: this._safetyBypass});
+      return {...this.prefs, context: this._safetyBypass};
     }
     return this.prefs;
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js
deleted file mode 100644
index 55abd38..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.js
+++ /dev/null
@@ -1,454 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host(.no-left) .sideBySide .left,
-    :host(.no-left) .sideBySide .left + td,
-    :host(.no-left) .sideBySide .right:not([data-value]),
-    :host(.no-left) .sideBySide .right:not([data-value]) + td {
-      display: none;
-    }
-    :host {
-      font-family: var(--monospace-font-family, ''), 'Roboto Mono';
-      font-size: var(--font-size, var(--font-size-code, 12px));
-      line-height: var(--line-height-code, 1.334);
-    }
-
-    .thread-group {
-      display: block;
-      max-width: var(--content-width, 80ch);
-      white-space: normal;
-      background-color: var(--diff-blank-background-color);
-    }
-    .diffContainer {
-      display: flex;
-      font-family: var(--monospace-font-family);
-      @apply --diff-container-styles;
-    }
-    .diffContainer.hiddenscroll {
-      margin-bottom: var(--spacing-m);
-    }
-    table {
-      border-collapse: collapse;
-      border-right: 1px solid var(--border-color);
-      table-layout: fixed;
-    }
-    .lineNumButton {
-      display: block;
-      width: 100%;
-      height: 100%;
-      background-color: var(--diff-blank-background-color);
-    }
-    /*
-      The only way to focus this (clicking) will apply our own focus styling,
-      so this default styling is not needed and distracting.
-      */
-    .lineNumButton:focus {
-      outline: none;
-    }
-    .image-diff .gr-diff {
-      text-align: center;
-    }
-    .image-diff img {
-      box-shadow: var(--elevation-level-1);
-      max-width: 50em;
-    }
-    .image-diff .right.lineNumButton {
-      border-left: 1px solid var(--border-color);
-    }
-    .image-diff label,
-    .binary-diff label {
-      font-family: var(--font-family);
-      font-style: italic;
-    }
-    .diff-row {
-      outline: none;
-      user-select: none;
-    }
-    .diff-row.target-row.target-side-left .lineNumButton.left,
-    .diff-row.target-row.target-side-right .lineNumButton.right,
-    .diff-row.target-row.unified .lineNumButton {
-      background-color: var(--diff-selection-background-color);
-      color: var(--primary-text-color);
-    }
-    .content {
-      background-color: var(--diff-blank-background-color);
-    }
-    .contentText {
-      background-color: var(--view-background-color);
-    }
-    .blank {
-      background-color: var(--diff-blank-background-color);
-    }
-    .image-diff .content {
-      background-color: var(--diff-blank-background-color);
-    }
-    .full-width {
-      width: 100%;
-    }
-    .full-width .contentText {
-      white-space: pre-wrap;
-      word-wrap: break-word;
-    }
-    .lineNumButton,
-    .content {
-      vertical-align: top;
-      white-space: pre;
-    }
-    .contextLineNum,
-    .lineNumButton {
-      -webkit-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-      user-select: none;
-
-      color: var(--deemphasized-text-color);
-      padding: 0 var(--spacing-m);
-      text-align: right;
-    }
-    .canComment .lineNumButton {
-      cursor: pointer;
-    }
-    .content {
-      /* Set min width since setting width on table cells still
-           allows them to shrink. Do not set max width because
-           CJK (Chinese-Japanese-Korean) glyphs have variable width */
-      min-width: var(--content-width, 80ch);
-      width: var(--content-width, 80ch);
-    }
-    .content.add .contentText .intraline,
-      /* If there are no intraline info, consider everything changed */
-      .content.add.no-intraline-info .contentText,
-      .delta.total .content.add .contentText {
-      background-color: var(--dark-add-highlight-color);
-    }
-    .content.add .contentText {
-      background-color: var(--light-add-highlight-color);
-    }
-    .content.remove .contentText .intraline,
-      /* If there are no intraline info, consider everything changed */
-      .content.remove.no-intraline-info .contentText,
-      .delta.total .content.remove .contentText {
-      background-color: var(--dark-remove-highlight-color);
-    }
-    .content.remove .contentText {
-      background-color: var(--light-remove-highlight-color);
-    }
-
-    /* dueToRebase */
-    .dueToRebase .content.add .contentText .intraline,
-    .delta.total.dueToRebase .content.add .contentText {
-      background-color: var(--dark-rebased-add-highlight-color);
-    }
-    .dueToRebase .content.add .contentText {
-      background-color: var(--light-rebased-add-highlight-color);
-    }
-    .dueToRebase .content.remove .contentText .intraline,
-    .delta.total.dueToRebase .content.remove .contentText {
-      background-color: var(--dark-rebased-remove-highlight-color);
-    }
-    .dueToRebase .content.remove .contentText {
-      background-color: var(--light-remove-add-highlight-color);
-    }
-
-    /* ignoredWhitespaceOnly */
-    .ignoredWhitespaceOnly .content.add .contentText .intraline,
-    .delta.total.ignoredWhitespaceOnly .content.add .contentText,
-    .ignoredWhitespaceOnly .content.add .contentText,
-    .ignoredWhitespaceOnly .content.remove .contentText .intraline,
-    .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
-    .ignoredWhitespaceOnly .content.remove .contentText {
-      background-color: var(--view-background-color);
-    }
-
-    .content .contentText:empty:after {
-      /* Newline, to ensure empty lines are one line-height tall. */
-      content: '\\A';
-    }
-    .contextControl {
-      background-color: var(--diff-context-control-background-color);
-      border: 1px solid var(--diff-context-control-border-color);
-      color: var(--diff-context-control-color);
-    }
-    .contextControl gr-button {
-      display: inline-block;
-      text-decoration: none;
-      vertical-align: top;
-      line-height: var(--line-height-mono, 18px);
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        padding: var(--spacing-xxs) var(--spacing-l);
-      }
-    }
-    .contextControl gr-button iron-icon {
-      /* should match line-height of gr-button */
-      width: var(--line-height-mono, 18px);
-      height: var(--line-height-mono, 18px);
-    }
-    .contextControl td:not(.lineNumButton) {
-      text-align: center;
-    }
-    .displayLine .diff-row.target-row td {
-      box-shadow: inset 0 -1px var(--border-color);
-    }
-    .br:after {
-      /* Line feed */
-      content: '\\A';
-    }
-    .tab {
-      display: inline-block;
-    }
-    .tab-indicator:before {
-      color: var(--diff-tab-indicator-color);
-      /* >> character */
-      content: '\\00BB';
-      position: absolute;
-    }
-    /* Is defined after other background-colors, such that this
-         rule wins in case of same specificity. */
-    .trailing-whitespace,
-    .content .trailing-whitespace,
-    .trailing-whitespace .intraline,
-    .content .trailing-whitespace .intraline {
-      border-radius: var(--border-radius, 4px);
-      background-color: var(--diff-trailing-whitespace-indicator);
-    }
-    #diffHeader {
-      background-color: var(--table-header-background-color);
-      border-bottom: 1px solid var(--border-color);
-      color: var(--link-color);
-      padding: var(--spacing-m) 0 var(--spacing-m) 48px;
-    }
-    #loadingError,
-    #sizeWarning {
-      display: none;
-      margin: var(--spacing-l) auto;
-      max-width: 60em;
-      text-align: center;
-    }
-    #loadingError {
-      color: var(--error-text-color);
-    }
-    #sizeWarning gr-button {
-      margin: var(--spacing-l);
-    }
-    #loadingError.showError,
-    #sizeWarning.warn {
-      display: block;
-    }
-    .target-row td.blame {
-      background: var(--diff-selection-background-color);
-    }
-    col.blame {
-      display: none;
-    }
-    td.blame {
-      display: none;
-      padding: 0 var(--spacing-m);
-      white-space: pre;
-    }
-    :host(.showBlame) col.blame {
-      display: table-column;
-    }
-    :host(.showBlame) td.blame {
-      display: table-cell;
-    }
-    td.blame > span {
-      opacity: 0.6;
-    }
-    td.blame > span.startOfRange {
-      opacity: 1;
-    }
-    td.blame .blameDate {
-      font-family: var(--monospace-font-family);
-      color: var(--link-color);
-      text-decoration: none;
-    }
-    .full-width td.blame {
-      overflow: hidden;
-      width: 200px;
-    }
-    /** Support the line length indicator **/
-    .full-width td.content .contentText {
-      /* Base 64 encoded 1x1px of #ddd */
-      background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mO8+x8AAr8B3gzOjaQAAAAASUVORK5CYII=');
-      background-position: var(--line-limit) 0;
-      background-repeat: repeat-y;
-    }
-    .newlineWarning {
-      color: var(--deemphasized-text-color);
-      text-align: center;
-    }
-    .newlineWarning.hidden {
-      display: none;
-    }
-    .lineNum.COVERED .lineNumButton {
-      background-color: var(--coverage-covered, #e0f2f1);
-    }
-    .lineNum.NOT_COVERED .lineNumButton {
-      background-color: var(--coverage-not-covered, #ffd1a4);
-    }
-    .lineNum.PARTIALLY_COVERED .lineNumButton {
-      background: linear-gradient(
-        to right bottom,
-        var(--coverage-not-covered, #ffd1a4) 0%,
-        var(--coverage-not-covered, #ffd1a4) 50%,
-        var(--coverage-covered, #e0f2f1) 50%,
-        var(--coverage-covered, #e0f2f1) 100%
-      );
-    }
-
-    /** BEGIN: Select and copy for Polymer 2 */
-    /** Below was copied and modified from the original css in gr-diff-selection.html */
-    .content,
-    .contextControl,
-    .blame {
-      -webkit-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-      user-select: none;
-    }
-
-    .selected-left:not(.selected-comment)
-      .side-by-side
-      .left
-      + .content
-      .contentText,
-    .selected-right:not(.selected-comment)
-      .side-by-side
-      .right
-      + .content
-      .contentText,
-    .selected-left:not(.selected-comment)
-      .unified
-      .left.lineNum
-      ~ .content:not(.both)
-      .contentText,
-    .selected-right:not(.selected-comment)
-      .unified
-      .right.lineNum
-      ~ .content
-      .contentText,
-    .selected-left.selected-comment .side-by-side .left + .content .message,
-    .selected-right.selected-comment
-      .side-by-side
-      .right
-      + .content
-      .message
-      :not(.collapsedContent),
-    .selected-comment .unified .message :not(.collapsedContent),
-    .selected-blame .blame {
-      -webkit-user-select: text;
-      -moz-user-select: text;
-      -ms-user-select: text;
-      user-select: text;
-    }
-
-    /** Make comments selectable when selected */
-    .selected-left.selected-comment
-      ::slotted(gr-comment-thread[comment-side='left']),
-    .selected-right.selected-comment
-      ::slotted(gr-comment-thread[comment-side='right']) {
-      -webkit-user-select: text;
-      -moz-user-select: text;
-      -ms-user-select: text;
-      user-select: text;
-    }
-    /** END: Select and copy for Polymer 2 */
-
-    .whitespace-change-only-message {
-      background-color: var(--diff-context-control-background-color);
-      border: 1px solid var(--diff-context-control-border-color);
-      text-align: center;
-    }
-  </style>
-  <style include="gr-syntax-theme">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-ranged-comment-theme">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
-    <template is="dom-repeat" items="[[_diffHeaderItems]]">
-      <div>[[item]]</div>
-    </template>
-  </div>
-  <div
-    class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
-    on-tap="_handleTap"
-  >
-    <gr-diff-selection diff="[[diff]]">
-      <gr-diff-highlight
-        id="highlights"
-        logged-in="[[loggedIn]]"
-        comment-ranges="{{_commentRanges}}"
-      >
-        <gr-diff-builder
-          id="diffBuilder"
-          comment-ranges="[[_commentRanges]]"
-          coverage-ranges="[[coverageRanges]]"
-          project-name="[[projectName]]"
-          diff="[[diff]]"
-          path="[[path]]"
-          change-num="[[changeNum]]"
-          patch-num="[[patchRange.patchNum]]"
-          view-mode="[[viewMode]]"
-          is-image-diff="[[isImageDiff]]"
-          base-image="[[baseImage]]"
-          layers="[[layers]]"
-          revision-image="[[revisionImage]]"
-        >
-          <table
-            id="diffTable"
-            class$="[[_diffTableClass]]"
-            role="presentation"
-          ></table>
-
-          <template
-            is="dom-if"
-            if="[[showNoChangeMessage(loading, prefs, _diffLength)]]"
-          >
-            <div class="whitespace-change-only-message">
-              This file only contains whitespace changes. Modify the whitespace
-              setting to see the changes.
-            </div>
-          </template>
-        </gr-diff-builder>
-      </gr-diff-highlight>
-    </gr-diff-selection>
-  </div>
-  <div class$="[[_computeNewlineWarningClass(_newlineWarning, loading)]]">
-    [[_newlineWarning]]
-  </div>
-  <div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
-    [[errorMessage]]
-  </div>
-  <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
-    <p>
-      Prevented render because "Whole file" is enabled and this diff is very
-      large (about [[_diffLength]] lines).
-    </p>
-    <gr-button on-click="_handleLimitedBypass">
-      Render with limited context
-    </gr-button>
-    <gr-button on-click="_handleFullBypass">
-      Render anyway (may be slow)
-    </gr-button>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
new file mode 100644
index 0000000..f18af23
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -0,0 +1,464 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host(.no-left) .sideBySide .left,
+    :host(.no-left) .sideBySide .left + td,
+    :host(.no-left) .sideBySide .right:not([data-value]),
+    :host(.no-left) .sideBySide .right:not([data-value]) + td {
+      display: none;
+    }
+    :host {
+      font-family: var(--monospace-font-family, ''), 'Roboto Mono';
+      font-size: var(--font-size, var(--font-size-code, 12px));
+      line-height: var(--line-height-code, 1.334);
+    }
+
+    .thread-group {
+      display: block;
+      max-width: var(--content-width, 80ch);
+      white-space: normal;
+      background-color: var(--diff-blank-background-color);
+    }
+    .diffContainer {
+      display: flex;
+      font-family: var(--monospace-font-family);
+      @apply --diff-container-styles;
+    }
+    .diffContainer.hiddenscroll {
+      margin-bottom: var(--spacing-m);
+    }
+    table {
+      border-collapse: collapse;
+      border-right: 1px solid var(--border-color);
+      table-layout: fixed;
+    }
+    .lineNumButton {
+      display: block;
+      width: 100%;
+      height: 100%;
+      background-color: var(--diff-blank-background-color);
+    }
+    /*
+      The only way to focus this (clicking) will apply our own focus styling,
+      so this default styling is not needed and distracting.
+      */
+    .lineNumButton:focus {
+      outline: none;
+    }
+    .image-diff .gr-diff {
+      text-align: center;
+    }
+    .image-diff img {
+      box-shadow: var(--elevation-level-1);
+      max-width: 50em;
+    }
+    .image-diff .right.lineNumButton {
+      border-left: 1px solid var(--border-color);
+    }
+    .image-diff label,
+    .binary-diff label {
+      font-family: var(--font-family);
+      font-style: italic;
+    }
+    .diff-row {
+      outline: none;
+      user-select: none;
+    }
+    .diff-row.target-row.target-side-left .lineNumButton.left,
+    .diff-row.target-row.target-side-right .lineNumButton.right,
+    .diff-row.target-row.unified .lineNumButton {
+      background-color: var(--diff-selection-background-color);
+      color: var(--primary-text-color);
+    }
+    .content {
+      background-color: var(--diff-blank-background-color);
+    }
+    /*
+      The file line, which has no contentText, add some margin before the first
+      comment. We cannot add padding the container because we only want it if
+      there is at least one comment thread, and the slotting makes :empty not
+      work as expected.
+     */
+    .content.file slot:first-child::slotted(.comment-thread) {
+      display: block;
+      margin-top: var(--spacing-xs);
+    }
+    .contentText {
+      background-color: var(--view-background-color);
+    }
+    .blank {
+      background-color: var(--diff-blank-background-color);
+    }
+    .image-diff .content {
+      background-color: var(--diff-blank-background-color);
+    }
+    .full-width {
+      width: 100%;
+    }
+    .full-width .contentText {
+      white-space: pre-wrap;
+      word-wrap: break-word;
+    }
+    .lineNumButton,
+    .content {
+      vertical-align: top;
+      white-space: pre;
+    }
+    .contextLineNum,
+    .lineNumButton {
+      -webkit-user-select: none;
+      -moz-user-select: none;
+      -ms-user-select: none;
+      user-select: none;
+
+      color: var(--deemphasized-text-color);
+      padding: 0 var(--spacing-m);
+      text-align: right;
+    }
+    .canComment .lineNumButton {
+      cursor: pointer;
+    }
+    .content {
+      /* Set min width since setting width on table cells still
+           allows them to shrink. Do not set max width because
+           CJK (Chinese-Japanese-Korean) glyphs have variable width */
+      min-width: var(--content-width, 80ch);
+      width: var(--content-width, 80ch);
+    }
+    .content.add .contentText .intraline,
+      /* If there are no intraline info, consider everything changed */
+      .content.add.no-intraline-info .contentText,
+      .delta.total .content.add .contentText {
+      background-color: var(--dark-add-highlight-color);
+    }
+    .content.add .contentText {
+      background-color: var(--light-add-highlight-color);
+    }
+    .content.remove .contentText .intraline,
+      /* If there are no intraline info, consider everything changed */
+      .content.remove.no-intraline-info .contentText,
+      .delta.total .content.remove .contentText {
+      background-color: var(--dark-remove-highlight-color);
+    }
+    .content.remove .contentText {
+      background-color: var(--light-remove-highlight-color);
+    }
+
+    /* dueToRebase */
+    .dueToRebase .content.add .contentText .intraline,
+    .delta.total.dueToRebase .content.add .contentText {
+      background-color: var(--dark-rebased-add-highlight-color);
+    }
+    .dueToRebase .content.add .contentText {
+      background-color: var(--light-rebased-add-highlight-color);
+    }
+    .dueToRebase .content.remove .contentText .intraline,
+    .delta.total.dueToRebase .content.remove .contentText {
+      background-color: var(--dark-rebased-remove-highlight-color);
+    }
+    .dueToRebase .content.remove .contentText {
+      background-color: var(--light-remove-add-highlight-color);
+    }
+
+    /* ignoredWhitespaceOnly */
+    .ignoredWhitespaceOnly .content.add .contentText .intraline,
+    .delta.total.ignoredWhitespaceOnly .content.add .contentText,
+    .ignoredWhitespaceOnly .content.add .contentText,
+    .ignoredWhitespaceOnly .content.remove .contentText .intraline,
+    .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
+    .ignoredWhitespaceOnly .content.remove .contentText {
+      background-color: var(--view-background-color);
+    }
+
+    .content .contentText:empty:after {
+      /* Newline, to ensure empty lines are one line-height tall. */
+      content: '\\A';
+    }
+    .contextControl {
+      background-color: var(--diff-context-control-background-color);
+      border: 1px solid var(--diff-context-control-border-color);
+      color: var(--diff-context-control-color);
+    }
+    .contextControl gr-button {
+      display: inline-block;
+      text-decoration: none;
+      vertical-align: top;
+      line-height: var(--line-height-mono, 18px);
+      --gr-button: {
+        color: var(--diff-context-control-color);
+        padding: var(--spacing-xxs) var(--spacing-l);
+      }
+    }
+    .contextControl gr-button iron-icon {
+      /* should match line-height of gr-button */
+      width: var(--line-height-mono, 18px);
+      height: var(--line-height-mono, 18px);
+    }
+    .contextControl td:not(.lineNumButton) {
+      text-align: center;
+    }
+    .displayLine .diff-row.target-row td {
+      box-shadow: inset 0 -1px var(--border-color);
+    }
+    .br:after {
+      /* Line feed */
+      content: '\\A';
+    }
+    .tab {
+      display: inline-block;
+    }
+    .tab-indicator:before {
+      color: var(--diff-tab-indicator-color);
+      /* >> character */
+      content: '\\00BB';
+      position: absolute;
+    }
+    /* Is defined after other background-colors, such that this
+         rule wins in case of same specificity. */
+    .trailing-whitespace,
+    .content .trailing-whitespace,
+    .trailing-whitespace .intraline,
+    .content .trailing-whitespace .intraline {
+      border-radius: var(--border-radius, 4px);
+      background-color: var(--diff-trailing-whitespace-indicator);
+    }
+    #diffHeader {
+      background-color: var(--table-header-background-color);
+      border-bottom: 1px solid var(--border-color);
+      color: var(--link-color);
+      padding: var(--spacing-m) 0 var(--spacing-m) 48px;
+    }
+    #loadingError,
+    #sizeWarning {
+      display: none;
+      margin: var(--spacing-l) auto;
+      max-width: 60em;
+      text-align: center;
+    }
+    #loadingError {
+      color: var(--error-text-color);
+    }
+    #sizeWarning gr-button {
+      margin: var(--spacing-l);
+    }
+    #loadingError.showError,
+    #sizeWarning.warn {
+      display: block;
+    }
+    .target-row td.blame {
+      background: var(--diff-selection-background-color);
+    }
+    col.blame {
+      display: none;
+    }
+    td.blame {
+      display: none;
+      padding: 0 var(--spacing-m);
+      white-space: pre;
+    }
+    :host(.showBlame) col.blame {
+      display: table-column;
+    }
+    :host(.showBlame) td.blame {
+      display: table-cell;
+    }
+    td.blame > span {
+      opacity: 0.6;
+    }
+    td.blame > span.startOfRange {
+      opacity: 1;
+    }
+    td.blame .blameDate {
+      font-family: var(--monospace-font-family);
+      color: var(--link-color);
+      text-decoration: none;
+    }
+    .full-width td.blame {
+      overflow: hidden;
+      width: 200px;
+    }
+    /** Support the line length indicator **/
+    .full-width td.content .contentText {
+      /* Base 64 encoded 1x1px of #ddd */
+      background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mO8+x8AAr8B3gzOjaQAAAAASUVORK5CYII=');
+      background-position: var(--line-limit) 0;
+      background-repeat: repeat-y;
+    }
+    .newlineWarning {
+      color: var(--deemphasized-text-color);
+      text-align: center;
+    }
+    .newlineWarning.hidden {
+      display: none;
+    }
+    .lineNum.COVERED .lineNumButton {
+      background-color: var(--coverage-covered, #e0f2f1);
+    }
+    .lineNum.NOT_COVERED .lineNumButton {
+      background-color: var(--coverage-not-covered, #ffd1a4);
+    }
+    .lineNum.PARTIALLY_COVERED .lineNumButton {
+      background: linear-gradient(
+        to right bottom,
+        var(--coverage-not-covered, #ffd1a4) 0%,
+        var(--coverage-not-covered, #ffd1a4) 50%,
+        var(--coverage-covered, #e0f2f1) 50%,
+        var(--coverage-covered, #e0f2f1) 100%
+      );
+    }
+
+    /** BEGIN: Select and copy for Polymer 2 */
+    /** Below was copied and modified from the original css in gr-diff-selection.html */
+    .content,
+    .contextControl,
+    .blame {
+      -webkit-user-select: none;
+      -moz-user-select: none;
+      -ms-user-select: none;
+      user-select: none;
+    }
+
+    .selected-left:not(.selected-comment)
+      .side-by-side
+      .left
+      + .content
+      .contentText,
+    .selected-right:not(.selected-comment)
+      .side-by-side
+      .right
+      + .content
+      .contentText,
+    .selected-left:not(.selected-comment)
+      .unified
+      .left.lineNum
+      ~ .content:not(.both)
+      .contentText,
+    .selected-right:not(.selected-comment)
+      .unified
+      .right.lineNum
+      ~ .content
+      .contentText,
+    .selected-left.selected-comment .side-by-side .left + .content .message,
+    .selected-right.selected-comment
+      .side-by-side
+      .right
+      + .content
+      .message
+      :not(.collapsedContent),
+    .selected-comment .unified .message :not(.collapsedContent),
+    .selected-blame .blame {
+      -webkit-user-select: text;
+      -moz-user-select: text;
+      -ms-user-select: text;
+      user-select: text;
+    }
+
+    /** Make comments selectable when selected */
+    .selected-left.selected-comment
+      ::slotted(gr-comment-thread[comment-side='left']),
+    .selected-right.selected-comment
+      ::slotted(gr-comment-thread[comment-side='right']) {
+      -webkit-user-select: text;
+      -moz-user-select: text;
+      -ms-user-select: text;
+      user-select: text;
+    }
+    /** END: Select and copy for Polymer 2 */
+
+    .whitespace-change-only-message {
+      background-color: var(--diff-context-control-background-color);
+      border: 1px solid var(--diff-context-control-border-color);
+      text-align: center;
+    }
+  </style>
+  <style include="gr-syntax-theme">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-ranged-comment-theme">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
+    <template is="dom-repeat" items="[[_diffHeaderItems]]">
+      <div>[[item]]</div>
+    </template>
+  </div>
+  <div
+    class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
+    on-tap="_handleTap"
+  >
+    <gr-diff-selection diff="[[diff]]">
+      <gr-diff-highlight
+        id="highlights"
+        logged-in="[[loggedIn]]"
+        comment-ranges="{{_commentRanges}}"
+      >
+        <gr-diff-builder
+          id="diffBuilder"
+          comment-ranges="[[_commentRanges]]"
+          coverage-ranges="[[coverageRanges]]"
+          project-name="[[projectName]]"
+          diff="[[diff]]"
+          path="[[path]]"
+          change-num="[[changeNum]]"
+          patch-num="[[patchRange.patchNum]]"
+          view-mode="[[viewMode]]"
+          is-image-diff="[[isImageDiff]]"
+          base-image="[[baseImage]]"
+          layers="[[layers]]"
+          revision-image="[[revisionImage]]"
+        >
+          <table
+            id="diffTable"
+            class$="[[_diffTableClass]]"
+            role="presentation"
+          ></table>
+
+          <template
+            is="dom-if"
+            if="[[showNoChangeMessage(loading, prefs, _diffLength, diff)]]"
+          >
+            <div class="whitespace-change-only-message">
+              This file only contains whitespace changes. Modify the whitespace
+              setting to see the changes.
+            </div>
+          </template>
+        </gr-diff-builder>
+      </gr-diff-highlight>
+    </gr-diff-selection>
+  </div>
+  <div class$="[[_computeNewlineWarningClass(_newlineWarning, loading)]]">
+    [[_newlineWarning]]
+  </div>
+  <div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
+    [[errorMessage]]
+  </div>
+  <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
+    <p>
+      Prevented render because "Whole file" is enabled and this diff is very
+      large (about [[_diffLength]] lines).
+    </p>
+    <gr-button on-click="_handleLimitedBypass">
+      Render with limited context
+    </gr-button>
+    <gr-button on-click="_handleFullBypass">
+      Render anyway (may be slow)
+    </gr-button>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
deleted file mode 100644
index bb0366b..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ /dev/null
@@ -1,1167 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-diff</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script src="/components/web-component-tester/data/a11ySuite.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff></gr-diff>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {getMockDiffResponse} from '../../../test/mock-diff-response.js';
-import './gr-diff.js';
-import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
-import {util} from '../../../scripts/util.js';
-import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
-
-suite('gr-diff tests', () => {
-  let element;
-  let sandbox;
-
-  const MINIMAL_PREFS = {tab_size: 2, line_length: 80};
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('selectionchange event handling', () => {
-    const emulateSelection = function() {
-      document.dispatchEvent(new CustomEvent('selectionchange'));
-    };
-
-    setup(() => {
-      element = fixture('basic');
-      sandbox.stub(element.$.highlights, 'handleSelectionChange');
-    });
-
-    test('enabled if logged in', () => {
-      element.loggedIn = true;
-      emulateSelection();
-      assert.isTrue(element.$.highlights.handleSelectionChange.called);
-    });
-
-    test('ignored if logged out', () => {
-      element.loggedIn = false;
-      emulateSelection();
-      assert.isFalse(element.$.highlights.handleSelectionChange.called);
-    });
-  });
-
-  test('cancel', () => {
-    element = fixture('basic');
-    const cancelStub = sandbox.stub(element.$.diffBuilder, 'cancel');
-    element.cancel();
-    assert.isTrue(cancelStub.calledOnce);
-  });
-
-  test('line limit with line_wrapping', () => {
-    element = fixture('basic');
-    element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: true});
-    flushAsynchronousOperations();
-    assert.equal(util.getComputedStyleValue('--line-limit', element), '80ch');
-  });
-
-  test('line limit without line_wrapping', () => {
-    element = fixture('basic');
-    element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: false});
-    flushAsynchronousOperations();
-    assert.isNotOk(util.getComputedStyleValue('--line-limit', element));
-  });
-
-  suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
-    let lineEl;
-    let contentEl;
-
-    setup(() => {
-      element = fixture('basic');
-      lineEl = document.createElement('td');
-      contentEl = document.createElement('span');
-    });
-
-    suite('_getPatchNumByLineAndContent', () => {
-      test('right side', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        lineEl.classList.add('right');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            4);
-      });
-
-      test('left side parent by linenum', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        lineEl.classList.add('left');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            4);
-      });
-
-      test('left side parent by content', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        contentEl.classList.add('remove');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            4);
-      });
-
-      test('left side merge parent', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: -2};
-        contentEl.classList.add('remove');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            4);
-      });
-
-      test('left side non parent', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 3};
-        contentEl.classList.add('remove');
-        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
-            3);
-      });
-    });
-
-    suite('_getIsParentCommentByLineAndContent', () => {
-      test('right side', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        lineEl.classList.add('right');
-        assert.isFalse(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-
-      test('left side parent by linenum', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        lineEl.classList.add('left');
-        assert.isTrue(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-
-      test('left side parent by content', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
-        contentEl.classList.add('remove');
-        assert.isTrue(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-
-      test('left side merge parent', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: -2};
-        contentEl.classList.add('remove');
-        assert.isTrue(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-
-      test('left side non parent', () => {
-        element.patchRange = {patchNum: 4, basePatchNum: 3};
-        contentEl.classList.add('remove');
-        assert.isFalse(
-            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
-      });
-    });
-  });
-
-  suite('not logged in', () => {
-    setup(() => {
-      const getLoggedInPromise = Promise.resolve(false);
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return getLoggedInPromise; },
-      });
-      element = fixture('basic');
-      return getLoggedInPromise;
-    });
-
-    test('toggleLeftDiff', () => {
-      element.toggleLeftDiff();
-      assert.isTrue(element.classList.contains('no-left'));
-      element.toggleLeftDiff();
-      assert.isFalse(element.classList.contains('no-left'));
-    });
-
-    test('addDraftAtLine', () => {
-      sandbox.stub(element, '_selectLine');
-      const loggedInErrorSpy = sandbox.spy();
-      element.addEventListener('show-auth-required', loggedInErrorSpy);
-      element.addDraftAtLine();
-      assert.isTrue(loggedInErrorSpy.called);
-    });
-
-    test('view does not start with displayLine classList', () => {
-      assert.isFalse(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
-    });
-
-    test('displayLine class added called when displayLine is true', () => {
-      const spy = sandbox.spy(element, '_computeContainerClass');
-      element.displayLine = true;
-      assert.isTrue(spy.called);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
-    });
-
-    test('thread groups', () => {
-      const contentEl = document.createElement('div');
-
-      element.changeNum = 123;
-      element.patchRange = {basePatchNum: 1, patchNum: 2};
-      element.path = 'file.txt';
-
-      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
-          getMockDiffResponse(), Object.assign({}, MINIMAL_PREFS));
-
-      // No thread groups.
-      assert.isNotOk(element._getThreadGroupForLine(contentEl));
-
-      // A thread group gets created.
-      const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
-      assert.isOk(threadGroupEl);
-
-      // The new thread group can be fetched.
-      assert.isOk(element._getThreadGroupForLine(contentEl));
-
-      assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
-    });
-
-    suite('image diffs', () => {
-      let mockFile1;
-      let mockFile2;
-      setup(() => {
-        mockFile1 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAAAAAA/w==',
-          type: 'image/bmp',
-        };
-        mockFile2 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
-          'wsAAAAAAAAAAAAA/////w==',
-          type: 'image/bmp',
-        };
-
-        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-        element.isImageDiff = true;
-        element.prefs = {
-          auto_hide_diff_table_header: true,
-          context: 10,
-          cursor_blink_rate: 0,
-          font_size: 12,
-          ignore_whitespace: 'IGNORE_NONE',
-          intraline_difference: true,
-          line_length: 100,
-          line_wrapping: false,
-          show_line_endings: true,
-          show_tabs: true,
-          show_whitespace_errors: true,
-          syntax_highlighting: true,
-          tab_size: 8,
-          theme: 'DEFAULT',
-        };
-      });
-
-      test('renders image diffs with same file name', done => {
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isNotOk(rightLabelName);
-          assert.isNotOk(leftLabelName);
-
-          let leftLoaded = false;
-          let rightLoaded = false;
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-            leftLoaded = true;
-            if (rightLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-
-            rightLoaded = true;
-            if (leftLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.revisionImage = mockFile2;
-        element.diff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-      });
-
-      test('renders image diffs with a different file name', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot2.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot2.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isOk(rightLabelName);
-          assert.isOk(leftLabelName);
-          assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
-          assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
-
-          let leftLoaded = false;
-          let rightLoaded = false;
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-            leftLoaded = true;
-            if (rightLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64, ' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-
-            rightLoaded = true;
-            if (leftLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.baseImage._name = mockDiff.meta_a.name;
-        element.revisionImage = mockFile2;
-        element.revisionImage._name = mockDiff.meta_b.name;
-        element.diff = mockDiff;
-      });
-
-      test('renders added image', done => {
-        const mockDiff = {
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'ADDED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 0000000..f9c2f2c 100644',
-            '--- /dev/null',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-
-        function rendered() {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const rightImage = element.$.diffTable.querySelector('td.right img');
-
-          assert.isNotOk(leftImage);
-          assert.isOk(rightImage);
-          done();
-          element.removeEventListener('render', rendered);
-        }
-        element.addEventListener('render', rendered);
-
-        element.revisionImage = mockFile2;
-        element.diff = mockDiff;
-      });
-
-      test('renders removed image', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-
-        function rendered() {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const rightImage = element.$.diffTable.querySelector('td.right img');
-
-          assert.isOk(leftImage);
-          assert.isNotOk(rightImage);
-          done();
-          element.removeEventListener('render', rendered);
-        }
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.diff = mockDiff;
-      });
-
-      test('does not render disallowed image type', done => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
-            lines: 560},
-          intraline_status: 'OK',
-          change_type: 'DELETED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index f9c2f2c..0000000 100644',
-            '--- a/carrot.jpg',
-            '+++ /dev/null',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        mockFile1.type = 'image/jpeg-evil';
-
-        function rendered() {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          assert.isNotOk(leftImage);
-          done();
-          element.removeEventListener('render', rendered);
-        }
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.diff = mockDiff;
-      });
-    });
-
-    test('_handleTap lineNum', done => {
-      const addDraftStub = sandbox.stub(element, 'addDraftAtLine');
-      const el = document.createElement('div');
-      el.className = 'lineNum';
-      el.addEventListener('click', e => {
-        element._handleTap(e);
-        assert.isTrue(addDraftStub.called);
-        assert.equal(addDraftStub.lastCall.args[0], el);
-        done();
-      });
-      el.click();
-    });
-
-    test('_handleTap context', done => {
-      const showContextStub =
-          sandbox.stub(element.$.diffBuilder, 'showContext');
-      const el = document.createElement('div');
-      el.className = 'showContext';
-      el.addEventListener('click', e => {
-        element._handleTap(e);
-        assert.isTrue(showContextStub.called);
-        done();
-      });
-      el.click();
-    });
-
-    test('_handleTap content', done => {
-      const content = document.createElement('div');
-      const lineEl = document.createElement('div');
-
-      const selectStub = sandbox.stub(element, '_selectLine');
-      sandbox.stub(element.$.diffBuilder, 'getLineElByChild', () => lineEl);
-
-      content.className = 'content';
-      content.addEventListener('click', e => {
-        element._handleTap(e);
-        assert.isTrue(selectStub.called);
-        assert.equal(selectStub.lastCall.args[0], lineEl);
-        done();
-      });
-      content.click();
-    });
-
-    suite('getCursorStops', () => {
-      const setupDiff = function() {
-        element.diff = getMockDiffResponse();
-        element.prefs = {
-          context: 10,
-          tab_size: 8,
-          font_size: 12,
-          line_length: 100,
-          cursor_blink_rate: 0,
-          line_wrapping: false,
-          intraline_difference: true,
-          show_line_endings: true,
-          show_tabs: true,
-          show_whitespace_errors: true,
-          syntax_highlighting: true,
-          auto_hide_diff_table_header: true,
-          theme: 'DEFAULT',
-          ignore_whitespace: 'IGNORE_NONE',
-        };
-
-        element._renderDiffTable();
-        flushAsynchronousOperations();
-      };
-
-      test('getCursorStops returns [] when hidden and noAutoRender', () => {
-        element.noAutoRender = true;
-        setupDiff();
-        element.hidden = true;
-        assert.equal(element.getCursorStops().length, 0);
-      });
-
-      test('getCursorStops', () => {
-        setupDiff();
-        const ROWS = 48;
-        const FILE_ROW = 1;
-        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
-      });
-    });
-
-    test('adds .hiddenscroll', () => {
-      _setHiddenScroll(true);
-      element.displayLine = true;
-      assert.include(element.shadowRoot
-          .querySelector('.diffContainer').className, 'hiddenscroll');
-    });
-  });
-
-  suite('logged in', () => {
-    let fakeLineEl;
-    setup(() => {
-      element = fixture('basic');
-      element.loggedIn = true;
-      element.patchRange = {};
-
-      fakeLineEl = {
-        getAttribute: sandbox.stub().returns(42),
-        classList: {
-          contains: sandbox.stub().returns(true),
-        },
-      };
-    });
-
-    test('addDraftAtLine', () => {
-      sandbox.stub(element, '_selectLine');
-      sandbox.stub(element, '_createComment');
-      element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(element._createComment
-          .calledWithExactly(fakeLineEl, 42));
-    });
-
-    test('addDraftAtLine on an edit', () => {
-      element.patchRange.basePatchNum = element.EDIT_NAME;
-      sandbox.stub(element, '_selectLine');
-      sandbox.stub(element, '_createComment');
-      const alertSpy = sandbox.spy();
-      element.addEventListener('show-alert', alertSpy);
-      element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(alertSpy.called);
-      assert.isFalse(element._createComment.called);
-    });
-
-    test('addDraftAtLine on an edit base', () => {
-      element.patchRange.patchNum = element.EDIT_NAME;
-      element.patchRange.basePatchNum = element.PARENT_NAME;
-      sandbox.stub(element, '_selectLine');
-      sandbox.stub(element, '_createComment');
-      const alertSpy = sandbox.spy();
-      element.addEventListener('show-alert', alertSpy);
-      element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(alertSpy.called);
-      assert.isFalse(element._createComment.called);
-    });
-
-    suite('change in preferences', () => {
-      setup(() => {
-        element.diff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          diff_header: [],
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          content: [{skip: 66}],
-        };
-        element.flushDebouncer('renderDiffTable');
-      });
-
-      test('change in preferences re-renders diff', () => {
-        sandbox.stub(element, '_renderDiffTable');
-        element.prefs = Object.assign(
-            {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
-        element.flushDebouncer('renderDiffTable');
-        assert.isTrue(element._renderDiffTable.called);
-      });
-
-      test('adding/removing property in preferences re-renders diff', () => {
-        const stub = sandbox.stub(element, '_renderDiffTable');
-        const newPrefs1 = Object.assign({}, MINIMAL_PREFS,
-            {line_wrapping: true});
-        element.prefs = newPrefs1;
-        element.flushDebouncer('renderDiffTable');
-        assert.isTrue(element._renderDiffTable.called);
-        stub.reset();
-
-        const newPrefs2 = Object.assign({}, newPrefs1);
-        delete newPrefs2.line_wrapping;
-        element.prefs = newPrefs2;
-        element.flushDebouncer('renderDiffTable');
-        assert.isTrue(element._renderDiffTable.called);
-      });
-
-      test('change in preferences does not re-renders diff with ' +
-          'noRenderOnPrefsChange', () => {
-        sandbox.stub(element, '_renderDiffTable');
-        element.noRenderOnPrefsChange = true;
-        element.prefs = Object.assign(
-            {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
-        element.flushDebouncer('renderDiffTable');
-        assert.isFalse(element._renderDiffTable.called);
-      });
-    });
-  });
-
-  suite('diff header', () => {
-    setup(() => {
-      element = fixture('basic');
-      element.diff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-          lines: 560},
-        diff_header: [],
-        intraline_status: 'OK',
-        change_type: 'MODIFIED',
-        content: [{skip: 66}],
-      };
-    });
-
-    test('hidden', () => {
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', '--- a/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', '+++ b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'test');
-      assert.equal(element._diffHeaderItems.length, 1);
-      flushAsynchronousOperations();
-
-      assert.equal(element.$.diffHeader.textContent.trim(), 'test');
-    });
-
-    test('binary files', () => {
-      element.diff.binary = true;
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'test');
-      assert.equal(element._diffHeaderItems.length, 1);
-      element.push('diff.diff_header', 'Binary files differ');
-      assert.equal(element._diffHeaderItems.length, 1);
-    });
-  });
-
-  suite('safety and bypass', () => {
-    let renderStub;
-
-    setup(() => {
-      element = fixture('basic');
-      renderStub = sandbox.stub(element.$.diffBuilder, 'render',
-          () => {
-            element.$.diffBuilder.dispatchEvent(
-                new CustomEvent('render', {bubbles: true, composed: true}));
-            return Promise.resolve({});
-          });
-      sandbox.stub(element, 'getDiffLength').returns(10000);
-      element.diff = getMockDiffResponse();
-      element.noRenderOnPrefsChange = true;
-    });
-
-    test('large render w/ context = 10', done => {
-      element.prefs = Object.assign({}, MINIMAL_PREFS, {context: 10});
-      function rendered() {
-        assert.isTrue(renderStub.called);
-        assert.isFalse(element._showWarning);
-        done();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-    });
-
-    test('large render w/ whole file and bypass', done => {
-      element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
-      element._safetyBypass = 10;
-      function rendered() {
-        assert.isTrue(renderStub.called);
-        assert.isFalse(element._showWarning);
-        done();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-    });
-
-    test('large render w/ whole file and no bypass', done => {
-      element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
-      function rendered() {
-        assert.isFalse(renderStub.called);
-        assert.isTrue(element._showWarning);
-        done();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-    });
-  });
-
-  suite('blame', () => {
-    setup(() => {
-      element = fixture('basic');
-    });
-
-    test('unsetting', () => {
-      element.blame = [];
-      const setBlameSpy = sandbox.spy(element.$.diffBuilder, 'setBlame');
-      element.classList.add('showBlame');
-      element.blame = null;
-      assert.isTrue(setBlameSpy.calledWithExactly(null));
-      assert.isFalse(element.classList.contains('showBlame'));
-    });
-
-    test('setting', () => {
-      const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-      element.blame = mockBlame;
-      assert.isTrue(element.classList.contains('showBlame'));
-    });
-  });
-
-  suite('trailing newline warnings', () => {
-    const NO_NEWLINE_BASE = 'No newline at end of base file.';
-    const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
-
-    const getWarning = element =>
-      element.shadowRoot.querySelector('.newlineWarning').textContent;
-
-    setup(() => {
-      element = fixture('basic');
-      element.showNewlineWarningLeft = false;
-      element.showNewlineWarningRight = false;
-    });
-
-    test('shows combined warning if both sides set to warn', () => {
-      element.showNewlineWarningLeft = true;
-      element.showNewlineWarningRight = true;
-      assert.include(getWarning(element),
-          NO_NEWLINE_BASE + ' \u2014 ' + NO_NEWLINE_REVISION);// \u2014 - '—'
-    });
-
-    suite('showNewlineWarningLeft', () => {
-      test('show warning if true', () => {
-        element.showNewlineWarningLeft = true;
-        assert.include(getWarning(element), NO_NEWLINE_BASE);
-      });
-
-      test('hide warning if false', () => {
-        element.showNewlineWarningLeft = false;
-        assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
-      });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningLeft = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
-      });
-    });
-
-    suite('showNewlineWarningRight', () => {
-      test('show warning if true', () => {
-        element.showNewlineWarningRight = true;
-        assert.include(getWarning(element), NO_NEWLINE_REVISION);
-      });
-
-      test('hide warning if false', () => {
-        element.showNewlineWarningRight = false;
-        assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
-      });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningRight = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
-      });
-    });
-
-    test('_computeNewlineWarningClass', () => {
-      const hidden = 'newlineWarning hidden';
-      const shown = 'newlineWarning';
-      assert.equal(element._computeNewlineWarningClass(null, true), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
-      assert.equal(element._computeNewlineWarningClass(null, false), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', false), shown);
-    });
-
-    test('_prefsEqual', () => {
-      element = fixture('basic');
-      assert.isTrue(element._prefsEqual(null, null));
-      assert.isTrue(element._prefsEqual({}, {}));
-      assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
-      assert.isTrue(
-          element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-      const somePref = {abc: 'def', p: true};
-      assert.isTrue(element._prefsEqual(somePref, somePref));
-
-      assert.isFalse(element._prefsEqual({}, null));
-      assert.isFalse(element._prefsEqual(null, {}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
-    });
-  });
-
-  suite('key locations', () => {
-    let renderStub;
-
-    setup(() => {
-      element = fixture('basic');
-      element.prefs = {};
-      renderStub = sandbox.stub(element.$.diffBuilder, 'render')
-          .returns(new Promise(() => {}));
-    });
-
-    test('lineOfInterest is a key location', () => {
-      element.lineOfInterest = {number: 789, leftSide: true};
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {789: true},
-        right: {},
-      });
-    });
-
-    test('line comments are key locations', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      dom(element).appendChild(threadEl);
-      flush();
-
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {},
-        right: {3: true},
-      });
-    });
-
-    test('file comments are key locations', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('comment-side', 'left');
-      dom(element).appendChild(threadEl);
-      flush();
-
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {FILE: true},
-        right: {},
-      });
-    });
-  });
-  const setupSampleDiff = function(params) {
-    const {ignore_whitespace, content} = params;
-    element = fixture('basic');
-    element.prefs = {
-      ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
-      auto_hide_diff_table_header: true,
-      context: 10,
-      cursor_blink_rate: 0,
-      font_size: 12,
-      intraline_difference: true,
-      line_length: 100,
-      line_wrapping: false,
-      show_line_endings: true,
-      show_tabs: true,
-      show_whitespace_errors: true,
-      syntax_highlighting: true,
-      tab_size: 8,
-      theme: 'DEFAULT',
-    };
-    element.diff = {
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/carrot.js b/carrot.js',
-        'index 2adc47d..f9c2f2c 100644',
-        '--- a/carrot.js',
-        '+++ b/carrot.jjs',
-        'file differ',
-      ],
-      content,
-      binary: false,
-    };
-    element._renderDiffTable();
-    flushAsynchronousOperations();
-  };
-
-  test('clear diff table content as soon as diff changes', () => {
-    const content = [{
-      a: ['all work and no play make andybons a dull boy'],
-    }, {
-      b: [
-        'Non eram nescius, Brute, cum, quae summis ingeniis ',
-      ],
-    }];
-    function assertDiffTableWithContent() {
-      assert.isTrue(element.$.diffTable.innerText.includes(content[0].a));
-    }
-    setupSampleDiff({content});
-    assertDiffTableWithContent();
-    const diffCopy = Object.assign({}, element.diff);
-    element.diff = diffCopy;
-    // immediatelly cleaned up
-    assert.equal(element.$.diffTable.innerHTML, '');
-    element._renderDiffTable();
-    flushAsynchronousOperations();
-    // rendered again
-    assertDiffTableWithContent();
-  });
-
-  suite('selection test', () => {
-    test('user-select set correctly on side-by-side view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      flushAsynchronousOperations();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
-      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      // click to mark it as selected
-      MockInteractions.tap(diffLine);
-      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
-    });
-
-    test('user-select set correctly on unified view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      element.viewMode = 'UNIFIED_DIFF';
-      flushAsynchronousOperations();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
-      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      MockInteractions.tap(diffLine);
-      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
-    });
-  });
-
-  suite('whitespace changes only message', () => {
-    test('show the message if ignore_whitespace is criteria matches', () => {
-      setupSampleDiff({content: [{skip: 100}]});
-      assert.isTrue(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength
-      ));
-    });
-
-    test('do not show the message if still loading', () => {
-      setupSampleDiff({content: [{skip: 100}]});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ true,
-          element.prefs,
-          element._diffLength
-      ));
-    });
-
-    test('do not show the message if contains valid changes', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      assert.equal(element._diffLength, 3);
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength
-      ));
-    });
-
-    test('do not show message if ignore whitespace is disabled', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength
-      ));
-    });
-  });
-
-  test('getDiffLength', () => {
-    const diff = getMockDiffResponse();
-    assert.equal(element.getDiffLength(diff), 52);
-  });
-
-  test('`render` event has contentRendered field in detail', done => {
-    element = fixture('basic');
-    element.prefs = {};
-    sandbox.stub(element.$.diffBuilder, 'render')
-        .returns(Promise.resolve());
-    element.addEventListener('render', event => {
-      assert.isTrue(event.detail.contentRendered);
-      done();
-    });
-    element._renderDiffTable();
-  });
-});
-
-a11ySuite('basic');
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
new file mode 100644
index 0000000..02f09a8
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
@@ -0,0 +1,1184 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import './gr-diff.js';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
+import {getComputedStyleValue} from '../../../utils/dom-util.js';
+import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
+import {runA11yAudit} from '../../../test/a11y-test-utils.js';
+import '@polymer/paper-button/paper-button.js';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+
+const basicFixture = fixtureFromElement('gr-diff');
+
+suite('gr-diff a11y test', () => {
+  test('audit', async () => {
+    await runA11yAudit(basicFixture);
+  });
+});
+
+suite('gr-diff tests', () => {
+  let element;
+
+  const MINIMAL_PREFS = {tab_size: 2, line_length: 80};
+
+  setup(() => {
+
+  });
+
+  suite('selectionchange event handling', () => {
+    const emulateSelection = function() {
+      document.dispatchEvent(new CustomEvent('selectionchange'));
+    };
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      sinon.stub(element.$.highlights, 'handleSelectionChange');
+    });
+
+    test('enabled if logged in', () => {
+      element.loggedIn = true;
+      emulateSelection();
+      assert.isTrue(element.$.highlights.handleSelectionChange.called);
+    });
+
+    test('ignored if logged out', () => {
+      element.loggedIn = false;
+      emulateSelection();
+      assert.isFalse(element.$.highlights.handleSelectionChange.called);
+    });
+  });
+
+  test('cancel', () => {
+    element = basicFixture.instantiate();
+    const cancelStub = sinon.stub(element.$.diffBuilder, 'cancel');
+    element.cancel();
+    assert.isTrue(cancelStub.calledOnce);
+  });
+
+  test('line limit with line_wrapping', () => {
+    element = basicFixture.instantiate();
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
+    flushAsynchronousOperations();
+    assert.equal(getComputedStyleValue('--line-limit', element), '80ch');
+  });
+
+  test('line limit without line_wrapping', () => {
+    element = basicFixture.instantiate();
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
+    flushAsynchronousOperations();
+    assert.isNotOk(getComputedStyleValue('--line-limit', element));
+  });
+
+  suite('_get{PatchNum|IsParentComment}ByLineAndContent', () => {
+    let lineEl;
+    let contentEl;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      lineEl = document.createElement('td');
+      contentEl = document.createElement('span');
+    });
+
+    suite('_getPatchNumByLineAndContent', () => {
+      test('right side', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        lineEl.classList.add('right');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            4);
+      });
+
+      test('left side parent by linenum', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        lineEl.classList.add('left');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            4);
+      });
+
+      test('left side parent by content', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        contentEl.classList.add('remove');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            4);
+      });
+
+      test('left side merge parent', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: -2};
+        contentEl.classList.add('remove');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            4);
+      });
+
+      test('left side non parent', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 3};
+        contentEl.classList.add('remove');
+        assert.equal(element._getPatchNumByLineAndContent(lineEl, contentEl),
+            3);
+      });
+    });
+
+    suite('_getIsParentCommentByLineAndContent', () => {
+      test('right side', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        lineEl.classList.add('right');
+        assert.isFalse(
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
+      });
+
+      test('left side parent by linenum', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        lineEl.classList.add('left');
+        assert.isTrue(
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
+      });
+
+      test('left side parent by content', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 'PARENT'};
+        contentEl.classList.add('remove');
+        assert.isTrue(
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
+      });
+
+      test('left side merge parent', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: -2};
+        contentEl.classList.add('remove');
+        assert.isTrue(
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
+      });
+
+      test('left side non parent', () => {
+        element.patchRange = {patchNum: 4, basePatchNum: 3};
+        contentEl.classList.add('remove');
+        assert.isFalse(
+            element._getIsParentCommentByLineAndContent(lineEl, contentEl));
+      });
+    });
+  });
+
+  suite('not logged in', () => {
+    setup(() => {
+      const getLoggedInPromise = Promise.resolve(false);
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return getLoggedInPromise; },
+      });
+      element = basicFixture.instantiate();
+      return getLoggedInPromise;
+    });
+
+    test('toggleLeftDiff', () => {
+      element.toggleLeftDiff();
+      assert.isTrue(element.classList.contains('no-left'));
+      element.toggleLeftDiff();
+      assert.isFalse(element.classList.contains('no-left'));
+    });
+
+    test('addDraftAtLine', () => {
+      sinon.stub(element, '_selectLine');
+      const loggedInErrorSpy = sinon.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      element.addDraftAtLine();
+      assert.isTrue(loggedInErrorSpy.called);
+    });
+
+    test('view does not start with displayLine classList', () => {
+      assert.isFalse(
+          element.shadowRoot
+              .querySelector('.diffContainer')
+              .classList
+              .contains('displayLine'));
+    });
+
+    test('displayLine class added called when displayLine is true', () => {
+      const spy = sinon.spy(element, '_computeContainerClass');
+      element.displayLine = true;
+      assert.isTrue(spy.called);
+      assert.isTrue(
+          element.shadowRoot
+              .querySelector('.diffContainer')
+              .classList
+              .contains('displayLine'));
+    });
+
+    test('thread groups', () => {
+      const contentEl = document.createElement('div');
+
+      element.changeNum = 123;
+      element.patchRange = {basePatchNum: 1, patchNum: 2};
+      element.path = 'file.txt';
+
+      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
+          getMockDiffResponse(), {...MINIMAL_PREFS});
+
+      // No thread groups.
+      assert.isNotOk(element._getThreadGroupForLine(contentEl));
+
+      // A thread group gets created.
+      const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
+      assert.isOk(threadGroupEl);
+
+      // The new thread group can be fetched.
+      assert.isOk(element._getThreadGroupForLine(contentEl));
+
+      assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
+    });
+
+    suite('image diffs', () => {
+      let mockFile1;
+      let mockFile2;
+      setup(() => {
+        mockFile1 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAAAAAA/w==',
+          type: 'image/bmp',
+        };
+        mockFile2 = {
+          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAA/////w==',
+          type: 'image/bmp',
+        };
+
+        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+        element.isImageDiff = true;
+        element.prefs = {
+          auto_hide_diff_table_header: true,
+          context: 10,
+          cursor_blink_rate: 0,
+          font_size: 12,
+          ignore_whitespace: 'IGNORE_NONE',
+          intraline_difference: true,
+          line_length: 100,
+          line_wrapping: false,
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          tab_size: 8,
+          theme: 'DEFAULT',
+        };
+      });
+
+      test('renders image diffs with same file name', done => {
+        const rendered = () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          // Left image rendered with the parent commit's version of the file.
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          const leftLabel =
+              element.$.diffTable.querySelector('td.left label');
+          const leftLabelContent = leftLabel.querySelector('.label');
+          const leftLabelName = leftLabel.querySelector('.name');
+
+          const rightImage =
+              element.$.diffTable.querySelector('td.right img');
+          const rightLabel = element.$.diffTable.querySelector(
+              'td.right label');
+          const rightLabelContent = rightLabel.querySelector('.label');
+          const rightLabelName = rightLabel.querySelector('.name');
+
+          assert.isNotOk(rightLabelName);
+          assert.isNotOk(leftLabelName);
+
+          let leftLoaded = false;
+          let rightLoaded = false;
+
+          leftImage.addEventListener('load', () => {
+            assert.isOk(leftImage);
+            assert.equal(leftImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile1.body);
+            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+            leftLoaded = true;
+            if (rightLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+
+          rightImage.addEventListener('load', () => {
+            assert.isOk(rightImage);
+            assert.equal(rightImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile2.body);
+            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+
+            rightLoaded = true;
+            if (leftLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+        };
+
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.revisionImage = mockFile2;
+        element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+      });
+
+      test('renders image diffs with a different file name', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot2.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot2.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        const rendered = () => {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          // Left image rendered with the parent commit's version of the file.
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          const leftLabel =
+              element.$.diffTable.querySelector('td.left label');
+          const leftLabelContent = leftLabel.querySelector('.label');
+          const leftLabelName = leftLabel.querySelector('.name');
+
+          const rightImage =
+              element.$.diffTable.querySelector('td.right img');
+          const rightLabel = element.$.diffTable.querySelector(
+              'td.right label');
+          const rightLabelContent = rightLabel.querySelector('.label');
+          const rightLabelName = rightLabel.querySelector('.name');
+
+          assert.isOk(rightLabelName);
+          assert.isOk(leftLabelName);
+          assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+          assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+          let leftLoaded = false;
+          let rightLoaded = false;
+
+          leftImage.addEventListener('load', () => {
+            assert.isOk(leftImage);
+            assert.equal(leftImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile1.body);
+            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+            leftLoaded = true;
+            if (rightLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+
+          rightImage.addEventListener('load', () => {
+            assert.isOk(rightImage);
+            assert.equal(rightImage.getAttribute('src'),
+                'data:image/bmp;base64, ' + mockFile2.body);
+            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
+
+            rightLoaded = true;
+            if (leftLoaded) {
+              element.removeEventListener('render', rendered);
+              done();
+            }
+          });
+        };
+
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.baseImage._name = mockDiff.meta_a.name;
+        element.revisionImage = mockFile2;
+        element.revisionImage._name = mockDiff.meta_b.name;
+        element.diff = mockDiff;
+      });
+
+      test('renders added image', done => {
+        const mockDiff = {
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'ADDED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 0000000..f9c2f2c 100644',
+            '--- /dev/null',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        function rendered() {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          const rightImage = element.$.diffTable.querySelector('td.right img');
+
+          assert.isNotOk(leftImage);
+          assert.isOk(rightImage);
+          done();
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
+
+        element.revisionImage = mockFile2;
+        element.diff = mockDiff;
+      });
+
+      test('renders removed image', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        function rendered() {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          const rightImage = element.$.diffTable.querySelector('td.right img');
+
+          assert.isOk(leftImage);
+          assert.isNotOk(rightImage);
+          done();
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+      });
+
+      test('does not render disallowed image type', done => {
+        const mockDiff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
+            lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        mockFile1.type = 'image/jpeg-evil';
+
+        function rendered() {
+          // Recognizes that it should be an image diff.
+          assert.isTrue(element.isImageDiff);
+          assert.instanceOf(
+              element.$.diffBuilder._builder, GrDiffBuilderImage);
+          const leftImage = element.$.diffTable.querySelector('td.left img');
+          assert.isNotOk(leftImage);
+          done();
+          element.removeEventListener('render', rendered);
+        }
+        element.addEventListener('render', rendered);
+
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+      });
+    });
+
+    test('_handleTap lineNum', done => {
+      const addDraftStub = sinon.stub(element, 'addDraftAtLine');
+      const el = document.createElement('div');
+      el.className = 'lineNum';
+      el.addEventListener('click', e => {
+        element._handleTap(e);
+        assert.isTrue(addDraftStub.called);
+        assert.equal(addDraftStub.lastCall.args[0], el);
+        done();
+      });
+      el.click();
+    });
+
+    test('_handleTap context', done => {
+      const showContextStub =
+          sinon.stub(element.$.diffBuilder, 'showContext');
+      const el = document.createElement('div');
+      el.className = 'showContext';
+      el.addEventListener('click', e => {
+        element._handleTap(e);
+        assert.isTrue(showContextStub.called);
+        done();
+      });
+      el.click();
+    });
+
+    test('_handleTap content', done => {
+      const content = document.createElement('div');
+      const lineEl = document.createElement('div');
+
+      const selectStub = sinon.stub(element, '_selectLine');
+      sinon.stub(element.$.diffBuilder, 'getLineElByChild')
+          .callsFake(() => lineEl);
+
+      content.className = 'content';
+      content.addEventListener('click', e => {
+        element._handleTap(e);
+        assert.isTrue(selectStub.called);
+        assert.equal(selectStub.lastCall.args[0], lineEl);
+        done();
+      });
+      content.click();
+    });
+
+    suite('getCursorStops', () => {
+      const setupDiff = function() {
+        element.diff = getMockDiffResponse();
+        element.prefs = {
+          context: 10,
+          tab_size: 8,
+          font_size: 12,
+          line_length: 100,
+          cursor_blink_rate: 0,
+          line_wrapping: false,
+          intraline_difference: true,
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          auto_hide_diff_table_header: true,
+          theme: 'DEFAULT',
+          ignore_whitespace: 'IGNORE_NONE',
+        };
+
+        element._renderDiffTable();
+        flushAsynchronousOperations();
+      };
+
+      test('getCursorStops returns [] when hidden and noAutoRender', () => {
+        element.noAutoRender = true;
+        setupDiff();
+        element.hidden = true;
+        assert.equal(element.getCursorStops().length, 0);
+      });
+
+      test('getCursorStops', () => {
+        setupDiff();
+        const ROWS = 48;
+        const FILE_ROW = 1;
+        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
+      });
+    });
+
+    test('adds .hiddenscroll', () => {
+      _setHiddenScroll(true);
+      element.displayLine = true;
+      assert.include(element.shadowRoot
+          .querySelector('.diffContainer').className, 'hiddenscroll');
+    });
+  });
+
+  suite('logged in', () => {
+    let fakeLineEl;
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.loggedIn = true;
+      element.patchRange = {};
+
+      fakeLineEl = {
+        getAttribute: sinon.stub().returns(42),
+        classList: {
+          contains: sinon.stub().returns(true),
+        },
+      };
+    });
+
+    test('addDraftAtLine', () => {
+      sinon.stub(element, '_selectLine');
+      sinon.stub(element, '_createComment');
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(element._createComment
+          .calledWithExactly(fakeLineEl, 42));
+    });
+
+    test('addDraftAtLine on an edit', () => {
+      element.patchRange.basePatchNum = SPECIAL_PATCH_SET_NUM.EDIT;
+      sinon.stub(element, '_selectLine');
+      sinon.stub(element, '_createComment');
+      const alertSpy = sinon.spy();
+      element.addEventListener('show-alert', alertSpy);
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(alertSpy.called);
+      assert.isFalse(element._createComment.called);
+    });
+
+    test('addDraftAtLine on an edit base', () => {
+      element.patchRange.patchNum = SPECIAL_PATCH_SET_NUM.EDIT;
+      element.patchRange.basePatchNum = SPECIAL_PATCH_SET_NUM.PARENT;
+      sinon.stub(element, '_selectLine');
+      sinon.stub(element, '_createComment');
+      const alertSpy = sinon.spy();
+      element.addEventListener('show-alert', alertSpy);
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(alertSpy.called);
+      assert.isFalse(element._createComment.called);
+    });
+
+    suite('change in preferences', () => {
+      setup(() => {
+        element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          diff_header: [],
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          content: [{skip: 66}],
+        };
+        element.flushDebouncer('renderDiffTable');
+      });
+
+      test('change in preferences re-renders diff', () => {
+        sinon.stub(element, '_renderDiffTable');
+        element.prefs = {
+          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
+        element.flushDebouncer('renderDiffTable');
+        assert.isTrue(element._renderDiffTable.called);
+      });
+
+      test('adding/removing property in preferences re-renders diff', () => {
+        const stub = sinon.stub(element, '_renderDiffTable');
+        const newPrefs1 = {...MINIMAL_PREFS,
+          line_wrapping: true};
+        element.prefs = newPrefs1;
+        element.flushDebouncer('renderDiffTable');
+        assert.isTrue(element._renderDiffTable.called);
+        stub.reset();
+
+        const newPrefs2 = {...newPrefs1};
+        delete newPrefs2.line_wrapping;
+        element.prefs = newPrefs2;
+        element.flushDebouncer('renderDiffTable');
+        assert.isTrue(element._renderDiffTable.called);
+      });
+
+      test('change in preferences does not re-renders diff with ' +
+          'noRenderOnPrefsChange', () => {
+        sinon.stub(element, '_renderDiffTable');
+        element.noRenderOnPrefsChange = true;
+        element.prefs = {
+          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
+        element.flushDebouncer('renderDiffTable');
+        assert.isFalse(element._renderDiffTable.called);
+      });
+    });
+  });
+
+  suite('diff header', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.diff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+          lines: 560},
+        diff_header: [],
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        content: [{skip: 66}],
+      };
+    });
+
+    test('hidden', () => {
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', '--- a/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', '+++ b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'test');
+      assert.equal(element._diffHeaderItems.length, 1);
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.diffHeader.textContent.trim(), 'test');
+    });
+
+    test('binary files', () => {
+      element.diff.binary = true;
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+      assert.equal(element._diffHeaderItems.length, 0);
+      element.push('diff.diff_header', 'test');
+      assert.equal(element._diffHeaderItems.length, 1);
+      element.push('diff.diff_header', 'Binary files differ');
+      assert.equal(element._diffHeaderItems.length, 1);
+    });
+  });
+
+  suite('safety and bypass', () => {
+    let renderStub;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      renderStub = sinon.stub(element.$.diffBuilder, 'render').callsFake(
+          () => {
+            element.$.diffBuilder.dispatchEvent(
+                new CustomEvent('render', {bubbles: true, composed: true}));
+            return Promise.resolve({});
+          });
+      sinon.stub(element, 'getDiffLength').returns(10000);
+      element.diff = getMockDiffResponse();
+      element.noRenderOnPrefsChange = true;
+    });
+
+    test('large render w/ context = 10', done => {
+      element.prefs = {...MINIMAL_PREFS, context: 10};
+      function rendered() {
+        assert.isTrue(renderStub.called);
+        assert.isFalse(element._showWarning);
+        done();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element._renderDiffTable();
+    });
+
+    test('large render w/ whole file and bypass', done => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+      element._safetyBypass = 10;
+      function rendered() {
+        assert.isTrue(renderStub.called);
+        assert.isFalse(element._showWarning);
+        done();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element._renderDiffTable();
+    });
+
+    test('large render w/ whole file and no bypass', done => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+      function rendered() {
+        assert.isFalse(renderStub.called);
+        assert.isTrue(element._showWarning);
+        done();
+        element.removeEventListener('render', rendered);
+      }
+      element.addEventListener('render', rendered);
+      element._renderDiffTable();
+    });
+  });
+
+  suite('blame', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+    });
+
+    test('unsetting', () => {
+      element.blame = [];
+      const setBlameSpy = sinon.spy(element.$.diffBuilder, 'setBlame');
+      element.classList.add('showBlame');
+      element.blame = null;
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.isFalse(element.classList.contains('showBlame'));
+    });
+
+    test('setting', () => {
+      element.blame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
+      assert.isTrue(element.classList.contains('showBlame'));
+    });
+  });
+
+  suite('trailing newline warnings', () => {
+    const NO_NEWLINE_BASE = 'No newline at end of base file.';
+    const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
+
+    const getWarning = element =>
+      element.shadowRoot.querySelector('.newlineWarning').textContent;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.showNewlineWarningLeft = false;
+      element.showNewlineWarningRight = false;
+    });
+
+    test('shows combined warning if both sides set to warn', () => {
+      element.showNewlineWarningLeft = true;
+      element.showNewlineWarningRight = true;
+      assert.include(getWarning(element),
+          NO_NEWLINE_BASE + ' \u2014 ' + NO_NEWLINE_REVISION);// \u2014 - '—'
+    });
+
+    suite('showNewlineWarningLeft', () => {
+      test('show warning if true', () => {
+        element.showNewlineWarningLeft = true;
+        assert.include(getWarning(element), NO_NEWLINE_BASE);
+      });
+
+      test('hide warning if false', () => {
+        element.showNewlineWarningLeft = false;
+        assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
+      });
+
+      test('hide warning if undefined', () => {
+        element.showNewlineWarningLeft = undefined;
+        assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
+      });
+    });
+
+    suite('showNewlineWarningRight', () => {
+      test('show warning if true', () => {
+        element.showNewlineWarningRight = true;
+        assert.include(getWarning(element), NO_NEWLINE_REVISION);
+      });
+
+      test('hide warning if false', () => {
+        element.showNewlineWarningRight = false;
+        assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
+      });
+
+      test('hide warning if undefined', () => {
+        element.showNewlineWarningRight = undefined;
+        assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
+      });
+    });
+
+    test('_computeNewlineWarningClass', () => {
+      const hidden = 'newlineWarning hidden';
+      const shown = 'newlineWarning';
+      assert.equal(element._computeNewlineWarningClass(null, true), hidden);
+      assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
+      assert.equal(element._computeNewlineWarningClass(null, false), hidden);
+      assert.equal(element._computeNewlineWarningClass('foo', false), shown);
+    });
+
+    test('_prefsEqual', () => {
+      element = basicFixture.instantiate();
+      assert.isTrue(element._prefsEqual(null, null));
+      assert.isTrue(element._prefsEqual({}, {}));
+      assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
+      assert.isTrue(
+          element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
+      const somePref = {abc: 'def', p: true};
+      assert.isTrue(element._prefsEqual(somePref, somePref));
+
+      assert.isFalse(element._prefsEqual({}, null));
+      assert.isFalse(element._prefsEqual(null, {}));
+      assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
+      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
+      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
+      assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
+    });
+  });
+
+  suite('key locations', () => {
+    let renderStub;
+
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.prefs = {};
+      renderStub = sinon.stub(element.$.diffBuilder, 'render')
+          .returns(new Promise(() => {}));
+    });
+
+    test('lineOfInterest is a key location', () => {
+      element.lineOfInterest = {number: 789, leftSide: true};
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {789: true},
+        right: {},
+      });
+    });
+
+    test('line comments are key locations', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'right');
+      threadEl.setAttribute('line-num', 3);
+      dom(element).appendChild(threadEl);
+      flush();
+
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {},
+        right: {3: true},
+      });
+    });
+
+    test('file comments are key locations', () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('comment-side', 'left');
+      dom(element).appendChild(threadEl);
+      flush();
+
+      element._renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {FILE: true},
+        right: {},
+      });
+    });
+  });
+  const setupSampleDiff = function(params) {
+    const {ignore_whitespace, content} = params;
+    // binary can't be undefined, use false if not set
+    const binary = params.binary || false;
+    element = basicFixture.instantiate();
+    element.prefs = {
+      ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
+      auto_hide_diff_table_header: true,
+      context: 10,
+      cursor_blink_rate: 0,
+      font_size: 12,
+      intraline_difference: true,
+      line_length: 100,
+      line_wrapping: false,
+      show_line_endings: true,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      tab_size: 8,
+      theme: 'DEFAULT',
+    };
+    element.diff = {
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/carrot.js b/carrot.js',
+        'index 2adc47d..f9c2f2c 100644',
+        '--- a/carrot.js',
+        '+++ b/carrot.jjs',
+        'file differ',
+      ],
+      content,
+      binary,
+    };
+    element._renderDiffTable();
+    flushAsynchronousOperations();
+  };
+
+  test('clear diff table content as soon as diff changes', () => {
+    const content = [{
+      a: ['all work and no play make andybons a dull boy'],
+    }, {
+      b: [
+        'Non eram nescius, Brute, cum, quae summis ingeniis ',
+      ],
+    }];
+    function assertDiffTableWithContent() {
+      assert.isTrue(element.$.diffTable.innerText.includes(content[0].a));
+    }
+    setupSampleDiff({content});
+    assertDiffTableWithContent();
+    element.diff = {...element.diff};
+    // immediately cleaned up
+    assert.equal(element.$.diffTable.innerHTML, '');
+    element._renderDiffTable();
+    flushAsynchronousOperations();
+    // rendered again
+    assertDiffTableWithContent();
+  });
+
+  suite('selection test', () => {
+    test('user-select set correctly on side-by-side view', () => {
+      const content = [{
+        a: ['all work and no play make andybons a dull boy'],
+        b: ['elgoog elgoog elgoog'],
+      }, {
+        ab: [
+          'Non eram nescius, Brute, cum, quae summis ingeniis ',
+          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+        ],
+      }];
+      setupSampleDiff({content});
+      flushAsynchronousOperations();
+      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      // click to mark it as selected
+      MockInteractions.tap(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+
+    test('user-select set correctly on unified view', () => {
+      const content = [{
+        a: ['all work and no play make andybons a dull boy'],
+        b: ['elgoog elgoog elgoog'],
+      }, {
+        ab: [
+          'Non eram nescius, Brute, cum, quae summis ingeniis ',
+          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+        ],
+      }];
+      setupSampleDiff({content});
+      element.viewMode = 'UNIFIED_DIFF';
+      flushAsynchronousOperations();
+      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      MockInteractions.tap(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+  });
+
+  suite('whitespace changes only message', () => {
+    test('show the message if ignore_whitespace is criteria matches', () => {
+      setupSampleDiff({content: [{skip: 100}]});
+      assert.isTrue(element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength,
+          element.diff
+      ));
+    });
+
+    test('do not show the message for binary files', () => {
+      setupSampleDiff({content: [{skip: 100}], binary: true});
+      assert.isFalse(element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength,
+          element.diff
+      ));
+    });
+
+    test('do not show the message if still loading', () => {
+      setupSampleDiff({content: [{skip: 100}]});
+      assert.isFalse(element.showNoChangeMessage(
+          /* loading= */ true,
+          element.prefs,
+          element._diffLength,
+          element.diff
+      ));
+    });
+
+    test('do not show the message if contains valid changes', () => {
+      const content = [{
+        a: ['all work and no play make andybons a dull boy'],
+        b: ['elgoog elgoog elgoog'],
+      }, {
+        ab: [
+          'Non eram nescius, Brute, cum, quae summis ingeniis ',
+          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+        ],
+      }];
+      setupSampleDiff({content});
+      assert.equal(element._diffLength, 3);
+      assert.isFalse(element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength,
+          element.diff
+      ));
+    });
+
+    test('do not show message if ignore whitespace is disabled', () => {
+      const content = [{
+        a: ['all work and no play make andybons a dull boy'],
+        b: ['elgoog elgoog elgoog'],
+      }, {
+        ab: [
+          'Non eram nescius, Brute, cum, quae summis ingeniis ',
+          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+        ],
+      }];
+      setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
+      assert.isFalse(element.showNoChangeMessage(
+          /* loading= */ false,
+          element.prefs,
+          element._diffLength,
+          element.diff
+      ));
+    });
+  });
+
+  test('getDiffLength', () => {
+    const diff = getMockDiffResponse();
+    assert.equal(element.getDiffLength(diff), 52);
+  });
+
+  test('`render` event has contentRendered field in detail', done => {
+    element = basicFixture.instantiate();
+    element.prefs = {};
+    sinon.stub(element.$.diffBuilder, 'render')
+        .returns(Promise.resolve());
+    element.addEventListener('render', event => {
+      assert.isTrue(event.detail.contentRendered);
+      done();
+    });
+    element._renderDiffTable();
+  });
+
+  test('_prefsEqual', () => {
+    element = basicFixture.instantiate();
+    assert.isTrue(element._prefsEqual(null, null));
+    assert.isTrue(element._prefsEqual({}, {}));
+    assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
+    assert.isTrue(element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
+    const somePref = {abc: 'def', p: true};
+    assert.isTrue(element._prefsEqual(somePref, somePref));
+
+    assert.isFalse(element._prefsEqual({}, null));
+    assert.isFalse(element._prefsEqual(null, {}));
+    assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
+    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
+    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
+    assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index 8bdc1a8..2f9483b 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -14,19 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
 import '../../shared/gr-select/gr-select.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-patch-range-select_html.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
 import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
+import {appContext} from '../../../services/app-context.js';
+import {
+  computeLatestPatchNum, findSortedIndex, getParentIndex,
+  getRevisionByPatchNum,
+  isMergeParent,
+  patchNumEquals, sortRevisions,
+  SPECIAL_PATCH_SET_NUM,
+} from '../../../utils/patch-set-util.js';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -38,13 +42,11 @@
  *
  * @property {string} patchNum
  * @property {string} basePatchNum
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrPatchRangeSelect extends mixinBehaviors( [
-  PatchSetBehavior,
-], GestureEventListeners(
+class GrPatchRangeSelect extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-patch-range-select'; }
@@ -80,6 +82,11 @@
     ];
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   _getShaForPatch(patch) {
     return patch.sha.substring(0, 10);
   }
@@ -93,7 +100,7 @@
       _sortedRevisions,
       changeComments,
       revisionInfo,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return undefined;
     }
 
@@ -108,10 +115,8 @@
       const basePatchNum = basePatch.num;
       const entry = this._createDropdownEntry(basePatchNum, 'Patchset ',
           _sortedRevisions, changeComments, this._getShaForPatch(basePatch));
-      dropdownContent.push(Object.assign({}, entry, {
-        disabled: this._computeLeftDisabled(
-            basePatch.num, patchNum, _sortedRevisions),
-      }));
+      dropdownContent.push({...entry, disabled: this._computeLeftDisabled(
+          basePatch.num, patchNum, _sortedRevisions)});
     }
 
     dropdownContent.push({
@@ -146,7 +151,7 @@
       basePatchNum,
       _sortedRevisions,
       changeComments,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return undefined;
     }
 
@@ -156,10 +161,11 @@
       const entry = this._createDropdownEntry(
           patchNum, patchNum === 'edit' ? '' : 'Patchset ', _sortedRevisions,
           changeComments, this._getShaForPatch(patch));
-      dropdownContent.push(Object.assign({}, entry, {
-        disabled: this._computeRightDisabled(basePatchNum, patchNum,
-            _sortedRevisions),
-      }));
+      dropdownContent.push({
+        ...entry,
+        disabled: this._computeRightDisabled(
+            basePatchNum, patchNum, _sortedRevisions),
+      });
     }
     return dropdownContent;
   }
@@ -190,7 +196,8 @@
 
   _updateSortedRevisions(revisionsRecord) {
     const revisions = revisionsRecord.base;
-    this._sortedRevisions = this.sortRevisions(Object.values(revisions));
+    if (!revisions) return;
+    this._sortedRevisions = sortRevisions(Object.values(revisions));
   }
 
   /**
@@ -203,8 +210,8 @@
    * @param {!Array} sortedRevisions
    */
   _computeLeftDisabled(basePatchNum, patchNum, sortedRevisions) {
-    return this.findSortedIndex(basePatchNum, sortedRevisions) <=
-        this.findSortedIndex(patchNum, sortedRevisions);
+    return findSortedIndex(basePatchNum, sortedRevisions) <=
+        findSortedIndex(patchNum, sortedRevisions);
   }
 
   /**
@@ -215,7 +222,7 @@
    * In addition, if the current basePatchNum is 'PARENT', all patchNums are
    * valid.
    *
-   * If the curent basePatchNum is a parent index, then only patches that have
+   * If the current basePatchNum is a parent index, then only patches that have
    * at least that many parents are valid.
    *
    * @param {number|string} basePatchNum The current selected base patch num.
@@ -224,16 +231,18 @@
    * @return {boolean}
    */
   _computeRightDisabled(basePatchNum, patchNum, sortedRevisions) {
-    if (this.patchNumEquals(basePatchNum, 'PARENT')) { return false; }
-
-    if (this.isMergeParent(basePatchNum)) {
-      // Note: parent indices use 1-offset.
-      return this.revisionInfo.getParentCount(patchNum) <
-          this.getParentIndex(basePatchNum);
+    if (patchNumEquals(basePatchNum, SPECIAL_PATCH_SET_NUM.PARENT)) {
+      return false;
     }
 
-    return this.findSortedIndex(basePatchNum, sortedRevisions) <=
-        this.findSortedIndex(patchNum, sortedRevisions);
+    if (isMergeParent(basePatchNum)) {
+      // Note: parent indices use 1-offset.
+      return this.revisionInfo.getParentCount(patchNum) <
+          getParentIndex(basePatchNum);
+    }
+
+    return findSortedIndex(basePatchNum, sortedRevisions) <=
+        findSortedIndex(patchNum, sortedRevisions);
   }
 
   _computePatchSetCommentsString(changeComments, patchNum) {
@@ -263,7 +272,7 @@
    * @param {boolean=} opt_addFrontSpace
    */
   _computePatchSetDescription(revisions, patchNum, opt_addFrontSpace) {
-    const rev = this.getRevisionByPatchNum(revisions, patchNum);
+    const rev = getRevisionByPatchNum(revisions, patchNum);
     return (rev && rev.description) ?
       (opt_addFrontSpace ? ' ' : '') +
         rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
@@ -274,7 +283,7 @@
    * @param {number|string} patchNum
    */
   _computePatchSetDate(revisions, patchNum) {
-    const rev = this.getRevisionByPatchNum(revisions, patchNum);
+    const rev = getRevisionByPatchNum(revisions, patchNum);
     return rev ? rev.created : undefined;
   }
 
@@ -285,10 +294,27 @@
   _handlePatchChange(e) {
     const detail = {patchNum: this.patchNum, basePatchNum: this.basePatchNum};
     const target = dom(e).localTarget;
-
+    const latestPatchNum = computeLatestPatchNum(this.availablePatches);
     if (target === this.$.patchNumDropdown) {
+      if (detail.patchNum === e.detail.value) return;
+      this.reporting.reportInteraction('right-patchset-changed',
+          {
+            previous: detail.patchNum,
+            current: e.detail.value,
+            latest: latestPatchNum,
+            commentCount: this.changeComments.computeCommentCount(
+                {patchNum: e.detail.value}),
+          });
       detail.patchNum = e.detail.value;
     } else {
+      if (patchNumEquals(detail.basePatchNum, e.detail.value)) return;
+      this.reporting.reportInteraction('left-patchset-changed',
+          {
+            previous: detail.basePatchNum,
+            current: e.detail.value,
+            commentCount: this.changeComments.computeCommentCount(
+                {patchNum: e.detail.value}),
+          });
       detail.basePatchNum = e.detail.value;
     }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
deleted file mode 100644
index 1d4b440..0000000
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.js
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      align-items: center;
-      display: flex;
-    }
-    select {
-      max-width: 15em;
-    }
-    .arrow {
-      color: var(--deemphasized-text-color);
-      margin: 0 var(--spacing-m);
-    }
-    gr-dropdown-list {
-      --trigger-style: {
-        color: var(--deemphasized-text-color);
-        text-transform: none;
-        font-family: var(--font-family);
-      }
-      --trigger-hover-color: rgba(0, 0, 0, 0.6);
-    }
-    @media screen and (max-width: 50em) {
-      .filesWeblinks {
-        display: none;
-      }
-      gr-dropdown-list {
-        --native-select-style: {
-          max-width: 5.25em;
-        }
-        --dropdown-content-stype: {
-          max-width: 300px;
-        }
-      }
-    }
-  </style>
-  <span class="patchRange">
-    <gr-dropdown-list
-      id="basePatchDropdown"
-      value="[[basePatchNum]]"
-      on-value-change="_handlePatchChange"
-      items="[[_baseDropdownContent]]"
-    >
-    </gr-dropdown-list>
-  </span>
-  <span is="dom-if" if="[[filesWeblinks.meta_a]]" class="filesWeblinks">
-    <template is="dom-repeat" items="[[filesWeblinks.meta_a]]" as="weblink">
-      <a target="_blank" rel="noopener" href$="[[weblink.url]]"
-        >[[weblink.name]]</a
-      >
-    </template>
-  </span>
-  <span class="arrow">→</span>
-  <span class="patchRange">
-    <gr-dropdown-list
-      id="patchNumDropdown"
-      value="[[patchNum]]"
-      on-value-change="_handlePatchChange"
-      items="[[_patchDropdownContent]]"
-    >
-    </gr-dropdown-list>
-    <span is="dom-if" if="[[filesWeblinks.meta_b]]" class="filesWeblinks">
-      <template is="dom-repeat" items="[[filesWeblinks.meta_b]]" as="weblink">
-        <a target="_blank" href$="[[weblink.url]]">[[weblink.name]]</a>
-      </template>
-    </span>
-  </span>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
new file mode 100644
index 0000000..415ef4b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      align-items: center;
+      display: flex;
+    }
+    select {
+      max-width: 15em;
+    }
+    .arrow {
+      color: var(--deemphasized-text-color);
+      margin: 0 var(--spacing-m);
+    }
+    gr-dropdown-list {
+      --trigger-style: {
+        color: var(--deemphasized-text-color);
+        text-transform: none;
+        font-family: var(--font-family);
+      }
+      --trigger-hover-color: rgba(0, 0, 0, 0.6);
+    }
+    @media screen and (max-width: 50em) {
+      .filesWeblinks {
+        display: none;
+      }
+      gr-dropdown-list {
+        --native-select-style: {
+          max-width: 5.25em;
+        }
+        --dropdown-content-stype: {
+          max-width: 300px;
+        }
+      }
+    }
+  </style>
+  <span class="patchRange" aria-label="patch range starts with">
+    <gr-dropdown-list
+      id="basePatchDropdown"
+      value="[[basePatchNum]]"
+      on-value-change="_handlePatchChange"
+      items="[[_baseDropdownContent]]"
+    >
+    </gr-dropdown-list>
+  </span>
+  <span is="dom-if" if="[[filesWeblinks.meta_a]]" class="filesWeblinks">
+    <template is="dom-repeat" items="[[filesWeblinks.meta_a]]" as="weblink">
+      <a target="_blank" rel="noopener" href$="[[weblink.url]]"
+        >[[weblink.name]]</a
+      >
+    </template>
+  </span>
+  <span aria-hidden="true" class="arrow">→</span>
+  <span class="patchRange" aria-label="patch range ends with">
+    <gr-dropdown-list
+      id="patchNumDropdown"
+      value="[[patchNum]]"
+      on-value-change="_handlePatchChange"
+      items="[[_patchDropdownContent]]"
+    >
+    </gr-dropdown-list>
+    <span is="dom-if" if="[[filesWeblinks.meta_b]]" class="filesWeblinks">
+      <template is="dom-repeat" items="[[filesWeblinks.meta_b]]" as="weblink">
+        <a target="_blank" href$="[[weblink.url]]">[[weblink.name]]</a>
+      </template>
+    </span>
+  </span>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
deleted file mode 100644
index 63e6fc6..0000000
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ /dev/null
@@ -1,429 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-patch-range-select</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script src="/node_modules/page/page.js"></script>
-
-<dom-module id="comment-api-mock">
-  <template>
-    <gr-patch-range-select id="patchRange" auto
-        change-comments="[[_changeComments]]"></gr-patch-range-select>
-    <gr-comment-api id="commentAPI"></gr-comment-api>
-  </template>
-  </dom-module>
-
-<test-fixture id="basic">
-  <template>
-    <comment-api-mock></comment-api-mock>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../gr-comment-api/gr-comment-api.js';
-import '../../shared/revision-info/revision-info.js';
-import './gr-patch-range-select.js';
-import '../gr-comment-api/gr-comment-api-mock_test.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
-suite('gr-patch-range-select tests', () => {
-  let element;
-  let sandbox;
-  let commentApiWrapper;
-
-  function getInfo(revisions) {
-    const revisionObj = {};
-    for (let i = 0; i < revisions.length; i++) {
-      revisionObj[i] = revisions[i];
-    }
-    return new RevisionInfo({revisions: revisionObj});
-  }
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-
-    stub('gr-rest-api-interface', {
-      getDiffComments() { return Promise.resolve({}); },
-      getDiffRobotComments() { return Promise.resolve({}); },
-      getDiffDrafts() { return Promise.resolve({}); },
-    });
-
-    // Element must be wrapped in an element with direct access to the
-    // comment API.
-    commentApiWrapper = fixture('basic');
-    element = commentApiWrapper.$.patchRange;
-
-    // Stub methods on the changeComments object after changeComments has
-    // been initialized.
-    return commentApiWrapper.loadComments();
-  });
-
-  teardown(() => sandbox.restore());
-
-  test('enabled/disabled options', () => {
-    const patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: '3',
-    };
-    const sortedRevisions = [
-      {_number: 3},
-      {_number: element.EDIT_NAME, basePatchNum: 2},
-      {_number: 2},
-      {_number: 1},
-    ];
-    for (const patchNum of ['1', '2', '3']) {
-      assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum,
-          patchNum, sortedRevisions));
-    }
-    for (const basePatchNum of ['1', '2']) {
-      assert.isFalse(element._computeLeftDisabled(basePatchNum,
-          patchRange.patchNum, sortedRevisions));
-    }
-    assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum));
-
-    patchRange.basePatchNum = element.EDIT_NAME;
-    assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum,
-        sortedRevisions));
-    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '1',
-        sortedRevisions));
-    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '2',
-        sortedRevisions));
-    assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum, '3',
-        sortedRevisions));
-    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum,
-        element.EDIT_NAME, sortedRevisions));
-  });
-
-  test('_computeBaseDropdownContent', () => {
-    const availablePatches = [
-      {num: 'edit', sha: '1'},
-      {num: 3, sha: '2'},
-      {num: 2, sha: '3'},
-      {num: 1, sha: '4'},
-    ];
-    const revisions = [
-      {
-        commit: {parents: []},
-        _number: 2,
-        description: 'description',
-      },
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(revisions);
-    const patchNum = 1;
-    const sortedRevisions = [
-      {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-      {_number: element.EDIT_NAME, basePatchNum: 2},
-      {_number: 2, description: 'description'},
-      {_number: 1},
-    ];
-    const expectedResult = [
-      {
-        disabled: true,
-        triggerText: 'Patchset edit',
-        text: 'Patchset edit | 1',
-        mobileText: 'edit',
-        bottomText: '',
-        value: 'edit',
-      },
-      {
-        disabled: true,
-        triggerText: 'Patchset 3',
-        text: 'Patchset 3 | 2',
-        mobileText: '3',
-        bottomText: '',
-        value: 3,
-        date: 'Mon, 01 Jan 2001 00:00:00 GMT',
-      },
-      {
-        disabled: true,
-        triggerText: 'Patchset 2',
-        text: 'Patchset 2 | 3',
-        mobileText: '2 description',
-        bottomText: 'description',
-        value: 2,
-      },
-      {
-        disabled: true,
-        triggerText: 'Patchset 1',
-        text: 'Patchset 1 | 4',
-        mobileText: '1',
-        bottomText: '',
-        value: 1,
-      },
-      {
-        text: 'Base',
-        value: 'PARENT',
-      },
-    ];
-    assert.deepEqual(element._computeBaseDropdownContent(availablePatches,
-        patchNum, sortedRevisions, element.changeComments,
-        element.revisionInfo),
-    expectedResult);
-  });
-
-  test('_computeBaseDropdownContent called when patchNum updates', () => {
-    element.revisions = [
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(element.revisions);
-    element.availablePatches = [
-      {num: 1, sha: '1'},
-      {num: 2, sha: '2'},
-      {num: 3, sha: '3'},
-      {num: 'edit', sha: '4'},
-    ];
-    element.patchNum = 2;
-    element.basePatchNum = 'PARENT';
-    flushAsynchronousOperations();
-
-    sandbox.stub(element, '_computeBaseDropdownContent');
-
-    // Should be recomputed for each available patch
-    element.set('patchNum', 1);
-    assert.equal(element._computeBaseDropdownContent.callCount, 1);
-  });
-
-  test('_computeBaseDropdownContent called when changeComments update',
-      done => {
-        element.revisions = [
-          {commit: {parents: []}},
-          {commit: {parents: []}},
-          {commit: {parents: []}},
-          {commit: {parents: []}},
-        ];
-        element.revisionInfo = getInfo(element.revisions);
-        element.availablePatches = [
-          {num: 'edit', sha: '1'},
-          {num: 3, sha: '2'},
-          {num: 2, sha: '3'},
-          {num: 1, sha: '4'},
-        ];
-        element.patchNum = 2;
-        element.basePatchNum = 'PARENT';
-        flushAsynchronousOperations();
-
-        // Should be recomputed for each available patch
-        sandbox.stub(element, '_computeBaseDropdownContent');
-        assert.equal(element._computeBaseDropdownContent.callCount, 0);
-        commentApiWrapper.loadComments().then()
-            .then(() => {
-              assert.equal(element._computeBaseDropdownContent.callCount, 1);
-              done();
-            });
-      });
-
-  test('_computePatchDropdownContent called when basePatchNum updates', () => {
-    element.revisions = [
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(element.revisions);
-    element.availablePatches = [
-      {num: 1, sha: '1'},
-      {num: 2, sha: '2'},
-      {num: 3, sha: '3'},
-      {num: 'edit', sha: '4'},
-    ];
-    element.patchNum = 2;
-    element.basePatchNum = 'PARENT';
-    flushAsynchronousOperations();
-
-    // Should be recomputed for each available patch
-    sandbox.stub(element, '_computePatchDropdownContent');
-    element.set('basePatchNum', 1);
-    assert.equal(element._computePatchDropdownContent.callCount, 1);
-  });
-
-  test('_computePatchDropdownContent called when comments update', done => {
-    element.revisions = [
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(element.revisions);
-    element.availablePatches = [
-      {num: 1, sha: '1'},
-      {num: 2, sha: '2'},
-      {num: 3, sha: '3'},
-      {num: 'edit', sha: '4'},
-    ];
-    element.patchNum = 2;
-    element.basePatchNum = 'PARENT';
-    flushAsynchronousOperations();
-
-    // Should be recomputed for each available patch
-    sandbox.stub(element, '_computePatchDropdownContent');
-    assert.equal(element._computePatchDropdownContent.callCount, 0);
-    commentApiWrapper.loadComments().then()
-        .then(() => {
-          done();
-        });
-  });
-
-  test('_computePatchDropdownContent', () => {
-    const availablePatches = [
-      {num: 'edit', sha: '1'},
-      {num: 3, sha: '2'},
-      {num: 2, sha: '3'},
-      {num: 1, sha: '4'},
-    ];
-    const basePatchNum = 1;
-    const sortedRevisions = [
-      {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
-      {_number: element.EDIT_NAME, basePatchNum: 2},
-      {_number: 2, description: 'description'},
-      {_number: 1},
-    ];
-
-    const expectedResult = [
-      {
-        disabled: false,
-        triggerText: 'edit',
-        text: 'edit | 1',
-        mobileText: 'edit',
-        bottomText: '',
-        value: 'edit',
-      },
-      {
-        disabled: false,
-        triggerText: 'Patchset 3',
-        text: 'Patchset 3 | 2',
-        mobileText: '3',
-        bottomText: '',
-        value: 3,
-        date: 'Mon, 01 Jan 2001 00:00:00 GMT',
-      },
-      {
-        disabled: false,
-        triggerText: 'Patchset 2',
-        text: 'Patchset 2 | 3',
-        mobileText: '2 description',
-        bottomText: 'description',
-        value: 2,
-      },
-      {
-        disabled: true,
-        triggerText: 'Patchset 1',
-        text: 'Patchset 1 | 4',
-        mobileText: '1',
-        bottomText: '',
-        value: 1,
-      },
-    ];
-
-    assert.deepEqual(element._computePatchDropdownContent(availablePatches,
-        basePatchNum, sortedRevisions, element.changeComments),
-    expectedResult);
-  });
-
-  test('filesWeblinks', () => {
-    element.filesWeblinks = {
-      meta_a: [
-        {
-          name: 'foo',
-          url: 'f.oo',
-        },
-      ],
-      meta_b: [
-        {
-          name: 'bar',
-          url: 'ba.r',
-        },
-      ],
-    };
-    flushAsynchronousOperations();
-    const domApi = dom(element.root);
-    assert.equal(
-        domApi.querySelector('a[href="f.oo"]').textContent, 'foo');
-    assert.equal(
-        domApi.querySelector('a[href="ba.r"]').textContent, 'bar');
-  });
-
-  test('_computePatchSetCommentsString', () => {
-    // Test string with unresolved comments.
-    element.changeComments._comments = {
-      foo: [{
-        id: '27dcee4d_f7b77cfa',
-        message: 'test',
-        patch_set: 1,
-        unresolved: true,
-        updated: '2017-10-11 20:48:40.000000000',
-      }],
-      bar: [{
-        id: '27dcee4d_f7b77cfa',
-        message: 'test',
-        patch_set: 1,
-        updated: '2017-10-12 20:48:40.000000000',
-      },
-      {
-        id: '27dcee4d_f7b77cfa',
-        message: 'test',
-        patch_set: 1,
-        updated: '2017-10-13 20:48:40.000000000',
-      }],
-      abc: [],
-    };
-
-    assert.equal(element._computePatchSetCommentsString(
-        element.changeComments, 1), ' (3 comments, 1 unresolved)');
-
-    // Test string with no unresolved comments.
-    delete element.changeComments._comments['foo'];
-    assert.equal(element._computePatchSetCommentsString(
-        element.changeComments, 1), ' (2 comments)');
-
-    // Test string with no comments.
-    delete element.changeComments._comments['bar'];
-    assert.equal(element._computePatchSetCommentsString(
-        element.changeComments, 1), '');
-  });
-
-  test('patch-range-change fires', () => {
-    const handler = sandbox.stub();
-    element.basePatchNum = 1;
-    element.patchNum = 3;
-    element.addEventListener('patch-range-change', handler);
-
-    element.$.basePatchDropdown._handleValueChange(2, [{value: 2}]);
-    assert.isTrue(handler.calledOnce);
-    assert.deepEqual(handler.lastCall.args[0].detail,
-        {basePatchNum: 2, patchNum: 3});
-
-    // BasePatchNum should not have changed, due to one-way data binding.
-    element.$.patchNumDropdown._handleValueChange('edit', [{value: 'edit'}]);
-    assert.deepEqual(handler.lastCall.args[0].detail,
-        {basePatchNum: 1, patchNum: 'edit'});
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
new file mode 100644
index 0000000..782ffc5
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
@@ -0,0 +1,412 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-comment-api/gr-comment-api.js';
+import '../../shared/revision-info/revision-info.js';
+import './gr-patch-range-select.js';
+import '../../../test/mocks/comment-api.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
+import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+
+const commentApiMockElement = createCommentApiMockWithTemplateElement(
+    'gr-patch-range-select-comment-api-mock', html`
+    <gr-patch-range-select id="patchRange" auto
+        change-comments="[[_changeComments]]"></gr-patch-range-select>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
+`);
+
+const basicFixture = fixtureFromElement(commentApiMockElement.is);
+
+suite('gr-patch-range-select tests', () => {
+  let element;
+
+  let commentApiWrapper;
+
+  function getInfo(revisions) {
+    const revisionObj = {};
+    for (let i = 0; i < revisions.length; i++) {
+      revisionObj[i] = revisions[i];
+    }
+    return new RevisionInfo({revisions: revisionObj});
+  }
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getDiffComments() { return Promise.resolve({}); },
+      getDiffRobotComments() { return Promise.resolve({}); },
+      getDiffDrafts() { return Promise.resolve({}); },
+    });
+
+    // Element must be wrapped in an element with direct access to the
+    // comment API.
+    commentApiWrapper = basicFixture.instantiate();
+    element = commentApiWrapper.$.patchRange;
+
+    // Stub methods on the changeComments object after changeComments has
+    // been initialized.
+    return commentApiWrapper.loadComments();
+  });
+
+  test('enabled/disabled options', () => {
+    const patchRange = {
+      basePatchNum: 'PARENT',
+      patchNum: '3',
+    };
+    const sortedRevisions = [
+      {_number: 3},
+      {_number: SPECIAL_PATCH_SET_NUM.EDIT, basePatchNum: 2},
+      {_number: 2},
+      {_number: 1},
+    ];
+    for (const patchNum of ['1', '2', '3']) {
+      assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum,
+          patchNum, sortedRevisions));
+    }
+    for (const basePatchNum of ['1', '2']) {
+      assert.isFalse(element._computeLeftDisabled(basePatchNum,
+          patchRange.patchNum, sortedRevisions));
+    }
+    assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum));
+
+    patchRange.basePatchNum = SPECIAL_PATCH_SET_NUM.EDIT;
+    assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum,
+        sortedRevisions));
+    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '1',
+        sortedRevisions));
+    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum, '2',
+        sortedRevisions));
+    assert.isFalse(element._computeRightDisabled(patchRange.basePatchNum, '3',
+        sortedRevisions));
+    assert.isTrue(element._computeRightDisabled(patchRange.basePatchNum,
+        SPECIAL_PATCH_SET_NUM.EDIT, sortedRevisions));
+  });
+
+  test('_computeBaseDropdownContent', () => {
+    const availablePatches = [
+      {num: 'edit', sha: '1'},
+      {num: 3, sha: '2'},
+      {num: 2, sha: '3'},
+      {num: 1, sha: '4'},
+    ];
+    const revisions = [
+      {
+        commit: {parents: []},
+        _number: 2,
+        description: 'description',
+      },
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+    ];
+    element.revisionInfo = getInfo(revisions);
+    const patchNum = 1;
+    const sortedRevisions = [
+      {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
+      {_number: SPECIAL_PATCH_SET_NUM.EDIT, basePatchNum: 2},
+      {_number: 2, description: 'description'},
+      {_number: 1},
+    ];
+    const expectedResult = [
+      {
+        disabled: true,
+        triggerText: 'Patchset edit',
+        text: 'Patchset edit | 1',
+        mobileText: 'edit',
+        bottomText: '',
+        value: 'edit',
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 3',
+        text: 'Patchset 3 | 2',
+        mobileText: '3',
+        bottomText: '',
+        value: 3,
+        date: 'Mon, 01 Jan 2001 00:00:00 GMT',
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 2',
+        text: 'Patchset 2 | 3',
+        mobileText: '2 description',
+        bottomText: 'description',
+        value: 2,
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 1',
+        text: 'Patchset 1 | 4',
+        mobileText: '1',
+        bottomText: '',
+        value: 1,
+      },
+      {
+        text: 'Base',
+        value: 'PARENT',
+      },
+    ];
+    assert.deepEqual(element._computeBaseDropdownContent(availablePatches,
+        patchNum, sortedRevisions, element.changeComments,
+        element.revisionInfo),
+    expectedResult);
+  });
+
+  test('_computeBaseDropdownContent called when patchNum updates', () => {
+    element.revisions = [
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 1, sha: '1'},
+      {num: 2, sha: '2'},
+      {num: 3, sha: '3'},
+      {num: 'edit', sha: '4'},
+    ];
+    element.patchNum = 2;
+    element.basePatchNum = 'PARENT';
+    flushAsynchronousOperations();
+
+    sinon.stub(element, '_computeBaseDropdownContent');
+
+    // Should be recomputed for each available patch
+    element.set('patchNum', 1);
+    assert.equal(element._computeBaseDropdownContent.callCount, 1);
+  });
+
+  test('_computeBaseDropdownContent called when changeComments update',
+      done => {
+        element.revisions = [
+          {commit: {parents: []}},
+          {commit: {parents: []}},
+          {commit: {parents: []}},
+          {commit: {parents: []}},
+        ];
+        element.revisionInfo = getInfo(element.revisions);
+        element.availablePatches = [
+          {num: 'edit', sha: '1'},
+          {num: 3, sha: '2'},
+          {num: 2, sha: '3'},
+          {num: 1, sha: '4'},
+        ];
+        element.patchNum = 2;
+        element.basePatchNum = 'PARENT';
+        flushAsynchronousOperations();
+
+        // Should be recomputed for each available patch
+        sinon.stub(element, '_computeBaseDropdownContent');
+        assert.equal(element._computeBaseDropdownContent.callCount, 0);
+        commentApiWrapper.loadComments().then()
+            .then(() => {
+              assert.equal(element._computeBaseDropdownContent.callCount, 1);
+              done();
+            });
+      });
+
+  test('_computePatchDropdownContent called when basePatchNum updates', () => {
+    element.revisions = [
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 1, sha: '1'},
+      {num: 2, sha: '2'},
+      {num: 3, sha: '3'},
+      {num: 'edit', sha: '4'},
+    ];
+    element.patchNum = 2;
+    element.basePatchNum = 'PARENT';
+    flushAsynchronousOperations();
+
+    // Should be recomputed for each available patch
+    sinon.stub(element, '_computePatchDropdownContent');
+    element.set('basePatchNum', 1);
+    assert.equal(element._computePatchDropdownContent.callCount, 1);
+  });
+
+  test('_computePatchDropdownContent called when comments update', done => {
+    element.revisions = [
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+      {commit: {parents: []}},
+    ];
+    element.revisionInfo = getInfo(element.revisions);
+    element.availablePatches = [
+      {num: 1, sha: '1'},
+      {num: 2, sha: '2'},
+      {num: 3, sha: '3'},
+      {num: 'edit', sha: '4'},
+    ];
+    element.patchNum = 2;
+    element.basePatchNum = 'PARENT';
+    flushAsynchronousOperations();
+
+    // Should be recomputed for each available patch
+    sinon.stub(element, '_computePatchDropdownContent');
+    assert.equal(element._computePatchDropdownContent.callCount, 0);
+    commentApiWrapper.loadComments().then()
+        .then(() => {
+          done();
+        });
+  });
+
+  test('_computePatchDropdownContent', () => {
+    const availablePatches = [
+      {num: 'edit', sha: '1'},
+      {num: 3, sha: '2'},
+      {num: 2, sha: '3'},
+      {num: 1, sha: '4'},
+    ];
+    const basePatchNum = 1;
+    const sortedRevisions = [
+      {_number: 3, created: 'Mon, 01 Jan 2001 00:00:00 GMT'},
+      {_number: SPECIAL_PATCH_SET_NUM.EDIT, basePatchNum: 2},
+      {_number: 2, description: 'description'},
+      {_number: 1},
+    ];
+
+    const expectedResult = [
+      {
+        disabled: false,
+        triggerText: 'edit',
+        text: 'edit | 1',
+        mobileText: 'edit',
+        bottomText: '',
+        value: 'edit',
+      },
+      {
+        disabled: false,
+        triggerText: 'Patchset 3',
+        text: 'Patchset 3 | 2',
+        mobileText: '3',
+        bottomText: '',
+        value: 3,
+        date: 'Mon, 01 Jan 2001 00:00:00 GMT',
+      },
+      {
+        disabled: false,
+        triggerText: 'Patchset 2',
+        text: 'Patchset 2 | 3',
+        mobileText: '2 description',
+        bottomText: 'description',
+        value: 2,
+      },
+      {
+        disabled: true,
+        triggerText: 'Patchset 1',
+        text: 'Patchset 1 | 4',
+        mobileText: '1',
+        bottomText: '',
+        value: 1,
+      },
+    ];
+
+    assert.deepEqual(element._computePatchDropdownContent(availablePatches,
+        basePatchNum, sortedRevisions, element.changeComments),
+    expectedResult);
+  });
+
+  test('filesWeblinks', () => {
+    element.filesWeblinks = {
+      meta_a: [
+        {
+          name: 'foo',
+          url: 'f.oo',
+        },
+      ],
+      meta_b: [
+        {
+          name: 'bar',
+          url: 'ba.r',
+        },
+      ],
+    };
+    flushAsynchronousOperations();
+    const domApi = dom(element.root);
+    assert.equal(
+        domApi.querySelector('a[href="f.oo"]').textContent, 'foo');
+    assert.equal(
+        domApi.querySelector('a[href="ba.r"]').textContent, 'bar');
+  });
+
+  test('_computePatchSetCommentsString', () => {
+    // Test string with unresolved comments.
+    element.changeComments._comments = {
+      foo: [{
+        id: '27dcee4d_f7b77cfa',
+        message: 'test',
+        patch_set: 1,
+        unresolved: true,
+        updated: '2017-10-11 20:48:40.000000000',
+      }],
+      bar: [{
+        id: '27dcee4d_f7b77cfa',
+        message: 'test',
+        patch_set: 1,
+        updated: '2017-10-12 20:48:40.000000000',
+      },
+      {
+        id: '27dcee4d_f7b77cfa',
+        message: 'test',
+        patch_set: 1,
+        updated: '2017-10-13 20:48:40.000000000',
+      }],
+      abc: [],
+    };
+
+    assert.equal(element._computePatchSetCommentsString(
+        element.changeComments, 1), ' (3 comments, 1 unresolved)');
+
+    // Test string with no unresolved comments.
+    delete element.changeComments._comments['foo'];
+    assert.equal(element._computePatchSetCommentsString(
+        element.changeComments, 1), ' (2 comments)');
+
+    // Test string with no comments.
+    delete element.changeComments._comments['bar'];
+    assert.equal(element._computePatchSetCommentsString(
+        element.changeComments, 1), '');
+  });
+
+  test('patch-range-change fires', () => {
+    const handler = sinon.stub();
+    element.basePatchNum = 1;
+    element.patchNum = 3;
+    element.addEventListener('patch-range-change', handler);
+
+    element.$.basePatchDropdown._handleValueChange(2, [{value: 2}]);
+    assert.isTrue(handler.calledOnce);
+    assert.deepEqual(handler.lastCall.args[0].detail,
+        {basePatchNum: 2, patchNum: 3});
+
+    // BasePatchNum should not have changed, due to one-way data binding.
+    element.$.patchNumDropdown._handleValueChange('edit', [{value: 'edit'}]);
+    assert.deepEqual(handler.lastCall.args[0].detail,
+        {basePatchNum: 1, patchNum: 'edit'});
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index c3b6b87..830a91a 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -14,22 +14,21 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-ranged-comment-layer_html.js';
 import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+import {strToClassName} from '../../../utils/dom-util.js';
 
 // Polymer 1 adds # before array's key, while Polymer 2 doesn't
-const HOVER_PATH_PATTERN = /^(commentRanges\.\#?\d+)\.hovering$/;
+const HOVER_PATH_PATTERN = /^(commentRanges\.#?\d+)\.hovering$/;
 
 const RANGE_HIGHLIGHT = 'style-scope gr-diff range';
 const HOVER_HIGHLIGHT = 'style-scope gr-diff rangeHighlight';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrRangedCommentLayer extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -94,7 +93,8 @@
     for (const range of ranges) {
       GrAnnotation.annotateElement(el, range.start,
           range.end - range.start,
-          range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT);
+          (range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT) +
+          ` ${strToClassName(range.rootId)}`);
     }
   }
 
@@ -109,6 +109,10 @@
     this._listeners.push(fn);
   }
 
+  removeListener(fn) {
+    this._listeners = this._listeners.filter(f => f != fn);
+  }
+
   /**
    * Notify Layer listeners of changes to annotations.
    *
@@ -134,11 +138,11 @@
     // If the entire set of comments was changed.
     if (record.path === 'commentRanges') {
       this._rangesMap = {left: {}, right: {}};
-      for (const {side, range, hovering} of record.value) {
+      for (const {side, range, rootId, hovering} of record.value) {
         this._updateRangesMap({
           side, range, hovering,
           operation: (forLine, start, end, hovering) => {
-            forLine.push({start, end, hovering});
+            forLine.push({start, end, hovering, rootId});
           }});
       }
     }
@@ -148,7 +152,7 @@
     if (match) {
       // The #number indicates the key of that item in the array
       // not the index, especially in polymer 1.
-      const {side, range, hovering} = this.get(match[1]);
+      const {side, range, hovering, rootId} = this.get(match[1]);
 
       this._updateRangesMap({
         side, range, hovering, skipLayerUpdate: true,
@@ -156,6 +160,7 @@
           const index = forLine.findIndex(lineRange =>
             lineRange.start === start && lineRange.end === end);
           forLine[index].hovering = hovering;
+          forLine[index].rootId = rootId;
         }});
     }
 
@@ -163,21 +168,22 @@
     if (record.path === 'commentRanges.splices') {
       for (const indexSplice of record.value.indexSplices) {
         const removed = indexSplice.removed;
-        for (const {side, range, hovering} of removed) {
+        for (const {side, range, hovering, rootId} of removed) {
           this._updateRangesMap({
             side, range, hovering, operation: (forLine, start, end) => {
               const index = forLine.findIndex(lineRange =>
-                lineRange.start === start && lineRange.end === end);
+                lineRange.start === start && lineRange.end === end &&
+                rootId === lineRange.rootId);
               forLine.splice(index, 1);
             }});
         }
         const added = indexSplice.object.slice(
             indexSplice.index, indexSplice.index + indexSplice.addedCount);
-        for (const {side, range, hovering} of added) {
+        for (const {side, range, hovering, rootId} of added) {
           this._updateRangesMap({
             side, range, hovering,
             operation: (forLine, start, end, hovering) => {
-              forLine.push({start, end, hovering});
+              forLine.push({start, end, hovering, rootId});
             }});
         }
       }
@@ -217,7 +223,7 @@
         .map(range => {
           // Make a copy, so that the normalization below does not mess with
           // our map.
-          range = Object.assign({}, range);
+          range = {...range};
           range.end = range.end === -1 ? line.text.length : range.end;
 
           // Normalize invalid ranges where the start is after the end but the
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.js
deleted file mode 100644
index 3ed33d1..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts
new file mode 100644
index 0000000..1489006
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
deleted file mode 100644
index 37d1707..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ /dev/null
@@ -1,342 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-ranged-comment-layer</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-ranged-comment-layer></gr-ranged-comment-layer>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../gr-diff/gr-diff-line.js';
-import './gr-ranged-comment-layer.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-
-suite('gr-ranged-comment-layer', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    const initialCommentRanges = [
-      {
-        side: 'left',
-        range: {
-          end_character: 9,
-          end_line: 39,
-          start_character: 6,
-          start_line: 36,
-        },
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 22,
-          end_line: 12,
-          start_character: 10,
-          start_line: 10,
-        },
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 15,
-          end_line: 100,
-          start_character: 5,
-          start_line: 100,
-        },
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 2,
-          end_line: 55,
-          start_character: 32,
-          start_line: 55,
-        },
-      },
-    ];
-
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    element.commentRanges = initialCommentRanges;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('annotate', () => {
-    let sandbox;
-    let el;
-    let line;
-    let annotateElementStub;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
-      el = document.createElement('div');
-      el.setAttribute('data-side', 'left');
-      line = new GrDiffLine(GrDiffLine.Type.BOTH);
-      line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('type=Remove no-comment', () => {
-      line.type = GrDiffLine.Type.REMOVE;
-      line.beforeNumber = 40;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('type=Remove has-comment', () => {
-      line.type = GrDiffLine.Type.REMOVE;
-      line.beforeNumber = 36;
-      const expectedStart = 6;
-      const expectedLength = line.text.length - expectedStart;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      assert.equal(lastCall.args[0], el);
-      assert.equal(lastCall.args[1], expectedStart);
-      assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(lastCall.args[3], 'style-scope gr-diff range');
-    });
-
-    test('type=Remove has-comment hovering', () => {
-      line.type = GrDiffLine.Type.REMOVE;
-      line.beforeNumber = 36;
-      element.set(['commentRanges', 0, 'hovering'], true);
-
-      const expectedStart = 6;
-      const expectedLength = line.text.length - expectedStart;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      assert.equal(lastCall.args[0], el);
-      assert.equal(lastCall.args[1], expectedStart);
-      assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(lastCall.args[3], 'style-scope gr-diff rangeHighlight');
-    });
-
-    test('type=Both has-comment', () => {
-      line.type = GrDiffLine.Type.BOTH;
-      line.beforeNumber = 36;
-
-      const expectedStart = 6;
-      const expectedLength = line.text.length - expectedStart;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      assert.equal(lastCall.args[0], el);
-      assert.equal(lastCall.args[1], expectedStart);
-      assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(lastCall.args[3], 'style-scope gr-diff range');
-    });
-
-    test('type=Both has-comment off side', () => {
-      line.type = GrDiffLine.Type.BOTH;
-      line.beforeNumber = 36;
-      el.setAttribute('data-side', 'right');
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('type=Add has-comment', () => {
-      line.type = GrDiffLine.Type.ADD;
-      line.afterNumber = 12;
-      el.setAttribute('data-side', 'right');
-
-      const expectedStart = 0;
-      const expectedLength = 22;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      assert.equal(lastCall.args[0], el);
-      assert.equal(lastCall.args[1], expectedStart);
-      assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(lastCall.args[3], 'style-scope gr-diff range');
-    });
-  });
-
-  test('_handleCommentRangesChange overwrite', () => {
-    element.set('commentRanges', []);
-
-    assert.equal(Object.keys(element._rangesMap.left).length, 0);
-    assert.equal(Object.keys(element._rangesMap.right).length, 0);
-  });
-
-  test('_handleCommentRangesChange hovering', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-    const updateRangesMapSpy = sandbox.spy(element, '_updateRangesMap');
-
-    element.set(['commentRanges', 1, 'hovering'], true);
-
-    // notify will be skipped for hovering
-    assert.isFalse(notifyStub.called);
-
-    assert.isTrue(updateRangesMapSpy.called);
-  });
-
-  test('_handleCommentRangesChange splice out', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-
-    element.splice('commentRanges', 1, 1);
-
-    assert.isTrue(notifyStub.called);
-    const lastCall = notifyStub.lastCall;
-    assert.equal(lastCall.args[0], 10);
-    assert.equal(lastCall.args[1], 12);
-    assert.equal(lastCall.args[2], 'right');
-  });
-
-  test('_handleCommentRangesChange splice in', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-
-    element.splice('commentRanges', 1, 0, {
-      side: 'left',
-      range: {
-        end_character: 15,
-        end_line: 275,
-        start_character: 5,
-        start_line: 250,
-      },
-    });
-
-    assert.isTrue(notifyStub.called);
-    const lastCall = notifyStub.lastCall;
-    assert.equal(lastCall.args[0], 250);
-    assert.equal(lastCall.args[1], 275);
-    assert.equal(lastCall.args[2], 'left');
-  });
-
-  test('_handleCommentRangesChange mixed actions', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-    const updateRangesMapSpy = sandbox.spy(element, '_updateRangesMap');
-
-    element.set(['commentRanges', 1, 'hovering'], true);
-    assert.isTrue(updateRangesMapSpy.callCount === 1);
-    element.splice('commentRanges', 1, 1);
-    assert.isTrue(updateRangesMapSpy.callCount === 2);
-    element.splice('commentRanges', 1, 1);
-    assert.isTrue(updateRangesMapSpy.callCount === 3);
-    element.splice('commentRanges', 1, 0, {
-      side: 'left',
-      range: {
-        end_character: 15,
-        end_line: 275,
-        start_character: 5,
-        start_line: 250,
-      },
-    });
-    assert.isTrue(updateRangesMapSpy.callCount === 4);
-    element.set(['commentRanges', 2, 'hovering'], true);
-    assert.isTrue(updateRangesMapSpy.callCount === 5);
-  });
-
-  test('_computeCommentMap creates maps correctly', () => {
-    // There is only one ranged comment on the left, but it spans ll.36-39.
-    const leftKeys = [];
-    for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
-    assert.deepEqual(Object.keys(element._rangesMap.left).sort(),
-        leftKeys.sort());
-
-    assert.equal(element._rangesMap.left[36].length, 1);
-    assert.equal(element._rangesMap.left[36][0].start, 6);
-    assert.equal(element._rangesMap.left[36][0].end, -1);
-
-    assert.equal(element._rangesMap.left[37].length, 1);
-    assert.equal(element._rangesMap.left[37][0].start, 0);
-    assert.equal(element._rangesMap.left[37][0].end, -1);
-
-    assert.equal(element._rangesMap.left[38].length, 1);
-    assert.equal(element._rangesMap.left[38][0].start, 0);
-    assert.equal(element._rangesMap.left[38][0].end, -1);
-
-    assert.equal(element._rangesMap.left[39].length, 1);
-    assert.equal(element._rangesMap.left[39][0].start, 0);
-    assert.equal(element._rangesMap.left[39][0].end, 9);
-
-    // The right has two ranged comments, one spanning ll.10-12 and the other
-    // on line 100.
-    const rightKeys = [];
-    for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
-    rightKeys.push('55', '100');
-    assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
-        rightKeys.sort());
-
-    assert.equal(element._rangesMap.right[10].length, 1);
-    assert.equal(element._rangesMap.right[10][0].start, 10);
-    assert.equal(element._rangesMap.right[10][0].end, -1);
-
-    assert.equal(element._rangesMap.right[11].length, 1);
-    assert.equal(element._rangesMap.right[11][0].start, 0);
-    assert.equal(element._rangesMap.right[11][0].end, -1);
-
-    assert.equal(element._rangesMap.right[12].length, 1);
-    assert.equal(element._rangesMap.right[12][0].start, 0);
-    assert.equal(element._rangesMap.right[12][0].end, 22);
-
-    assert.equal(element._rangesMap.right[100].length, 1);
-    assert.equal(element._rangesMap.right[100][0].start, 5);
-    assert.equal(element._rangesMap.right[100][0].end, 15);
-  });
-
-  test('_getRangesForLine normalizes invalid ranges', () => {
-    const line = {
-      afterNumber: 55,
-      text: '_getRangesForLine normalizes invalid ranges',
-    };
-    const ranges = element._getRangesForLine(line, 'right');
-    assert.equal(ranges.length, 1);
-    const range = ranges[0];
-    assert.isTrue(range.start < range.end, 'start and end are normalized');
-    assert.equal(range.end, line.text.length);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
new file mode 100644
index 0000000..5f32677
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
@@ -0,0 +1,321 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-diff/gr-diff-line.js';
+import './gr-ranged-comment-layer.js';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+
+const basicFixture = fixtureFromElement('gr-ranged-comment-layer');
+
+suite('gr-ranged-comment-layer', () => {
+  let element;
+
+  setup(() => {
+    const initialCommentRanges = [
+      {
+        side: 'left',
+        range: {
+          end_character: 9,
+          end_line: 39,
+          start_character: 6,
+          start_line: 36,
+        },
+        rootId: 'a',
+      },
+      {
+        side: 'right',
+        range: {
+          end_character: 22,
+          end_line: 12,
+          start_character: 10,
+          start_line: 10,
+        },
+        rootId: 'b',
+      },
+      {
+        side: 'right',
+        range: {
+          end_character: 15,
+          end_line: 100,
+          start_character: 5,
+          start_line: 100,
+        },
+        rootId: 'c',
+      },
+      {
+        side: 'right',
+        range: {
+          end_character: 2,
+          end_line: 55,
+          start_character: 32,
+          start_line: 55,
+        },
+        rootId: 'd',
+      },
+    ];
+
+    element = basicFixture.instantiate();
+    element.commentRanges = initialCommentRanges;
+  });
+
+  suite('annotate', () => {
+    let el;
+    let line;
+    let annotateElementStub;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      el = document.createElement('div');
+      el.setAttribute('data-side', 'left');
+      line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
+    });
+
+    test('type=Remove no-comment', () => {
+      line.type = GrDiffLine.Type.REMOVE;
+      line.beforeNumber = 40;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('type=Remove has-comment', () => {
+      line.type = GrDiffLine.Type.REMOVE;
+      line.beforeNumber = 36;
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(lastCall.args[3], 'style-scope gr-diff range generated_a');
+    });
+
+    test('type=Remove has-comment hovering', () => {
+      line.type = GrDiffLine.Type.REMOVE;
+      line.beforeNumber = 36;
+      element.set(['commentRanges', 0, 'hovering'], true);
+
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(
+          lastCall.args[3], 'style-scope gr-diff rangeHighlight generated_a'
+      );
+    });
+
+    test('type=Both has-comment', () => {
+      line.type = GrDiffLine.Type.BOTH;
+      line.beforeNumber = 36;
+
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(lastCall.args[3], 'style-scope gr-diff range generated_a');
+    });
+
+    test('type=Both has-comment off side', () => {
+      line.type = GrDiffLine.Type.BOTH;
+      line.beforeNumber = 36;
+      el.setAttribute('data-side', 'right');
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('type=Add has-comment', () => {
+      line.type = GrDiffLine.Type.ADD;
+      line.afterNumber = 12;
+      el.setAttribute('data-side', 'right');
+
+      const expectedStart = 0;
+      const expectedLength = 22;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(lastCall.args[3], 'style-scope gr-diff range generated_b');
+    });
+  });
+
+  test('_handleCommentRangesChange overwrite', () => {
+    element.set('commentRanges', []);
+
+    assert.equal(Object.keys(element._rangesMap.left).length, 0);
+    assert.equal(Object.keys(element._rangesMap.right).length, 0);
+  });
+
+  test('_handleCommentRangesChange hovering', () => {
+    const notifyStub = sinon.stub();
+    element.addListener(notifyStub);
+    const updateRangesMapSpy = sinon.spy(element, '_updateRangesMap');
+
+    element.set(['commentRanges', 1, 'hovering'], true);
+
+    // notify will be skipped for hovering
+    assert.isFalse(notifyStub.called);
+
+    assert.isTrue(updateRangesMapSpy.called);
+  });
+
+  test('_handleCommentRangesChange splice out', () => {
+    const notifyStub = sinon.stub();
+    element.addListener(notifyStub);
+
+    element.splice('commentRanges', 1, 1);
+
+    assert.isTrue(notifyStub.called);
+    const lastCall = notifyStub.lastCall;
+    assert.equal(lastCall.args[0], 10);
+    assert.equal(lastCall.args[1], 12);
+    assert.equal(lastCall.args[2], 'right');
+  });
+
+  test('_handleCommentRangesChange splice in', () => {
+    const notifyStub = sinon.stub();
+    element.addListener(notifyStub);
+
+    element.splice('commentRanges', 1, 0, {
+      side: 'left',
+      range: {
+        end_character: 15,
+        end_line: 275,
+        start_character: 5,
+        start_line: 250,
+      },
+    });
+
+    assert.isTrue(notifyStub.called);
+    const lastCall = notifyStub.lastCall;
+    assert.equal(lastCall.args[0], 250);
+    assert.equal(lastCall.args[1], 275);
+    assert.equal(lastCall.args[2], 'left');
+  });
+
+  test('_handleCommentRangesChange mixed actions', () => {
+    const notifyStub = sinon.stub();
+    element.addListener(notifyStub);
+    const updateRangesMapSpy = sinon.spy(element, '_updateRangesMap');
+
+    element.set(['commentRanges', 1, 'hovering'], true);
+    assert.isTrue(updateRangesMapSpy.callCount === 1);
+    element.splice('commentRanges', 1, 1);
+    assert.isTrue(updateRangesMapSpy.callCount === 2);
+    element.splice('commentRanges', 1, 1);
+    assert.isTrue(updateRangesMapSpy.callCount === 3);
+    element.splice('commentRanges', 1, 0, {
+      side: 'left',
+      range: {
+        end_character: 15,
+        end_line: 275,
+        start_character: 5,
+        start_line: 250,
+      },
+    });
+    assert.isTrue(updateRangesMapSpy.callCount === 4);
+    element.set(['commentRanges', 2, 'hovering'], true);
+    assert.isTrue(updateRangesMapSpy.callCount === 5);
+  });
+
+  test('_computeCommentMap creates maps correctly', () => {
+    // There is only one ranged comment on the left, but it spans ll.36-39.
+    const leftKeys = [];
+    for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
+    assert.deepEqual(Object.keys(element._rangesMap.left).sort(),
+        leftKeys.sort());
+
+    assert.equal(element._rangesMap.left[36].length, 1);
+    assert.equal(element._rangesMap.left[36][0].start, 6);
+    assert.equal(element._rangesMap.left[36][0].end, -1);
+
+    assert.equal(element._rangesMap.left[37].length, 1);
+    assert.equal(element._rangesMap.left[37][0].start, 0);
+    assert.equal(element._rangesMap.left[37][0].end, -1);
+
+    assert.equal(element._rangesMap.left[38].length, 1);
+    assert.equal(element._rangesMap.left[38][0].start, 0);
+    assert.equal(element._rangesMap.left[38][0].end, -1);
+
+    assert.equal(element._rangesMap.left[39].length, 1);
+    assert.equal(element._rangesMap.left[39][0].start, 0);
+    assert.equal(element._rangesMap.left[39][0].end, 9);
+
+    // The right has two ranged comments, one spanning ll.10-12 and the other
+    // on line 100.
+    const rightKeys = [];
+    for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
+    rightKeys.push('55', '100');
+    assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
+        rightKeys.sort());
+
+    assert.equal(element._rangesMap.right[10].length, 1);
+    assert.equal(element._rangesMap.right[10][0].start, 10);
+    assert.equal(element._rangesMap.right[10][0].end, -1);
+
+    assert.equal(element._rangesMap.right[11].length, 1);
+    assert.equal(element._rangesMap.right[11][0].start, 0);
+    assert.equal(element._rangesMap.right[11][0].end, -1);
+
+    assert.equal(element._rangesMap.right[12].length, 1);
+    assert.equal(element._rangesMap.right[12][0].start, 0);
+    assert.equal(element._rangesMap.right[12][0].end, 22);
+
+    assert.equal(element._rangesMap.right[100].length, 1);
+    assert.equal(element._rangesMap.right[100][0].start, 5);
+    assert.equal(element._rangesMap.right[100][0].end, 15);
+  });
+
+  test('_getRangesForLine normalizes invalid ranges', () => {
+    const line = {
+      afterNumber: 55,
+      text: '_getRangesForLine normalizes invalid ranges',
+    };
+    const ranges = element._getRangesForLine(line, 'right');
+    assert.equal(ranges.length, 1);
+    const range = ranges[0];
+    assert.isTrue(range.start < range.end, 'start and end are normalized');
+    assert.equal(range.end, line.text.length);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.js
deleted file mode 100644
index 49ed980..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-ranged-comment-theme">
-  <template>
-    <style>
-      .range {
-        background-color: var(--diff-highlight-range-color);
-        display: inline;
-      }
-      .rangeHighlight {
-        background-color: var(--diff-highlight-range-hover-color);
-        display: inline;
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
new file mode 100644
index 0000000..70ee196
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-ranged-comment-theme">
+  <template>
+    <style>
+      .range {
+        background-color: var(--diff-highlight-range-color);
+        display: inline;
+      }
+      .rangeHighlight {
+        background-color: var(--diff-highlight-range-hover-color);
+        display: inline;
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
deleted file mode 100644
index f16db3b..0000000
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-tooltip/gr-tooltip.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-selection-action-box_html.js';
-
-/**
- * @extends Polymer.Element
- */
-class GrSelectionActionBox extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-selection-action-box'; }
-  /**
-   * Fired when the comment creation action was taken (click).
-   *
-   * @event create-comment-requested
-   */
-
-  static get properties() {
-    return {
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      positionBelow: Boolean,
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-
-    // See https://crbug.com/gerrit/4767
-    this.addEventListener('mousedown',
-        e => this._handleMouseDown(e));
-  }
-
-  placeAbove(el) {
-    flush();
-    const rect = this._getTargetBoundingRect(el);
-    const boxRect = this.$.tooltip.getBoundingClientRect();
-    const parentRect = this._getParentBoundingClientRect();
-    this.style.top =
-        rect.top - parentRect.top - boxRect.height - 6 + 'px';
-    this.style.left =
-        rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
-  }
-
-  placeBelow(el) {
-    flush();
-    const rect = this._getTargetBoundingRect(el);
-    const boxRect = this.$.tooltip.getBoundingClientRect();
-    const parentRect = this._getParentBoundingClientRect();
-    this.style.top =
-    rect.top - parentRect.top + boxRect.height - 6 + 'px';
-    this.style.left =
-    rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
-  }
-
-  _getParentBoundingClientRect() {
-    // With native shadow DOM, the parent is the shadow root, not the gr-diff
-    // element
-    const parent = this.parentElement || this.parentNode.host;
-    return parent.getBoundingClientRect();
-  }
-
-  _getTargetBoundingRect(el) {
-    let rect;
-    if (el instanceof Text) {
-      const range = document.createRange();
-      range.selectNode(el);
-      rect = range.getBoundingClientRect();
-      range.detach();
-    } else {
-      rect = el.getBoundingClientRect();
-    }
-    return rect;
-  }
-
-  _handleMouseDown(e) {
-    if (e.button !== 0) { return; } // 0 = main button
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('create-comment-requested', {
-      composed: true, bubbles: true,
-    }));
-  }
-}
-
-customElements.define(GrSelectionActionBox.is, GrSelectionActionBox);
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
new file mode 100644
index 0000000..884379c
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -0,0 +1,132 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import {GrTooltip} from '../../shared/gr-tooltip/gr-tooltip';
+import {customElement, property} from '@polymer/decorators';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-selection-action-box_html';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-selection-action-box': GrSelectionActionBox;
+  }
+}
+
+export interface GrSelectionActionBox {
+  $: {
+    tooltip: GrTooltip;
+  };
+}
+
+@customElement('gr-selection-action-box')
+export class GrSelectionActionBox extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the comment creation action was taken (click).
+   *
+   * @event create-comment-requested
+   */
+
+  @property({type: Object})
+  keyEventTarget: Record<string, any> = document.body;
+
+  @property({type: Boolean})
+  positionBelow = false;
+
+  /** @override */
+  created() {
+    super.created();
+
+    // See https://crbug.com/gerrit/4767
+    this.addEventListener('mousedown', e => this._handleMouseDown(e));
+  }
+
+  placeAbove(el: Text | HTMLElement) {
+    flush();
+    const rect = this._getTargetBoundingRect(el);
+    const boxRect = this.$.tooltip.getBoundingClientRect();
+    const parentRect = this._getParentBoundingClientRect();
+    if (parentRect === null) {
+      return;
+    }
+    this.style.top = `${rect.top - parentRect.top - boxRect.height - 6}px`;
+    this.style.left = `${
+      rect.left - parentRect.left + (rect.width - boxRect.width) / 2
+    }px`;
+  }
+
+  placeBelow(el: Text | HTMLElement) {
+    flush();
+    const rect = this._getTargetBoundingRect(el);
+    const boxRect = this.$.tooltip.getBoundingClientRect();
+    const parentRect = this._getParentBoundingClientRect();
+    if (parentRect === null) {
+      return;
+    }
+    this.style.top = `${rect.top - parentRect.top + boxRect.height - 6}px`;
+    this.style.left = `${
+      rect.left - parentRect.left + (rect.width - boxRect.width) / 2
+    }px`;
+  }
+
+  private _getParentBoundingClientRect() {
+    // With native shadow DOM, the parent is the shadow root, not the gr-diff
+    // element
+    if (this.parentElement) {
+      return this.parentElement.getBoundingClientRect();
+    }
+    if (this.parentNode !== null) {
+      return (this.parentNode as ShadowRoot).host.getBoundingClientRect();
+    }
+    return null;
+  }
+
+  private _getTargetBoundingRect(el: Text | HTMLElement) {
+    let rect;
+    if (el instanceof Text) {
+      const range = document.createRange();
+      range.selectNode(el);
+      rect = range.getBoundingClientRect();
+      range.detach();
+    } else {
+      rect = el.getBoundingClientRect();
+    }
+    return rect;
+  }
+
+  private _handleMouseDown(e: MouseEvent) {
+    if (e.button !== 0) {
+      return;
+    } // 0 = main button
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('create-comment-requested', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.js
deleted file mode 100644
index e7795b9..0000000
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.js
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      cursor: pointer;
-      font-family: var(--font-family);
-      position: absolute;
-      white-space: nowrap;
-    }
-  </style>
-  <gr-tooltip
-    id="tooltip"
-    text="Press c to comment"
-    position-below="[[positionBelow]]"
-  ></gr-tooltip>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts
new file mode 100644
index 0000000..24d63b3
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      cursor: pointer;
+      font-family: var(--font-family);
+      position: absolute;
+      white-space: nowrap;
+    }
+  </style>
+  <gr-tooltip
+    id="tooltip"
+    text="Press c to comment"
+    position-below="[[positionBelow]]"
+  ></gr-tooltip>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
deleted file mode 100644
index ff6fba7..0000000
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ /dev/null
@@ -1,135 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-selection-action-box</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <div>
-      <gr-selection-action-box></gr-selection-action-box>
-      <div class="target">some text</div>
-    </div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-selection-action-box.js';
-suite('gr-selection-action-box', () => {
-  let container;
-  let element;
-  let sandbox;
-
-  setup(() => {
-    container = fixture('basic');
-    element = container.querySelector('gr-selection-action-box');
-    sandbox = sinon.sandbox.create();
-    sandbox.stub(element, 'dispatchEvent');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('ignores regular keys', () => {
-    MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
-    assert.isFalse(element.dispatchEvent.called);
-  });
-
-  suite('mousedown reacts only to main button', () => {
-    let e;
-
-    setup(() => {
-      e = {
-        button: 0,
-        preventDefault: sandbox.stub(),
-        stopPropagation: sandbox.stub(),
-      };
-    });
-
-    test('event handled if main button', () => {
-      element._handleMouseDown(e);
-      assert.isTrue(e.preventDefault.called);
-      assert.equal(
-          element.dispatchEvent.lastCall.args[0].type,
-          'create-comment-requested'
-      );
-    });
-
-    test('event ignored if not main button', () => {
-      e.button = 1;
-      element._handleMouseDown(e);
-      assert.isFalse(e.preventDefault.called);
-      assert.isFalse(element.dispatchEvent.called);
-    });
-  });
-
-  suite('placeAbove', () => {
-    let target;
-
-    setup(() => {
-      target = container.querySelector('.target');
-      sandbox.stub(container, 'getBoundingClientRect').returns(
-          {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
-      sandbox.stub(element, '_getTargetBoundingRect').returns(
-          {top: 42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
-      sandbox.stub(element.$.tooltip, 'getBoundingClientRect').returns(
-          {width: 10, height: 10});
-    });
-
-    test('placeAbove for Element argument', () => {
-      element.placeAbove(target);
-      assert.equal(element.style.top, '25px');
-      assert.equal(element.style.left, '72px');
-    });
-
-    test('placeAbove for Text Node argument', () => {
-      element.placeAbove(target.firstChild);
-      assert.equal(element.style.top, '25px');
-      assert.equal(element.style.left, '72px');
-    });
-
-    test('placeBelow for Element argument', () => {
-      element.placeBelow(target);
-      assert.equal(element.style.top, '45px');
-      assert.equal(element.style.left, '72px');
-    });
-
-    test('placeBelow for Text Node argument', () => {
-      element.placeBelow(target.firstChild);
-      assert.equal(element.style.top, '45px');
-      assert.equal(element.style.left, '72px');
-    });
-
-    test('uses document.createRange', () => {
-      sandbox.spy(document, 'createRange');
-      element._getTargetBoundingRect.restore();
-      sandbox.spy(element, '_getTargetBoundingRect');
-      element.placeAbove(target.firstChild);
-      assert.isTrue(document.createRange.called);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js
new file mode 100644
index 0000000..81cf0d6
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js
@@ -0,0 +1,119 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-selection-action-box.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+  <div>
+    <gr-selection-action-box></gr-selection-action-box>
+    <div class="target">some text</div>
+  </div>
+`);
+
+suite('gr-selection-action-box', () => {
+  let container;
+  let element;
+
+  setup(() => {
+    container = basicFixture.instantiate();
+    element = container.querySelector('gr-selection-action-box');
+
+    sinon.stub(element, 'dispatchEvent');
+  });
+
+  test('ignores regular keys', () => {
+    MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
+    assert.isFalse(element.dispatchEvent.called);
+  });
+
+  suite('mousedown reacts only to main button', () => {
+    let e;
+
+    setup(() => {
+      e = {
+        button: 0,
+        preventDefault: sinon.stub(),
+        stopPropagation: sinon.stub(),
+      };
+    });
+
+    test('event handled if main button', () => {
+      element._handleMouseDown(e);
+      assert.isTrue(e.preventDefault.called);
+      assert.equal(
+          element.dispatchEvent.lastCall.args[0].type,
+          'create-comment-requested'
+      );
+    });
+
+    test('event ignored if not main button', () => {
+      e.button = 1;
+      element._handleMouseDown(e);
+      assert.isFalse(e.preventDefault.called);
+      assert.isFalse(element.dispatchEvent.called);
+    });
+  });
+
+  suite('placeAbove', () => {
+    let target;
+
+    setup(() => {
+      target = container.querySelector('.target');
+      sinon.stub(container, 'getBoundingClientRect').returns(
+          {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
+      sinon.stub(element, '_getTargetBoundingRect').returns(
+          {top: 42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
+      sinon.stub(element.$.tooltip, 'getBoundingClientRect').returns(
+          {width: 10, height: 10});
+    });
+
+    test('placeAbove for Element argument', () => {
+      element.placeAbove(target);
+      assert.equal(element.style.top, '25px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('placeAbove for Text Node argument', () => {
+      element.placeAbove(target.firstChild);
+      assert.equal(element.style.top, '25px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('placeBelow for Element argument', () => {
+      element.placeBelow(target);
+      assert.equal(element.style.top, '45px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('placeBelow for Text Node argument', () => {
+      element.placeBelow(target.firstChild);
+      assert.equal(element.style.top, '45px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('uses document.createRange', () => {
+      sinon.spy(document, 'createRange');
+      element._getTargetBoundingRect.restore();
+      sinon.spy(element, '_getTargetBoundingRect');
+      element.placeAbove(target.firstChild);
+      assert.isTrue(document.createRange.called);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index f1e930f..6399c4a 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-lib-loader/gr-lib-loader.js';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
@@ -104,7 +102,7 @@
 };
 const ASYNC_DELAY = 10;
 
-const CLASS_WHITELIST = {
+const CLASS_SAFELIST = {
   'gr-diff gr-syntax gr-syntax-attr': true,
   'gr-diff gr-syntax gr-syntax-attribute': true,
   'gr-diff gr-syntax gr-syntax-built_in': true,
@@ -135,12 +133,12 @@
 };
 
 const CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
-const CPP_WCHAR_PATTERN = /L\'(\\)?.\'/g;
+const CPP_WCHAR_PATTERN = /L'(\\)?.'/g;
 const JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
 const GO_BACKSLASH_LITERAL = '\'\\\\\'';
 const GLOBAL_LT_PATTERN = /</g;
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrSyntaxLayer extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -251,7 +249,7 @@
   process() {
     // Cancel any still running process() calls, because they append to the
     // same _baseRanges and _revisionRanges fields.
-    this._cancel();
+    this.cancel();
 
     // Discard existing ranges.
     this._baseRanges = [];
@@ -321,7 +319,7 @@
   /**
    * Cancel any asynchronous syntax processing jobs.
    */
-  _cancel() {
+  cancel() {
     if (this._processHandle != null) {
       this.cancelAsync(this._processHandle);
       this._processHandle = null;
@@ -332,7 +330,7 @@
   }
 
   _diffChanged() {
-    this._cancel();
+    this.cancel();
     this._baseRanges = [];
     this._revisionRanges = [];
   }
@@ -367,7 +365,7 @@
       // Note: HLJS may emit a span with class undefined when it thinks there
       // may be a syntax error.
       if (node.tagName === 'SPAN' && node.className !== 'undefined') {
-        if (CLASS_WHITELIST.hasOwnProperty(node.className)) {
+        if (CLASS_SAFELIST.hasOwnProperty(node.className)) {
           result.push({
             start: offset,
             length: nodeLength,
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.js
deleted file mode 100644
index 433f814..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
new file mode 100644
index 0000000..ac59f4f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_html.ts
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <gr-lib-loader id="libLoader"></gr-lib-loader>
+`;
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
deleted file mode 100644
index ccdbe8b..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
+++ /dev/null
@@ -1,503 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-syntax-layer</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-syntax-layer></gr-syntax-layer>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import {getMockDiffResponse} from '../../../test/mock-diff-response.js';
-import './gr-syntax-layer.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-
-suite('gr-syntax-layer tests', () => {
-  let sandbox;
-  let diff;
-  let element;
-  const lineNumberEl = document.createElement('td');
-
-  function getMockHLJS() {
-    const html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
-        'ipsum</span>';
-    return {
-      configure() {},
-      highlight(lang, line, ignore, state) {
-        return {
-          value: line.replace(/ipsum/, html),
-          top: state === undefined ? 1 : state + 1,
-        };
-      },
-      // Return something truthy because this method is used to check if the
-      // language is supported.
-      getLanguage(s) {
-        return {};
-      },
-    };
-  }
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    diff = getMockDiffResponse();
-    element.diff = diff;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('annotate without range does nothing', () => {
-    const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-    const el = document.createElement('div');
-    el.textContent = 'Etiam dui, blandit wisi.';
-    const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
-    line.beforeNumber = 12;
-
-    element.annotate(el, lineNumberEl, line);
-
-    assert.isFalse(annotationSpy.called);
-  });
-
-  test('annotate with range applies it', () => {
-    const str = 'Etiam dui, blandit wisi.';
-    const start = 6;
-    const length = 3;
-    const className = 'foobar';
-
-    const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-    const el = document.createElement('div');
-    el.textContent = str;
-    const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
-    line.beforeNumber = 12;
-    element._baseRanges[11] = [{
-      start,
-      length,
-      className,
-    }];
-
-    element.annotate(el, lineNumberEl, line);
-
-    assert.isTrue(annotationSpy.called);
-    assert.equal(annotationSpy.lastCall.args[0], el);
-    assert.equal(annotationSpy.lastCall.args[1], start);
-    assert.equal(annotationSpy.lastCall.args[2], length);
-    assert.equal(annotationSpy.lastCall.args[3], className);
-    assert.isOk(el.querySelector('hl.' + className));
-  });
-
-  test('annotate with range but disabled does nothing', () => {
-    const str = 'Etiam dui, blandit wisi.';
-    const start = 6;
-    const length = 3;
-    const className = 'foobar';
-
-    const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-    const el = document.createElement('div');
-    el.textContent = str;
-    const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
-    line.beforeNumber = 12;
-    element._baseRanges[11] = [{
-      start,
-      length,
-      className,
-    }];
-    element.enabled = false;
-
-    element.annotate(el, lineNumberEl, line);
-
-    assert.isFalse(annotationSpy.called);
-  });
-
-  test('process on empty diff does nothing', done => {
-    element.diff = {
-      meta_a: {content_type: 'application/json'},
-      meta_b: {content_type: 'application/json'},
-      content: [],
-    };
-    const processNextSpy = sandbox.spy(element, '_processNextLine');
-
-    const processPromise = element.process();
-
-    processPromise.then(() => {
-      assert.isFalse(processNextSpy.called);
-      assert.equal(element._baseRanges.length, 0);
-      assert.equal(element._revisionRanges.length, 0);
-      done();
-    });
-  });
-
-  test('process for unsupported languages does nothing', done => {
-    element.diff = {
-      meta_a: {content_type: 'text/x+objective-cobol-plus-plus'},
-      meta_b: {content_type: 'application/not-a-real-language'},
-      content: [],
-    };
-    const processNextSpy = sandbox.spy(element, '_processNextLine');
-
-    const processPromise = element.process();
-
-    processPromise.then(() => {
-      assert.isFalse(processNextSpy.called);
-      assert.equal(element._baseRanges.length, 0);
-      assert.equal(element._revisionRanges.length, 0);
-      done();
-    });
-  });
-
-  test('process while disabled does nothing', done => {
-    const processNextSpy = sandbox.spy(element, '_processNextLine');
-    element.enabled = false;
-    const loadHLJSSpy = sandbox.spy(element, '_loadHLJS');
-
-    const processPromise = element.process();
-
-    processPromise.then(() => {
-      assert.isFalse(processNextSpy.called);
-      assert.equal(element._baseRanges.length, 0);
-      assert.equal(element._revisionRanges.length, 0);
-      assert.isFalse(loadHLJSSpy.called);
-      done();
-    });
-  });
-
-  test('process highlight ipsum', done => {
-    element.diff.meta_a.content_type = 'application/json';
-    element.diff.meta_b.content_type = 'application/json';
-
-    const mockHLJS = getMockHLJS();
-    const highlightSpy = sinon.spy(mockHLJS, 'highlight');
-    sandbox.stub(element.$.libLoader, 'getHLJS',
-        () => Promise.resolve(mockHLJS));
-    const processNextSpy = sandbox.spy(element, '_processNextLine');
-    const processPromise = element.process();
-
-    processPromise.then(() => {
-      const linesA = diff.meta_a.lines;
-      const linesB = diff.meta_b.lines;
-
-      assert.isTrue(processNextSpy.called);
-      assert.equal(element._baseRanges.length, linesA);
-      assert.equal(element._revisionRanges.length, linesB);
-
-      assert.equal(highlightSpy.callCount, linesA + linesB);
-
-      // The first line of both sides have a range.
-      let ranges = [element._baseRanges[0], element._revisionRanges[0]];
-      for (const range of ranges) {
-        assert.equal(range.length, 1);
-        assert.equal(range[0].className,
-            'gr-diff gr-syntax gr-syntax-string');
-        assert.equal(range[0].start, 'lorem '.length);
-        assert.equal(range[0].length, 'ipsum'.length);
-      }
-
-      // There are no ranges from ll.1-12 on the left and ll.1-11 on the
-      // right.
-      ranges = element._baseRanges.slice(1, 12)
-          .concat(element._revisionRanges.slice(1, 11));
-
-      for (const range of ranges) {
-        assert.equal(range.length, 0);
-      }
-
-      // There should be another pair of ranges on l.13 for the left and
-      // l.12 for the right.
-      ranges = [element._baseRanges[13], element._revisionRanges[12]];
-
-      for (const range of ranges) {
-        assert.equal(range.length, 1);
-        assert.equal(range[0].className,
-            'gr-diff gr-syntax gr-syntax-string');
-        assert.equal(range[0].start, 32);
-        assert.equal(range[0].length, 'ipsum'.length);
-      }
-
-      // The next group should have a similar instance on either side.
-
-      let range = element._baseRanges[15];
-      assert.equal(range.length, 1);
-      assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
-      assert.equal(range[0].start, 34);
-      assert.equal(range[0].length, 'ipsum'.length);
-
-      range = element._revisionRanges[14];
-      assert.equal(range.length, 1);
-      assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
-      assert.equal(range[0].start, 35);
-      assert.equal(range[0].length, 'ipsum'.length);
-
-      done();
-    });
-  });
-
-  test('_diffChanged calls cancel', () => {
-    const cancelSpy = sandbox.spy(element, '_diffChanged');
-    element.diff = {content: []};
-    assert.isTrue(cancelSpy.called);
-  });
-
-  test('_rangesFromElement no ranges', () => {
-    const elem = document.createElement('span');
-    elem.textContent = 'Etiam dui, blandit wisi.';
-    const offset = 100;
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 0);
-  });
-
-  test('_rangesFromElement single range', () => {
-    const str0 = 'Etiam ';
-    const str1 = 'dui, blandit';
-    const str2 = ' wisi.';
-    const className = 'gr-diff gr-syntax gr-syntax-string';
-    const offset = 100;
-
-    const elem = document.createElement('span');
-    elem.appendChild(document.createTextNode(str0));
-    const span = document.createElement('span');
-    span.textContent = str1;
-    span.className = className;
-    elem.appendChild(span);
-    elem.appendChild(document.createTextNode(str2));
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 1);
-    assert.equal(result[0].start, str0.length + offset);
-    assert.equal(result[0].length, str1.length);
-    assert.equal(result[0].className, className);
-  });
-
-  test('_rangesFromElement non-whitelist', () => {
-    const str0 = 'Etiam ';
-    const str1 = 'dui, blandit';
-    const str2 = ' wisi.';
-    const className = 'not-in-the-whitelist';
-    const offset = 100;
-
-    const elem = document.createElement('span');
-    elem.appendChild(document.createTextNode(str0));
-    const span = document.createElement('span');
-    span.textContent = str1;
-    span.className = className;
-    elem.appendChild(span);
-    elem.appendChild(document.createTextNode(str2));
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 0);
-  });
-
-  test('_rangesFromElement milti range', () => {
-    const str0 = 'Etiam ';
-    const str1 = 'dui,';
-    const str2 = ' blandit';
-    const str3 = ' wisi.';
-    const className = 'gr-diff gr-syntax gr-syntax-string';
-    const offset = 100;
-
-    const elem = document.createElement('span');
-    elem.appendChild(document.createTextNode(str0));
-    let span = document.createElement('span');
-    span.textContent = str1;
-    span.className = className;
-    elem.appendChild(span);
-    elem.appendChild(document.createTextNode(str2));
-    span = document.createElement('span');
-    span.textContent = str3;
-    span.className = className;
-    elem.appendChild(span);
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 2);
-
-    assert.equal(result[0].start, str0.length + offset);
-    assert.equal(result[0].length, str1.length);
-    assert.equal(result[0].className, className);
-
-    assert.equal(result[1].start,
-        str0.length + str1.length + str2.length + offset);
-    assert.equal(result[1].length, str3.length);
-    assert.equal(result[1].className, className);
-  });
-
-  test('_rangesFromElement nested range', () => {
-    const str0 = 'Etiam ';
-    const str1 = 'dui,';
-    const str2 = ' blandit';
-    const str3 = ' wisi.';
-    const className = 'gr-diff gr-syntax gr-syntax-string';
-    const offset = 100;
-
-    const elem = document.createElement('span');
-    elem.appendChild(document.createTextNode(str0));
-    const span1 = document.createElement('span');
-    span1.textContent = str1;
-    span1.className = className;
-    elem.appendChild(span1);
-    const span2 = document.createElement('span');
-    span2.textContent = str2;
-    span2.className = className;
-    span1.appendChild(span2);
-    elem.appendChild(document.createTextNode(str3));
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 2);
-
-    assert.equal(result[0].start, str0.length + offset);
-    assert.equal(result[0].length, str1.length + str2.length);
-    assert.equal(result[0].className, className);
-
-    assert.equal(result[1].start, str0.length + str1.length + offset);
-    assert.equal(result[1].length, str2.length);
-    assert.equal(result[1].className, className);
-  });
-
-  test('_rangesFromString whitelist allows recursion', () => {
-    const str = [
-      '<span class="non-whtelisted-class">',
-      '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
-      '</span>'].join('');
-    const result = element._rangesFromString(str, new Map());
-    assert.notEqual(result.length, 0);
-  });
-
-  test('_rangesFromString cache same syntax markers', () => {
-    sandbox.spy(element, '_rangesFromElement');
-    const str =
-      '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>';
-    const cacheMap = new Map();
-    element._rangesFromString(str, cacheMap);
-    element._rangesFromString(str, cacheMap);
-    assert.isTrue(element._rangesFromElement.calledOnce);
-  });
-
-  test('_isSectionDone', () => {
-    let state = {sectionIndex: 0, lineIndex: 0};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 0, lineIndex: 2};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 0, lineIndex: 4};
-    assert.isTrue(element._isSectionDone(state));
-
-    state = {sectionIndex: 1, lineIndex: 2};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 1, lineIndex: 3};
-    assert.isTrue(element._isSectionDone(state));
-
-    state = {sectionIndex: 3, lineIndex: 0};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 3, lineIndex: 3};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 3, lineIndex: 4};
-    assert.isTrue(element._isSectionDone(state));
-  });
-
-  test('workaround CPP LT directive', () => {
-    // Does nothing to regular line.
-    let line = 'int main(int argc, char** argv) { return 0; }';
-    assert.equal(element._workaround('cpp', line), line);
-
-    // Does nothing to include directive.
-    line = '#include <stdio>';
-    assert.equal(element._workaround('cpp', line), line);
-
-    // Converts left-shift operator in #define.
-    line = '#define GiB (1ull << 30)';
-    let expected = '#define GiB (1ull || 30)';
-    assert.equal(element._workaround('cpp', line), expected);
-
-    // Converts less-than operator in #if.
-    line = '  #if __GNUC__ < 4 || (__GNUC__ == 4 && __GNUC_MINOR__ < 1)';
-    expected = '  #if __GNUC__ | 4 || (__GNUC__ == 4 && __GNUC_MINOR__ | 1)';
-    assert.equal(element._workaround('cpp', line), expected);
-  });
-
-  test('workaround Java param-annotation', () => {
-    // Does nothing to regular line.
-    let line = 'public static void foo(int bar) { }';
-    assert.equal(element._workaround('java', line), line);
-
-    // Does nothing to regular annotation.
-    line = 'public static void foo(@Nullable int bar) { }';
-    assert.equal(element._workaround('java', line), line);
-
-    // Converts parameterized annotation.
-    line = 'public static void foo(@SuppressWarnings("unused") int bar) { }';
-    const expected = 'public static void foo(@SuppressWarnings "unused" ' +
-        ' int bar) { }';
-    assert.equal(element._workaround('java', line), expected);
-  });
-
-  test('workaround CPP whcar_t character literals', () => {
-    // Does nothing to regular line.
-    let line = 'int main(int argc, char** argv) { return 0; }';
-    assert.equal(element._workaround('cpp', line), line);
-
-    // Does nothing to wchar_t string.
-    line = 'wchar_t* sz = L"abc 123";';
-    assert.equal(element._workaround('cpp', line), line);
-
-    // Converts wchar_t character literal to string.
-    line = 'wchar_t myChar = L\'#\'';
-    let expected = 'wchar_t myChar = L"."';
-    assert.equal(element._workaround('cpp', line), expected);
-
-    // Converts wchar_t character literal with escape sequence to string.
-    line = 'wchar_t myChar = L\'\\"\'';
-    expected = 'wchar_t myChar = L"\\."';
-    assert.equal(element._workaround('cpp', line), expected);
-  });
-
-  test('workaround go backslash character literals', () => {
-    // Does nothing to regular line.
-    let line = 'func foo(in []byte) (lit []byte, n int, err error) {';
-    assert.equal(element._workaround('go', line), line);
-
-    // Does nothing to string with backslash literal
-    line = 'c := "\\\\"';
-    assert.equal(element._workaround('go', line), line);
-
-    // Converts backslash literal character to a string.
-    line = 'c := \'\\\\\'';
-    const expected = 'c := "\\\\"';
-    assert.equal(element._workaround('go', line), expected);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
new file mode 100644
index 0000000..03acfb5
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
@@ -0,0 +1,482 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
+import './gr-syntax-layer.js';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
+import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+
+const basicFixture = fixtureFromElement('gr-syntax-layer');
+
+suite('gr-syntax-layer tests', () => {
+  let diff;
+  let element;
+  const lineNumberEl = document.createElement('td');
+
+  function getMockHLJS() {
+    const html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
+        'ipsum</span>';
+    return {
+      configure() {},
+      highlight(lang, line, ignore, state) {
+        return {
+          value: line.replace(/ipsum/, html),
+          top: state === undefined ? 1 : state + 1,
+        };
+      },
+      // Return something truthy because this method is used to check if the
+      // language is supported.
+      getLanguage(s) {
+        return {};
+      },
+    };
+  }
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    diff = getMockDiffResponse();
+    element.diff = diff;
+  });
+
+  test('annotate without range does nothing', () => {
+    const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
+    const el = document.createElement('div');
+    el.textContent = 'Etiam dui, blandit wisi.';
+    const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+    line.beforeNumber = 12;
+
+    element.annotate(el, lineNumberEl, line);
+
+    assert.isFalse(annotationSpy.called);
+  });
+
+  test('annotate with range applies it', () => {
+    const str = 'Etiam dui, blandit wisi.';
+    const start = 6;
+    const length = 3;
+    const className = 'foobar';
+
+    const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
+    const el = document.createElement('div');
+    el.textContent = str;
+    const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+    line.beforeNumber = 12;
+    element._baseRanges[11] = [{
+      start,
+      length,
+      className,
+    }];
+
+    element.annotate(el, lineNumberEl, line);
+
+    assert.isTrue(annotationSpy.called);
+    assert.equal(annotationSpy.lastCall.args[0], el);
+    assert.equal(annotationSpy.lastCall.args[1], start);
+    assert.equal(annotationSpy.lastCall.args[2], length);
+    assert.equal(annotationSpy.lastCall.args[3], className);
+    assert.isOk(el.querySelector('hl.' + className));
+  });
+
+  test('annotate with range but disabled does nothing', () => {
+    const str = 'Etiam dui, blandit wisi.';
+    const start = 6;
+    const length = 3;
+    const className = 'foobar';
+
+    const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
+    const el = document.createElement('div');
+    el.textContent = str;
+    const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+    line.beforeNumber = 12;
+    element._baseRanges[11] = [{
+      start,
+      length,
+      className,
+    }];
+    element.enabled = false;
+
+    element.annotate(el, lineNumberEl, line);
+
+    assert.isFalse(annotationSpy.called);
+  });
+
+  test('process on empty diff does nothing', done => {
+    element.diff = {
+      meta_a: {content_type: 'application/json'},
+      meta_b: {content_type: 'application/json'},
+      content: [],
+    };
+    const processNextSpy = sinon.spy(element, '_processNextLine');
+
+    const processPromise = element.process();
+
+    processPromise.then(() => {
+      assert.isFalse(processNextSpy.called);
+      assert.equal(element._baseRanges.length, 0);
+      assert.equal(element._revisionRanges.length, 0);
+      done();
+    });
+  });
+
+  test('process for unsupported languages does nothing', done => {
+    element.diff = {
+      meta_a: {content_type: 'text/x+objective-cobol-plus-plus'},
+      meta_b: {content_type: 'application/not-a-real-language'},
+      content: [],
+    };
+    const processNextSpy = sinon.spy(element, '_processNextLine');
+
+    const processPromise = element.process();
+
+    processPromise.then(() => {
+      assert.isFalse(processNextSpy.called);
+      assert.equal(element._baseRanges.length, 0);
+      assert.equal(element._revisionRanges.length, 0);
+      done();
+    });
+  });
+
+  test('process while disabled does nothing', done => {
+    const processNextSpy = sinon.spy(element, '_processNextLine');
+    element.enabled = false;
+    const loadHLJSSpy = sinon.spy(element, '_loadHLJS');
+
+    const processPromise = element.process();
+
+    processPromise.then(() => {
+      assert.isFalse(processNextSpy.called);
+      assert.equal(element._baseRanges.length, 0);
+      assert.equal(element._revisionRanges.length, 0);
+      assert.isFalse(loadHLJSSpy.called);
+      done();
+    });
+  });
+
+  test('process highlight ipsum', done => {
+    element.diff.meta_a.content_type = 'application/json';
+    element.diff.meta_b.content_type = 'application/json';
+
+    const mockHLJS = getMockHLJS();
+    const highlightSpy = sinon.spy(mockHLJS, 'highlight');
+    sinon.stub(element.$.libLoader, 'getHLJS').callsFake(
+        () => Promise.resolve(mockHLJS));
+    const processNextSpy = sinon.spy(element, '_processNextLine');
+    const processPromise = element.process();
+
+    processPromise.then(() => {
+      const linesA = diff.meta_a.lines;
+      const linesB = diff.meta_b.lines;
+
+      assert.isTrue(processNextSpy.called);
+      assert.equal(element._baseRanges.length, linesA);
+      assert.equal(element._revisionRanges.length, linesB);
+
+      assert.equal(highlightSpy.callCount, linesA + linesB);
+
+      // The first line of both sides have a range.
+      let ranges = [element._baseRanges[0], element._revisionRanges[0]];
+      for (const range of ranges) {
+        assert.equal(range.length, 1);
+        assert.equal(range[0].className,
+            'gr-diff gr-syntax gr-syntax-string');
+        assert.equal(range[0].start, 'lorem '.length);
+        assert.equal(range[0].length, 'ipsum'.length);
+      }
+
+      // There are no ranges from ll.1-12 on the left and ll.1-11 on the
+      // right.
+      ranges = element._baseRanges.slice(1, 12)
+          .concat(element._revisionRanges.slice(1, 11));
+
+      for (const range of ranges) {
+        assert.equal(range.length, 0);
+      }
+
+      // There should be another pair of ranges on l.13 for the left and
+      // l.12 for the right.
+      ranges = [element._baseRanges[13], element._revisionRanges[12]];
+
+      for (const range of ranges) {
+        assert.equal(range.length, 1);
+        assert.equal(range[0].className,
+            'gr-diff gr-syntax gr-syntax-string');
+        assert.equal(range[0].start, 32);
+        assert.equal(range[0].length, 'ipsum'.length);
+      }
+
+      // The next group should have a similar instance on either side.
+
+      let range = element._baseRanges[15];
+      assert.equal(range.length, 1);
+      assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
+      assert.equal(range[0].start, 34);
+      assert.equal(range[0].length, 'ipsum'.length);
+
+      range = element._revisionRanges[14];
+      assert.equal(range.length, 1);
+      assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
+      assert.equal(range[0].start, 35);
+      assert.equal(range[0].length, 'ipsum'.length);
+
+      done();
+    });
+  });
+
+  test('_diffChanged calls cancel', () => {
+    const cancelSpy = sinon.spy(element, '_diffChanged');
+    element.diff = {content: []};
+    assert.isTrue(cancelSpy.called);
+  });
+
+  test('_rangesFromElement no ranges', () => {
+    const elem = document.createElement('span');
+    elem.textContent = 'Etiam dui, blandit wisi.';
+    const offset = 100;
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 0);
+  });
+
+  test('_rangesFromElement single range', () => {
+    const str0 = 'Etiam ';
+    const str1 = 'dui, blandit';
+    const str2 = ' wisi.';
+    const className = 'gr-diff gr-syntax gr-syntax-string';
+    const offset = 100;
+
+    const elem = document.createElement('span');
+    elem.appendChild(document.createTextNode(str0));
+    const span = document.createElement('span');
+    span.textContent = str1;
+    span.className = className;
+    elem.appendChild(span);
+    elem.appendChild(document.createTextNode(str2));
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 1);
+    assert.equal(result[0].start, str0.length + offset);
+    assert.equal(result[0].length, str1.length);
+    assert.equal(result[0].className, className);
+  });
+
+  test('_rangesFromElement non-allowed', () => {
+    const str0 = 'Etiam ';
+    const str1 = 'dui, blandit';
+    const str2 = ' wisi.';
+    const className = 'not-in-the-safelist';
+    const offset = 100;
+
+    const elem = document.createElement('span');
+    elem.appendChild(document.createTextNode(str0));
+    const span = document.createElement('span');
+    span.textContent = str1;
+    span.className = className;
+    elem.appendChild(span);
+    elem.appendChild(document.createTextNode(str2));
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 0);
+  });
+
+  test('_rangesFromElement milti range', () => {
+    const str0 = 'Etiam ';
+    const str1 = 'dui,';
+    const str2 = ' blandit';
+    const str3 = ' wisi.';
+    const className = 'gr-diff gr-syntax gr-syntax-string';
+    const offset = 100;
+
+    const elem = document.createElement('span');
+    elem.appendChild(document.createTextNode(str0));
+    let span = document.createElement('span');
+    span.textContent = str1;
+    span.className = className;
+    elem.appendChild(span);
+    elem.appendChild(document.createTextNode(str2));
+    span = document.createElement('span');
+    span.textContent = str3;
+    span.className = className;
+    elem.appendChild(span);
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 2);
+
+    assert.equal(result[0].start, str0.length + offset);
+    assert.equal(result[0].length, str1.length);
+    assert.equal(result[0].className, className);
+
+    assert.equal(result[1].start,
+        str0.length + str1.length + str2.length + offset);
+    assert.equal(result[1].length, str3.length);
+    assert.equal(result[1].className, className);
+  });
+
+  test('_rangesFromElement nested range', () => {
+    const str0 = 'Etiam ';
+    const str1 = 'dui,';
+    const str2 = ' blandit';
+    const str3 = ' wisi.';
+    const className = 'gr-diff gr-syntax gr-syntax-string';
+    const offset = 100;
+
+    const elem = document.createElement('span');
+    elem.appendChild(document.createTextNode(str0));
+    const span1 = document.createElement('span');
+    span1.textContent = str1;
+    span1.className = className;
+    elem.appendChild(span1);
+    const span2 = document.createElement('span');
+    span2.textContent = str2;
+    span2.className = className;
+    span1.appendChild(span2);
+    elem.appendChild(document.createTextNode(str3));
+
+    const result = element._rangesFromElement(elem, offset);
+
+    assert.equal(result.length, 2);
+
+    assert.equal(result[0].start, str0.length + offset);
+    assert.equal(result[0].length, str1.length + str2.length);
+    assert.equal(result[0].className, className);
+
+    assert.equal(result[1].start, str0.length + str1.length + offset);
+    assert.equal(result[1].length, str2.length);
+    assert.equal(result[1].className, className);
+  });
+
+  test('_rangesFromString safelist allows recursion', () => {
+    const str = [
+      '<span class="non-whtelisted-class">',
+      '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
+      '</span>'].join('');
+    const result = element._rangesFromString(str, new Map());
+    assert.notEqual(result.length, 0);
+  });
+
+  test('_rangesFromString cache same syntax markers', () => {
+    sinon.spy(element, '_rangesFromElement');
+    const str =
+      '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>';
+    const cacheMap = new Map();
+    element._rangesFromString(str, cacheMap);
+    element._rangesFromString(str, cacheMap);
+    assert.isTrue(element._rangesFromElement.calledOnce);
+  });
+
+  test('_isSectionDone', () => {
+    let state = {sectionIndex: 0, lineIndex: 0};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 0, lineIndex: 2};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 0, lineIndex: 4};
+    assert.isTrue(element._isSectionDone(state));
+
+    state = {sectionIndex: 1, lineIndex: 2};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 1, lineIndex: 3};
+    assert.isTrue(element._isSectionDone(state));
+
+    state = {sectionIndex: 3, lineIndex: 0};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 3, lineIndex: 3};
+    assert.isFalse(element._isSectionDone(state));
+
+    state = {sectionIndex: 3, lineIndex: 4};
+    assert.isTrue(element._isSectionDone(state));
+  });
+
+  test('workaround CPP LT directive', () => {
+    // Does nothing to regular line.
+    let line = 'int main(int argc, char** argv) { return 0; }';
+    assert.equal(element._workaround('cpp', line), line);
+
+    // Does nothing to include directive.
+    line = '#include <stdio>';
+    assert.equal(element._workaround('cpp', line), line);
+
+    // Converts left-shift operator in #define.
+    line = '#define GiB (1ull << 30)';
+    let expected = '#define GiB (1ull || 30)';
+    assert.equal(element._workaround('cpp', line), expected);
+
+    // Converts less-than operator in #if.
+    line = '  #if __GNUC__ < 4 || (__GNUC__ == 4 && __GNUC_MINOR__ < 1)';
+    expected = '  #if __GNUC__ | 4 || (__GNUC__ == 4 && __GNUC_MINOR__ | 1)';
+    assert.equal(element._workaround('cpp', line), expected);
+  });
+
+  test('workaround Java param-annotation', () => {
+    // Does nothing to regular line.
+    let line = 'public static void foo(int bar) { }';
+    assert.equal(element._workaround('java', line), line);
+
+    // Does nothing to regular annotation.
+    line = 'public static void foo(@Nullable int bar) { }';
+    assert.equal(element._workaround('java', line), line);
+
+    // Converts parameterized annotation.
+    line = 'public static void foo(@SuppressWarnings("unused") int bar) { }';
+    const expected = 'public static void foo(@SuppressWarnings "unused" ' +
+        ' int bar) { }';
+    assert.equal(element._workaround('java', line), expected);
+  });
+
+  test('workaround CPP whcar_t character literals', () => {
+    // Does nothing to regular line.
+    let line = 'int main(int argc, char** argv) { return 0; }';
+    assert.equal(element._workaround('cpp', line), line);
+
+    // Does nothing to wchar_t string.
+    line = 'wchar_t* sz = L"abc 123";';
+    assert.equal(element._workaround('cpp', line), line);
+
+    // Converts wchar_t character literal to string.
+    line = 'wchar_t myChar = L\'#\'';
+    let expected = 'wchar_t myChar = L"."';
+    assert.equal(element._workaround('cpp', line), expected);
+
+    // Converts wchar_t character literal with escape sequence to string.
+    line = 'wchar_t myChar = L\'\\"\'';
+    expected = 'wchar_t myChar = L"\\."';
+    assert.equal(element._workaround('cpp', line), expected);
+  });
+
+  test('workaround go backslash character literals', () => {
+    // Does nothing to regular line.
+    let line = 'func foo(in []byte) (lit []byte, n int, err error) {';
+    assert.equal(element._workaround('go', line), line);
+
+    // Does nothing to string with backslash literal
+    line = 'c := "\\\\"';
+    assert.equal(element._workaround('go', line), line);
+
+    // Converts backslash literal character to a string.
+    line = 'c := \'\\\\\'';
+    const expected = 'c := "\\\\"';
+    assert.equal(element._workaround('go', line), expected);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.js b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.js
deleted file mode 100644
index 76a01de..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.js
+++ /dev/null
@@ -1,121 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-syntax-theme">
-  <template>
-    <style>
-      /**
-       * @overview Highlight.js emits the following classes that do not have
-       * styles here:
-       *    subst, symbol, class, function, doctag, meta-string, section, name,
-       *    builtin-name, bulletm, code, formula, quote, addition, deletion,
-       *    attribute
-       * @see {@link http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html}
-       */
-
-      .contentText {
-        color: var(--syntax-default-color);
-      }
-      .gr-syntax-attribute {
-        color: var(--syntax-attribute-color);
-      }
-      .gr-syntax-function {
-        color: var(--syntax-function-color);
-      }
-      .gr-syntax-meta {
-        color: var(--syntax-meta-color);
-      }
-      .gr-syntax-keyword,
-      .gr-syntax-name {
-        color: var(--syntax-keyword-color);
-      }
-      .gr-syntax-number {
-        color: var(--syntax-number-color);
-      }
-      .gr-syntax-selector-class {
-        color: var(--syntax-selector-class-color);
-      }
-      .gr-syntax-variable {
-        color: var(--syntax-variable-color);
-      }
-      .gr-syntax-template-variable {
-        color: var(--syntax-template-variable-color);
-      }
-      .gr-syntax-comment {
-        color: var(--syntax-comment-color);
-      }
-      .gr-syntax-string {
-        color: var(--syntax-string-color);
-      }
-      .gr-syntax-selector-id {
-        color: var(--syntax-selector-id-color);
-      }
-      .gr-syntax-built_in {
-        color: var(--syntax-built_in-color);
-      }
-      .gr-syntax-tag {
-        color: var(--syntax-tag-color);
-      }
-      .gr-syntax-link {
-        color: var(--syntax-link-color);
-      }
-      .gr-syntax-meta-keyword {
-        color: var(--syntax-meta-keyword-color);
-      }
-      .gr-syntax-type {
-        color: var(--syntax-type-color);
-      }
-      .gr-syntax-title {
-        color: var(--syntax-title-color);
-      }
-      .gr-syntax-attr {
-        color: var(--syntax-attr-color);
-      }
-      .gr-syntax-literal { /* XML/HTML Attribute */
-        color: var(--syntax-literal-color);
-      }
-      .gr-syntax-selector-pseudo {
-        color: var(--syntax-selector-pseudo-color);
-      }
-      .gr-syntax-regexp {
-        color: var(--syntax-regexp-color);
-      }
-      .gr-syntax-selector-attr {
-        color: var(--syntax-selector-attr-color);
-      }
-      .gr-syntax-template-tag {
-        color: var(--syntax-template-tag-color);
-      }
-      .gr-syntax-params {
-        color: var(--syntax-params-color);
-      }
-      .gr-syntax-doctag {
-        font-weight: var(--syntax-doctag-weight);
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
new file mode 100644
index 0000000..ac015e1
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
@@ -0,0 +1,126 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-syntax-theme">
+  <template>
+    <style>
+      /**
+       * @overview Highlight.js emits the following classes that do not have
+       * styles here:
+       *    subst, symbol, class, function, doctag, meta-string, section, name,
+       *    builtin-name, bulletm, code, formula, quote, addition, deletion,
+       *    attribute
+       * @see {@link http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html}
+       */
+
+      .contentText {
+        color: var(--syntax-default-color);
+      }
+      .gr-syntax-attribute {
+        color: var(--syntax-attribute-color);
+      }
+      .gr-syntax-function {
+        color: var(--syntax-function-color);
+      }
+      .gr-syntax-meta {
+        color: var(--syntax-meta-color);
+      }
+      .gr-syntax-keyword,
+      .gr-syntax-name {
+        color: var(--syntax-keyword-color);
+      }
+      .gr-syntax-number {
+        color: var(--syntax-number-color);
+      }
+      .gr-syntax-selector-class {
+        color: var(--syntax-selector-class-color);
+      }
+      .gr-syntax-variable {
+        color: var(--syntax-variable-color);
+      }
+      .gr-syntax-template-variable {
+        color: var(--syntax-template-variable-color);
+      }
+      .gr-syntax-comment {
+        color: var(--syntax-comment-color);
+      }
+      .gr-syntax-string {
+        color: var(--syntax-string-color);
+      }
+      .gr-syntax-selector-id {
+        color: var(--syntax-selector-id-color);
+      }
+      .gr-syntax-built_in {
+        color: var(--syntax-built_in-color);
+      }
+      .gr-syntax-tag {
+        color: var(--syntax-tag-color);
+      }
+      .gr-syntax-link {
+        color: var(--syntax-link-color);
+      }
+      .gr-syntax-meta-keyword {
+        color: var(--syntax-meta-keyword-color);
+      }
+      .gr-syntax-type {
+        color: var(--syntax-type-color);
+      }
+      .gr-syntax-title {
+        color: var(--syntax-title-color);
+      }
+      .gr-syntax-attr {
+        color: var(--syntax-attr-color);
+      }
+      .gr-syntax-literal { /* XML/HTML Attribute */
+        color: var(--syntax-literal-color);
+      }
+      .gr-syntax-selector-pseudo {
+        color: var(--syntax-selector-pseudo-color);
+      }
+      .gr-syntax-regexp {
+        color: var(--syntax-regexp-color);
+      }
+      .gr-syntax-selector-attr {
+        color: var(--syntax-selector-attr-color);
+      }
+      .gr-syntax-template-tag {
+        color: var(--syntax-template-tag-color);
+      }
+      .gr-syntax-params {
+        color: var(--syntax-params-color);
+      }
+      .gr-syntax-doctag {
+        font-weight: var(--syntax-doctag-weight);
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
index 4d66699..8e7bd2d 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
@@ -14,25 +14,21 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/gr-table-styles.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-list-view/gr-list-view.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-documentation-search_html.js';
-import {ListViewBehavior} from '../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.js';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrDocumentationSearch extends mixinBehaviors( [
-  ListViewBehavior,
-], GestureEventListeners(
+class GrDocumentationSearch extends ListViewMixin(GestureEventListeners(
     LegacyElementMixin(
         PolymerElement))) {
   static get template() { return htmlTemplate; }
@@ -94,7 +90,7 @@
 
   _computeSearchUrl(url) {
     if (!url) { return ''; }
-    return this.getBaseUrl() + '/' + url;
+    return getBaseUrl() + '/' + url;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.js
deleted file mode 100644
index b637b75..0000000
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.js
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <gr-list-view
-    filter="[[_filter]]"
-    items="false"
-    offset="0"
-    loading="[[_loading]]"
-    path="[[_path]]"
-  >
-    <table id="list" class="genericList">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Name</th>
-          <th class="name topHeader"></th>
-          <th class="name topHeader"></th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_documentationSearches]]">
-          <tr class="table">
-            <td class="name">
-              <a href$="[[_computeSearchUrl(item.url)]]">[[item.title]]</a>
-            </td>
-            <td></td>
-            <td></td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </gr-list-view>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
new file mode 100644
index 0000000..de0a990
--- /dev/null
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
@@ -0,0 +1,58 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-table-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <gr-list-view
+    filter="[[_filter]]"
+    items="false"
+    offset="0"
+    loading="[[_loading]]"
+    path="[[_path]]"
+  >
+    <table id="list" class="genericList">
+      <tbody>
+        <tr class="headerRow">
+          <th class="name topHeader">Name</th>
+          <th class="name topHeader"></th>
+          <th class="name topHeader"></th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+      </tbody>
+      <tbody class$="[[computeLoadingClass(_loading)]]">
+        <template is="dom-repeat" items="[[_documentationSearches]]">
+          <tr class="table">
+            <td class="name">
+              <a href$="[[_computeSearchUrl(item.url)]]">[[item.title]]</a>
+            </td>
+            <td></td>
+            <td></td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </gr-list-view>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
deleted file mode 100644
index d0581ef..0000000
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
+++ /dev/null
@@ -1,124 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-documentation-search</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-documentation-search></gr-documentation-search>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-documentation-search.js';
-import page from 'page/page.mjs';
-
-let counter;
-const documentationGenerator = () => {
-  return {
-    title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
-    url: 'Documentation/dev-rest-api.html',
-  };
-};
-
-suite('gr-documentation-search tests', () => {
-  let element;
-  let documentationSearches;
-  let sandbox;
-  let value;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    sandbox.stub(page, 'show');
-    element = fixture('basic');
-    counter = 0;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('list with searches for documentation', () => {
-    setup(done => {
-      documentationSearches = _.times(26, documentationGenerator);
-      stub('gr-rest-api-interface', {
-        getDocumentationSearches() {
-          return Promise.resolve(documentationSearches);
-        },
-      });
-      element._paramsChanged(value).then(() => { flush(done); });
-    });
-
-    test('test for test repo in the list', done => {
-      flush(() => {
-        assert.equal(element._documentationSearches[0].title,
-            'Gerrit Code Review - REST API Developers Notes1');
-        assert.equal(element._documentationSearches[0].url,
-            'Documentation/dev-rest-api.html');
-        done();
-      });
-    });
-  });
-
-  suite('filter', () => {
-    setup(() => {
-      documentationSearches = _.times(25, documentationGenerator);
-      _.times(1, documentationSearches);
-    });
-
-    test('_paramsChanged', done => {
-      sandbox.stub(
-          element.$.restAPI,
-          'getDocumentationSearches',
-          () => Promise.resolve(documentationSearches));
-      const value = {
-        filter: 'test',
-      };
-      element._paramsChanged(value).then(() => {
-        assert.isTrue(element.$.restAPI.getDocumentationSearches.lastCall
-            .calledWithExactly('test'));
-        done();
-      });
-    });
-  });
-
-  suite('loading', () => {
-    test('correct contents are displayed', () => {
-      assert.isTrue(element._loading);
-      assert.equal(element.computeLoadingClass(element._loading), 'loading');
-      assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-      element._loading = false;
-      element._repos = _.times(25, documentationGenerator);
-
-      flushAsynchronousOperations();
-      assert.equal(element.computeLoadingClass(element._loading), '');
-      assert.equal(getComputedStyle(element.$.loading).display, 'none');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
new file mode 100644
index 0000000..c2a3f3d
--- /dev/null
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-documentation-search.js';
+import page from 'page/page.mjs';
+
+const basicFixture = fixtureFromElement('gr-documentation-search');
+
+let counter;
+const documentationGenerator = () => {
+  return {
+    title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
+    url: 'Documentation/dev-rest-api.html',
+  };
+};
+
+suite('gr-documentation-search tests', () => {
+  let element;
+  let documentationSearches;
+
+  let value;
+
+  setup(() => {
+    sinon.stub(page, 'show');
+    element = basicFixture.instantiate();
+    counter = 0;
+  });
+
+  suite('list with searches for documentation', () => {
+    setup(done => {
+      documentationSearches = _.times(26, documentationGenerator);
+      stub('gr-rest-api-interface', {
+        getDocumentationSearches() {
+          return Promise.resolve(documentationSearches);
+        },
+      });
+      element._paramsChanged(value).then(() => { flush(done); });
+    });
+
+    test('test for test repo in the list', done => {
+      flush(() => {
+        assert.equal(element._documentationSearches[0].title,
+            'Gerrit Code Review - REST API Developers Notes1');
+        assert.equal(element._documentationSearches[0].url,
+            'Documentation/dev-rest-api.html');
+        done();
+      });
+    });
+  });
+
+  suite('filter', () => {
+    setup(() => {
+      documentationSearches = _.times(25, documentationGenerator);
+      _.times(1, documentationSearches);
+    });
+
+    test('_paramsChanged', done => {
+      sinon.stub(
+          element.$.restAPI,
+          'getDocumentationSearches')
+          .callsFake(() => Promise.resolve(documentationSearches));
+      const value = {
+        filter: 'test',
+      };
+      element._paramsChanged(value).then(() => {
+        assert.isTrue(element.$.restAPI.getDocumentationSearches.lastCall
+            .calledWithExactly('test'));
+        done();
+      });
+    });
+  });
+
+  suite('loading', () => {
+    test('correct contents are displayed', () => {
+      assert.isTrue(element._loading);
+      assert.equal(element.computeLoadingClass(element._loading), 'loading');
+      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+      element._loading = false;
+      element._repos = _.times(25, documentationGenerator);
+
+      flushAsynchronousOperations();
+      assert.equal(element.computeLoadingClass(element._loading), '');
+      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
index 09f4abf..9acfd72 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
@@ -14,15 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-default-editor_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrDefaultEditor extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.js
deleted file mode 100644
index 7368289..0000000
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    textarea {
-      border: none;
-      box-sizing: border-box;
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-code);
-      line-height: var(--line-height-code);
-      min-height: 60vh;
-      resize: none;
-      white-space: pre;
-      width: 100%;
-    }
-    textarea:focus {
-      outline: none;
-    }
-  </style>
-  <textarea
-    id="textarea"
-    value="[[fileContent]]"
-    on-input="_handleTextareaInput"
-  ></textarea>
-`;
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts
new file mode 100644
index 0000000..ba8af3c
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    textarea {
+      border: none;
+      box-sizing: border-box;
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-code);
+      line-height: var(--line-height-code);
+      min-height: 60vh;
+      resize: none;
+      white-space: pre;
+      width: 100%;
+    }
+    textarea:focus {
+      outline: none;
+    }
+  </style>
+  <textarea
+    id="textarea"
+    value="[[fileContent]]"
+    on-input="_handleTextareaInput"
+  ></textarea>
+`;
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
deleted file mode 100644
index 229c6c3..0000000
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.html
+++ /dev/null
@@ -1,56 +0,0 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-default-editor</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-default-editor></gr-default-editor>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-default-editor.js';
-suite('gr-default-editor tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-    element.fileContent = '';
-  });
-
-  test('fires content-change event', done => {
-    const contentChangedHandler = e => {
-      assert.equal(e.detail.value, 'test');
-      done();
-    };
-    const textarea = element.$.textarea;
-    element.addEventListener('content-change', contentChangedHandler);
-    textarea.value = 'test';
-    textarea.dispatchEvent(new CustomEvent('input',
-        {target: textarea, bubbles: true, composed: true}));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.js
new file mode 100644
index 0000000..d40e83d
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.js
@@ -0,0 +1,43 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-default-editor.js';
+
+const basicFixture = fixtureFromElement('gr-default-editor');
+
+suite('gr-default-editor tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.fileContent = '';
+  });
+
+  test('fires content-change event', done => {
+    const contentChangedHandler = e => {
+      assert.equal(e.detail.value, 'test');
+      done();
+    };
+    const textarea = element.$.textarea;
+    element.addEventListener('content-change', contentChangedHandler);
+    textarea.value = 'test';
+    textarea.dispatchEvent(new CustomEvent('input',
+        {target: textarea, bubbles: true, composed: true}));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.js b/polygerrit-ui/app/elements/edit/gr-edit-constants.js
deleted file mode 100644
index 7282a46..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-constants.js
+++ /dev/null
@@ -1,27 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-export const GrEditConstants = {
-// Order corresponds to order in the UI.
-  Actions: {
-    OPEN: {label: 'Add/Open/Upload', id: 'open'},
-    DELETE: {label: 'Delete', id: 'delete'},
-    RENAME: {label: 'Rename', id: 'rename'},
-    RESTORE: {label: 'Restore', id: 'restore'},
-  },
-};
-
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.ts b/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
new file mode 100644
index 0000000..d4d4855
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export const GrEditConstants = {
+  // Order corresponds to order in the UI.
+  Actions: {
+    OPEN: {label: 'Add/Open/Upload', id: 'open'},
+    DELETE: {label: 'Delete', id: 'delete'},
+    RENAME: {label: 'Rename', id: 'rename'},
+    RESTORE: {label: 'Restore', id: 'restore'},
+  },
+};
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
index 7ca849f..86a2e61 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '../../shared/gr-autocomplete/gr-autocomplete.js';
 import '../../shared/gr-button/gr-button.js';
@@ -25,23 +23,19 @@
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../../styles/shared-styles.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-edit-controls_html.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
 import {GrEditConstants} from '../gr-edit-constants.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrEditControls extends mixinBehaviors( [
-  PatchSetBehavior,
-], GestureEventListeners(
+class GrEditControls extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-edit-controls'; }
@@ -229,7 +223,7 @@
 
   _handleDeleteConfirm(e) {
     // Get the dialog before the api call as the event will change during bubbling
-    // which will make Polymer.dom(e).path an emtpy array in polymer 2
+    // which will make Polymer.dom(e).path an empty array in polymer 2
     const dialog = this._getDialogFromEvent(e);
     this.$.restAPI.deleteFileInChangeEdit(this.change._number, this._path)
         .then(res => {
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.js
deleted file mode 100644
index 02639c0..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.js
+++ /dev/null
@@ -1,183 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      align-items: center;
-      display: flex;
-      justify-content: flex-end;
-    }
-    .invisible {
-      display: none;
-    }
-    gr-button {
-      margin-left: var(--spacing-l);
-      text-decoration: none;
-    }
-    gr-dialog {
-      width: 50em;
-    }
-    gr-dialog .main {
-      width: 100%;
-    }
-    gr-dialog .main > iron-input {
-      width: 100%;
-    }
-    input {
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      margin: var(--spacing-m) 0;
-      padding: var(--spacing-s);
-      width: 100%;
-      box-sizing: content-box;
-    }
-    #fileUploadBrowse {
-      margin-left: 0;
-    }
-    #dragDropArea {
-      border: 2px dashed var(--border-color);
-      border-radius: var(--border-radius);
-      margin-top: var(--spacing-l);
-      padding: var(--spacing-xxl) var(--spacing-xxl);
-      text-align: center;
-    }
-    #dragDropArea > p {
-      font-weight: var(--font-weight-bold);
-      padding: var(--spacing-s);
-    }
-    @media screen and (max-width: 50em) {
-      gr-dialog {
-        width: 100vw;
-      }
-    }
-  </style>
-  <template is="dom-repeat" items="[[_actions]]" as="action">
-    <gr-button
-      id$="[[action.id]]"
-      class$="[[_computeIsInvisible(action.id, hiddenActions)]]"
-      link=""
-      on-click="_handleTap"
-      >[[action.label]]</gr-button
-    >
-  </template>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-dialog
-      id="openDialog"
-      class="invisible dialog"
-      disabled$="[[!_isValidPath(_path)]]"
-      confirm-label="Confirm"
-      confirm-on-enter=""
-      on-confirm="_handleOpenConfirm"
-      on-cancel="_handleDialogCancel"
-    >
-      <div class="header" slot="header">
-        Add a new file or open an existing file
-      </div>
-      <div class="main" slot="main">
-        <gr-autocomplete
-          placeholder="Enter an existing or new full file path."
-          query="[[_query]]"
-          text="{{_path}}"
-        ></gr-autocomplete>
-        <div id="dragDropArea" on-drop="_handleDragAndDropUpload">
-          <p>Drag and drop a file here</p>
-          <p>or</p>
-          <p>
-            <iron-input>
-              <input
-                is="iron-input"
-                id="fileUploadInput"
-                type="file"
-                on-change="_handleFileUploadChanged"
-                hidden
-              />
-            </iron-input>
-            <label for="fileUploadInput">
-              <gr-button id="fileUploadBrowse">Browse</gr-button>
-            </label>
-          </p>
-        </div>
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="deleteDialog"
-      class="invisible dialog"
-      disabled$="[[!_isValidPath(_path)]]"
-      confirm-label="Delete"
-      confirm-on-enter=""
-      on-confirm="_handleDeleteConfirm"
-      on-cancel="_handleDialogCancel"
-    >
-      <div class="header" slot="header">Delete a file from the repo</div>
-      <div class="main" slot="main">
-        <gr-autocomplete
-          placeholder="Enter an existing full file path."
-          query="[[_query]]"
-          text="{{_path}}"
-        ></gr-autocomplete>
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="renameDialog"
-      class="invisible dialog"
-      disabled$="[[!_computeRenameDisabled(_path, _newPath)]]"
-      confirm-label="Rename"
-      confirm-on-enter=""
-      on-confirm="_handleRenameConfirm"
-      on-cancel="_handleDialogCancel"
-    >
-      <div class="header" slot="header">Rename a file in the repo</div>
-      <div class="main" slot="main">
-        <gr-autocomplete
-          placeholder="Enter an existing full file path."
-          query="[[_query]]"
-          text="{{_path}}"
-        ></gr-autocomplete>
-        <iron-input
-          class="newPathIronInput"
-          bind-value="{{_newPath}}"
-          placeholder="Enter the new path."
-        >
-          <input
-            class="newPathInput"
-            is="iron-input"
-            bind-value="{{_newPath}}"
-            placeholder="Enter the new path."
-          />
-        </iron-input>
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="restoreDialog"
-      class="invisible dialog"
-      confirm-label="Restore"
-      confirm-on-enter=""
-      on-confirm="_handleRestoreConfirm"
-      on-cancel="_handleDialogCancel"
-    >
-      <div class="header" slot="header">Restore this file?</div>
-      <div class="main" slot="main">
-        <iron-input disabled="" bind-value="{{_path}}">
-          <input is="iron-input" disabled="" bind-value="{{_path}}" />
-        </iron-input>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
new file mode 100644
index 0000000..b67f6cd
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
@@ -0,0 +1,183 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      align-items: center;
+      display: flex;
+      justify-content: flex-end;
+    }
+    .invisible {
+      display: none;
+    }
+    gr-button {
+      margin-left: var(--spacing-l);
+      text-decoration: none;
+    }
+    gr-dialog {
+      width: 50em;
+    }
+    gr-dialog .main {
+      width: 100%;
+    }
+    gr-dialog .main > iron-input {
+      width: 100%;
+    }
+    input {
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      margin: var(--spacing-m) 0;
+      padding: var(--spacing-s);
+      width: 100%;
+      box-sizing: content-box;
+    }
+    #fileUploadBrowse {
+      margin-left: 0;
+    }
+    #dragDropArea {
+      border: 2px dashed var(--border-color);
+      border-radius: var(--border-radius);
+      margin-top: var(--spacing-l);
+      padding: var(--spacing-xxl) var(--spacing-xxl);
+      text-align: center;
+    }
+    #dragDropArea > p {
+      font-weight: var(--font-weight-bold);
+      padding: var(--spacing-s);
+    }
+    @media screen and (max-width: 50em) {
+      gr-dialog {
+        width: 100vw;
+      }
+    }
+  </style>
+  <template is="dom-repeat" items="[[_actions]]" as="action">
+    <gr-button
+      id$="[[action.id]]"
+      class$="[[_computeIsInvisible(action.id, hiddenActions)]]"
+      link=""
+      on-click="_handleTap"
+      >[[action.label]]</gr-button
+    >
+  </template>
+  <gr-overlay id="overlay" with-backdrop="">
+    <gr-dialog
+      id="openDialog"
+      class="invisible dialog"
+      disabled$="[[!_isValidPath(_path)]]"
+      confirm-label="Confirm"
+      confirm-on-enter=""
+      on-confirm="_handleOpenConfirm"
+      on-cancel="_handleDialogCancel"
+    >
+      <div class="header" slot="header">
+        Add a new file or open an existing file
+      </div>
+      <div class="main" slot="main">
+        <gr-autocomplete
+          placeholder="Enter an existing or new full file path."
+          query="[[_query]]"
+          text="{{_path}}"
+        ></gr-autocomplete>
+        <div id="dragDropArea" on-drop="_handleDragAndDropUpload">
+          <p>Drag and drop a file here</p>
+          <p>or</p>
+          <p>
+            <iron-input>
+              <input
+                is="iron-input"
+                id="fileUploadInput"
+                type="file"
+                on-change="_handleFileUploadChanged"
+                hidden
+              />
+            </iron-input>
+            <label for="fileUploadInput">
+              <gr-button id="fileUploadBrowse">Browse</gr-button>
+            </label>
+          </p>
+        </div>
+      </div>
+    </gr-dialog>
+    <gr-dialog
+      id="deleteDialog"
+      class="invisible dialog"
+      disabled$="[[!_isValidPath(_path)]]"
+      confirm-label="Delete"
+      confirm-on-enter=""
+      on-confirm="_handleDeleteConfirm"
+      on-cancel="_handleDialogCancel"
+    >
+      <div class="header" slot="header">Delete a file from the repo</div>
+      <div class="main" slot="main">
+        <gr-autocomplete
+          placeholder="Enter an existing full file path."
+          query="[[_query]]"
+          text="{{_path}}"
+        ></gr-autocomplete>
+      </div>
+    </gr-dialog>
+    <gr-dialog
+      id="renameDialog"
+      class="invisible dialog"
+      disabled$="[[!_computeRenameDisabled(_path, _newPath)]]"
+      confirm-label="Rename"
+      confirm-on-enter=""
+      on-confirm="_handleRenameConfirm"
+      on-cancel="_handleDialogCancel"
+    >
+      <div class="header" slot="header">Rename a file in the repo</div>
+      <div class="main" slot="main">
+        <gr-autocomplete
+          placeholder="Enter an existing full file path."
+          query="[[_query]]"
+          text="{{_path}}"
+        ></gr-autocomplete>
+        <iron-input
+          class="newPathIronInput"
+          bind-value="{{_newPath}}"
+          placeholder="Enter the new path."
+        >
+          <input
+            class="newPathInput"
+            is="iron-input"
+            bind-value="{{_newPath}}"
+            placeholder="Enter the new path."
+          />
+        </iron-input>
+      </div>
+    </gr-dialog>
+    <gr-dialog
+      id="restoreDialog"
+      class="invisible dialog"
+      confirm-label="Restore"
+      confirm-on-enter=""
+      on-confirm="_handleRestoreConfirm"
+      on-cancel="_handleDialogCancel"
+    >
+      <div class="header" slot="header">Restore this file?</div>
+      <div class="main" slot="main">
+        <iron-input disabled="" bind-value="{{_path}}">
+          <input is="iron-input" disabled="" bind-value="{{_path}}" />
+        </iron-input>
+      </div>
+    </gr-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
deleted file mode 100644
index 1267525..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
+++ /dev/null
@@ -1,433 +0,0 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-edit-controls</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-edit-controls></gr-edit-controls>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-edit-controls.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-edit-controls tests', () => {
-  let element;
-  let sandbox;
-  let showDialogSpy;
-  let closeDialogSpy;
-  let queryStub;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    element.change = {_number: '42'};
-    showDialogSpy = sandbox.spy(element, '_showDialog');
-    closeDialogSpy = sandbox.spy(element, '_closeDialog');
-    sandbox.stub(element, '_hideAllDialogs');
-    queryStub = sandbox.stub(element.$.restAPI, 'queryChangeFiles')
-        .returns(Promise.resolve([]));
-    flushAsynchronousOperations();
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('all actions exist', () => {
-    // We take 1 away from the total found, due to an extra button being
-    // added for the file uploads (browse).
-    assert.equal(
-        dom(element.root).querySelectorAll('gr-button').length - 1,
-        element._actions.length);
-  });
-
-  suite('edit button CUJ', () => {
-    let navStubs;
-    let openAutoCcmplete;
-
-    setup(() => {
-      navStubs = [
-        sandbox.stub(GerritNav, 'getEditUrlForDiff'),
-        sandbox.stub(GerritNav, 'navigateToRelativeUrl'),
-      ];
-      openAutoCcmplete = element.$.openDialog.querySelector('gr-autocomplete');
-    });
-
-    test('_isValidPath', () => {
-      assert.isFalse(element._isValidPath(''));
-      assert.isFalse(element._isValidPath('test/'));
-      assert.isFalse(element._isValidPath('/'));
-      assert.isTrue(element._isValidPath('test/path.cpp'));
-      assert.isTrue(element._isValidPath('test.js'));
-    });
-
-    test('open', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
-      element.patchNum = 1;
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element._hideAllDialogs.called);
-        assert.isTrue(element.$.openDialog.disabled);
-        assert.isFalse(queryStub.called);
-        // Setup _focused manually - in headless mode Chrome sometimes don't
-        // setup focus. flush and/or flushAsynchronousOperations don't help
-        openAutoCcmplete._focused = true;
-        openAutoCcmplete.noDebounce = true;
-        openAutoCcmplete.text = 'src/test.cpp';
-        assert.isTrue(queryStub.called);
-        assert.isFalse(element.$.openDialog.disabled);
-        MockInteractions.tap(element.$.openDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        for (const stub of navStubs) { assert.isTrue(stub.called); }
-        assert.deepEqual(GerritNav.getEditUrlForDiff.lastCall.args,
-            [element.change, 'src/test.cpp', element.patchNum]);
-        assert.isTrue(closeDialogSpy.called);
-      });
-    });
-
-    test('cancel', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.openDialog.disabled);
-        openAutoCcmplete.noDebounce = true;
-        openAutoCcmplete.text = 'src/test.cpp';
-        assert.isFalse(element.$.openDialog.disabled);
-        MockInteractions.tap(element.$.openDialog.shadowRoot
-            .querySelector('gr-button'));
-        for (const stub of navStubs) { assert.isFalse(stub.called); }
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-      });
-    });
-  });
-
-  suite('delete button CUJ', () => {
-    let navStub;
-    let deleteStub;
-    let deleteAutocomplete;
-
-    setup(() => {
-      navStub = sandbox.stub(GerritNav, 'navigateToChange');
-      deleteStub = sandbox.stub(element.$.restAPI, 'deleteFileInChangeEdit');
-      deleteAutocomplete =
-          element.$.deleteDialog.querySelector('gr-autocomplete');
-    });
-
-    test('delete', () => {
-      deleteStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.deleteDialog.disabled);
-        assert.isFalse(queryStub.called);
-        // Setup _focused manually - in headless mode Chrome sometimes don't
-        // setup focus. flush and/or flushAsynchronousOperations don't help
-        deleteAutocomplete._focused = true;
-        deleteAutocomplete.noDebounce = true;
-        deleteAutocomplete.text = 'src/test.cpp';
-        assert.isTrue(queryStub.called);
-        assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(deleteStub.called);
-
-        return deleteStub.lastCall.returnValue.then(() => {
-          assert.equal(element._path, '');
-          assert.isTrue(navStub.called);
-          assert.isTrue(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('delete fails', () => {
-      deleteStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.deleteDialog.disabled);
-        assert.isFalse(queryStub.called);
-        // Setup _focused manually - in headless mode Chrome sometimes don't
-        // setup focus. flush and/or flushAsynchronousOperations don't help
-        deleteAutocomplete._focused = true;
-        deleteAutocomplete.noDebounce = true;
-        deleteAutocomplete.text = 'src/test.cpp';
-        assert.isTrue(queryStub.called);
-        assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(deleteStub.called);
-
-        return deleteStub.lastCall.returnValue.then(() => {
-          assert.isFalse(navStub.called);
-          assert.isFalse(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('cancel', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.deleteDialog.disabled);
-        element.$.deleteDialog.querySelector('gr-autocomplete').text =
-            'src/test.cpp';
-        assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.shadowRoot
-            .querySelector('gr-button'));
-        assert.isFalse(navStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-      });
-    });
-  });
-
-  suite('rename button CUJ', () => {
-    let navStub;
-    let renameStub;
-    let renameAutocomplete;
-    const inputSelector = PolymerElement ?
-      '.newPathIronInput' :
-      '.newPathInput';
-
-    setup(() => {
-      navStub = sandbox.stub(GerritNav, 'navigateToChange');
-      renameStub = sandbox.stub(element.$.restAPI, 'renameFileInChangeEdit');
-      renameAutocomplete =
-          element.$.renameDialog.querySelector('gr-autocomplete');
-    });
-
-    test('rename', () => {
-      renameStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.renameDialog.disabled);
-        assert.isFalse(queryStub.called);
-        // Setup _focused manually - in headless mode Chrome sometimes don't
-        // setup focus. flush and/or flushAsynchronousOperations don't help
-        renameAutocomplete._focused = true;
-        renameAutocomplete.noDebounce = true;
-        renameAutocomplete.text = 'src/test.cpp';
-        assert.isTrue(queryStub.called);
-        assert.isTrue(element.$.renameDialog.disabled);
-
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
-
-        assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(renameStub.called);
-
-        return renameStub.lastCall.returnValue.then(() => {
-          assert.equal(element._path, '');
-          assert.isTrue(navStub.called);
-          assert.isTrue(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('rename fails', () => {
-      renameStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.renameDialog.disabled);
-        assert.isFalse(queryStub.called);
-        // Setup _focused manually - in headless mode Chrome sometimes don't
-        // setup focus. flush and/or flushAsynchronousOperations don't help
-        renameAutocomplete._focused = true;
-        renameAutocomplete.noDebounce = true;
-        renameAutocomplete.text = 'src/test.cpp';
-        assert.isTrue(queryStub.called);
-        assert.isTrue(element.$.renameDialog.disabled);
-
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
-
-        assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(renameStub.called);
-
-        return renameStub.lastCall.returnValue.then(() => {
-          assert.isFalse(navStub.called);
-          assert.isFalse(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('cancel', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.renameDialog.disabled);
-        element.$.renameDialog.querySelector('gr-autocomplete').text =
-            'src/test.cpp';
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
-        assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button'));
-        assert.isFalse(navStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-        assert.equal(element._newPath, 'src/test.newPath');
-      });
-    });
-  });
-
-  suite('restore button CUJ', () => {
-    let navStub;
-    let restoreStub;
-
-    setup(() => {
-      navStub = sandbox.stub(GerritNav, 'navigateToChange');
-      restoreStub = sandbox.stub(element.$.restAPI, 'restoreFileInChangeEdit');
-    });
-
-    test('restore hidden by default', () => {
-      assert.isTrue(element.shadowRoot
-          .querySelector('#restore').classList.contains('invisible'));
-    });
-
-    test('restore', () => {
-      restoreStub.returns(Promise.resolve({ok: true}));
-      element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(restoreStub.called);
-        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
-        return restoreStub.lastCall.returnValue.then(() => {
-          assert.equal(element._path, '');
-          assert.isTrue(navStub.called);
-          assert.isTrue(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('restore fails', () => {
-      restoreStub.returns(Promise.resolve({ok: false}));
-      element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flushAsynchronousOperations();
-
-        assert.isTrue(restoreStub.called);
-        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
-        return restoreStub.lastCall.returnValue.then(() => {
-          assert.isFalse(navStub.called);
-          assert.isFalse(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('cancel', () => {
-      element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button'));
-        assert.isFalse(navStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-      });
-    });
-  });
-
-  suite('save file upload', () => {
-    let navStub;
-    let fileStub;
-
-    setup(() => {
-      navStub = sandbox.stub(GerritNav, 'navigateToChange');
-      fileStub = sandbox.stub(element.$.restAPI, 'saveFileUploadChangeEdit');
-    });
-
-    test('_handleUploadConfirm', () => {
-      fileStub.returns(Promise.resolve({ok: true}));
-
-      element.change = {
-        _number: '1',
-        project: 'project',
-        revisions: {
-          abcd: {_number: 1},
-          efgh: {_number: 2},
-        },
-        current_revision: 'efgh',
-      };
-
-      element._handleUploadConfirm('test.php', 'base64').then(() => {
-        assert.equal(
-            navStub.lastCall.args,
-            '/c/project/+/1');
-      });
-    });
-  });
-
-  test('openOpenDialog', done => {
-    element.openOpenDialog('test/path.cpp')
-        .then(() => {
-          assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
-          assert.equal(
-              element.$.openDialog.querySelector('gr-autocomplete').text,
-              'test/path.cpp');
-          done();
-        });
-  });
-
-  test('_getDialogFromEvent', () => {
-    const spy = sandbox.spy(element, '_getDialogFromEvent');
-    element.addEventListener('tap', element._getDialogFromEvent);
-
-    MockInteractions.tap(element.$.openDialog);
-    flushAsynchronousOperations();
-    assert.equal(spy.lastCall.returnValue.id, 'openDialog');
-
-    MockInteractions.tap(element.$.deleteDialog);
-    flushAsynchronousOperations();
-    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
-
-    MockInteractions.tap(
-        element.$.deleteDialog.querySelector('gr-autocomplete'));
-    flushAsynchronousOperations();
-    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
-
-    MockInteractions.tap(element);
-    flushAsynchronousOperations();
-    assert.notOk(spy.lastCall.returnValue);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
new file mode 100644
index 0000000..8269b81
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
@@ -0,0 +1,416 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-edit-controls.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-edit-controls');
+
+suite('gr-edit-controls tests', () => {
+  let element;
+
+  let showDialogSpy;
+  let closeDialogSpy;
+  let queryStub;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.change = {_number: '42'};
+    showDialogSpy = sinon.spy(element, '_showDialog');
+    closeDialogSpy = sinon.spy(element, '_closeDialog');
+    sinon.stub(element, '_hideAllDialogs');
+    queryStub = sinon.stub(element.$.restAPI, 'queryChangeFiles')
+        .returns(Promise.resolve([]));
+    flushAsynchronousOperations();
+  });
+
+  test('all actions exist', () => {
+    // We take 1 away from the total found, due to an extra button being
+    // added for the file uploads (browse).
+    assert.equal(
+        dom(element.root).querySelectorAll('gr-button').length - 1,
+        element._actions.length);
+  });
+
+  suite('edit button CUJ', () => {
+    let navStubs;
+    let openAutoCcmplete;
+
+    setup(() => {
+      navStubs = [
+        sinon.stub(GerritNav, 'getEditUrlForDiff'),
+        sinon.stub(GerritNav, 'navigateToRelativeUrl'),
+      ];
+      openAutoCcmplete = element.$.openDialog.querySelector('gr-autocomplete');
+    });
+
+    test('_isValidPath', () => {
+      assert.isFalse(element._isValidPath(''));
+      assert.isFalse(element._isValidPath('test/'));
+      assert.isFalse(element._isValidPath('/'));
+      assert.isTrue(element._isValidPath('test/path.cpp'));
+      assert.isTrue(element._isValidPath('test.js'));
+    });
+
+    test('open', () => {
+      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
+      element.patchNum = 1;
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element._hideAllDialogs.called);
+        assert.isTrue(element.$.openDialog.disabled);
+        assert.isFalse(queryStub.called);
+        // Setup _focused manually - in headless mode Chrome sometimes don't
+        // setup focus. flush and/or flushAsynchronousOperations don't help
+        openAutoCcmplete._focused = true;
+        openAutoCcmplete.noDebounce = true;
+        openAutoCcmplete.text = 'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isFalse(element.$.openDialog.disabled);
+        MockInteractions.tap(element.$.openDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        for (const stub of navStubs) { assert.isTrue(stub.called); }
+        assert.deepEqual(GerritNav.getEditUrlForDiff.lastCall.args,
+            [element.change, 'src/test.cpp', element.patchNum]);
+        assert.isTrue(closeDialogSpy.called);
+      });
+    });
+
+    test('cancel', () => {
+      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.openDialog.disabled);
+        openAutoCcmplete.noDebounce = true;
+        openAutoCcmplete.text = 'src/test.cpp';
+        assert.isFalse(element.$.openDialog.disabled);
+        MockInteractions.tap(element.$.openDialog.shadowRoot
+            .querySelector('gr-button'));
+        for (const stub of navStubs) { assert.isFalse(stub.called); }
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+      });
+    });
+  });
+
+  suite('delete button CUJ', () => {
+    let navStub;
+    let deleteStub;
+    let deleteAutocomplete;
+
+    setup(() => {
+      navStub = sinon.stub(GerritNav, 'navigateToChange');
+      deleteStub = sinon.stub(element.$.restAPI, 'deleteFileInChangeEdit');
+      deleteAutocomplete =
+          element.$.deleteDialog.querySelector('gr-autocomplete');
+    });
+
+    test('delete', () => {
+      deleteStub.returns(Promise.resolve({ok: true}));
+      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.deleteDialog.disabled);
+        assert.isFalse(queryStub.called);
+        // Setup _focused manually - in headless mode Chrome sometimes don't
+        // setup focus. flush and/or flushAsynchronousOperations don't help
+        deleteAutocomplete._focused = true;
+        deleteAutocomplete.noDebounce = true;
+        deleteAutocomplete.text = 'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isFalse(element.$.deleteDialog.disabled);
+        MockInteractions.tap(element.$.deleteDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(deleteStub.called);
+
+        return deleteStub.lastCall.returnValue.then(() => {
+          assert.equal(element._path, '');
+          assert.isTrue(navStub.called);
+          assert.isTrue(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('delete fails', () => {
+      deleteStub.returns(Promise.resolve({ok: false}));
+      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.deleteDialog.disabled);
+        assert.isFalse(queryStub.called);
+        // Setup _focused manually - in headless mode Chrome sometimes don't
+        // setup focus. flush and/or flushAsynchronousOperations don't help
+        deleteAutocomplete._focused = true;
+        deleteAutocomplete.noDebounce = true;
+        deleteAutocomplete.text = 'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isFalse(element.$.deleteDialog.disabled);
+        MockInteractions.tap(element.$.deleteDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(deleteStub.called);
+
+        return deleteStub.lastCall.returnValue.then(() => {
+          assert.isFalse(navStub.called);
+          assert.isFalse(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('cancel', () => {
+      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.deleteDialog.disabled);
+        element.$.deleteDialog.querySelector('gr-autocomplete').text =
+            'src/test.cpp';
+        assert.isFalse(element.$.deleteDialog.disabled);
+        MockInteractions.tap(element.$.deleteDialog.shadowRoot
+            .querySelector('gr-button'));
+        assert.isFalse(navStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+      });
+    });
+  });
+
+  suite('rename button CUJ', () => {
+    let navStub;
+    let renameStub;
+    let renameAutocomplete;
+    const inputSelector = PolymerElement ?
+      '.newPathIronInput' :
+      '.newPathInput';
+
+    setup(() => {
+      navStub = sinon.stub(GerritNav, 'navigateToChange');
+      renameStub = sinon.stub(element.$.restAPI, 'renameFileInChangeEdit');
+      renameAutocomplete =
+          element.$.renameDialog.querySelector('gr-autocomplete');
+    });
+
+    test('rename', () => {
+      renameStub.returns(Promise.resolve({ok: true}));
+      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.renameDialog.disabled);
+        assert.isFalse(queryStub.called);
+        // Setup _focused manually - in headless mode Chrome sometimes don't
+        // setup focus. flush and/or flushAsynchronousOperations don't help
+        renameAutocomplete._focused = true;
+        renameAutocomplete.noDebounce = true;
+        renameAutocomplete.text = 'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isTrue(element.$.renameDialog.disabled);
+
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
+            'src/test.newPath';
+
+        assert.isFalse(element.$.renameDialog.disabled);
+        MockInteractions.tap(element.$.renameDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(renameStub.called);
+
+        return renameStub.lastCall.returnValue.then(() => {
+          assert.equal(element._path, '');
+          assert.isTrue(navStub.called);
+          assert.isTrue(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('rename fails', () => {
+      renameStub.returns(Promise.resolve({ok: false}));
+      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.renameDialog.disabled);
+        assert.isFalse(queryStub.called);
+        // Setup _focused manually - in headless mode Chrome sometimes don't
+        // setup focus. flush and/or flushAsynchronousOperations don't help
+        renameAutocomplete._focused = true;
+        renameAutocomplete.noDebounce = true;
+        renameAutocomplete.text = 'src/test.cpp';
+        assert.isTrue(queryStub.called);
+        assert.isTrue(element.$.renameDialog.disabled);
+
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
+            'src/test.newPath';
+
+        assert.isFalse(element.$.renameDialog.disabled);
+        MockInteractions.tap(element.$.renameDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(renameStub.called);
+
+        return renameStub.lastCall.returnValue.then(() => {
+          assert.isFalse(navStub.called);
+          assert.isFalse(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('cancel', () => {
+      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.renameDialog.disabled);
+        element.$.renameDialog.querySelector('gr-autocomplete').text =
+            'src/test.cpp';
+        element.$.renameDialog.querySelector(inputSelector).bindValue =
+            'src/test.newPath';
+        assert.isFalse(element.$.renameDialog.disabled);
+        MockInteractions.tap(element.$.renameDialog.shadowRoot
+            .querySelector('gr-button'));
+        assert.isFalse(navStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+        assert.equal(element._newPath, 'src/test.newPath');
+      });
+    });
+  });
+
+  suite('restore button CUJ', () => {
+    let navStub;
+    let restoreStub;
+
+    setup(() => {
+      navStub = sinon.stub(GerritNav, 'navigateToChange');
+      restoreStub = sinon.stub(element.$.restAPI, 'restoreFileInChangeEdit');
+    });
+
+    test('restore hidden by default', () => {
+      assert.isTrue(element.shadowRoot
+          .querySelector('#restore').classList.contains('invisible'));
+    });
+
+    test('restore', () => {
+      restoreStub.returns(Promise.resolve({ok: true}));
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(element.$.restoreDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(restoreStub.called);
+        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+        return restoreStub.lastCall.returnValue.then(() => {
+          assert.equal(element._path, '');
+          assert.isTrue(navStub.called);
+          assert.isTrue(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('restore fails', () => {
+      restoreStub.returns(Promise.resolve({ok: false}));
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(element.$.restoreDialog.shadowRoot
+            .querySelector('gr-button[primary]'));
+        flushAsynchronousOperations();
+
+        assert.isTrue(restoreStub.called);
+        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+        return restoreStub.lastCall.returnValue.then(() => {
+          assert.isFalse(navStub.called);
+          assert.isFalse(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('cancel', () => {
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(element.$.restoreDialog.shadowRoot
+            .querySelector('gr-button'));
+        assert.isFalse(navStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, 'src/test.cpp');
+      });
+    });
+  });
+
+  suite('save file upload', () => {
+    let navStub;
+    let fileStub;
+
+    setup(() => {
+      navStub = sinon.stub(GerritNav, 'navigateToChange');
+      fileStub = sinon.stub(element.$.restAPI, 'saveFileUploadChangeEdit');
+    });
+
+    test('_handleUploadConfirm', () => {
+      fileStub.returns(Promise.resolve({ok: true}));
+
+      element.change = {
+        _number: '1',
+        project: 'project',
+        revisions: {
+          abcd: {_number: 1},
+          efgh: {_number: 2},
+        },
+        current_revision: 'efgh',
+      };
+
+      element._handleUploadConfirm('test.php', 'base64').then(() => {
+        assert.equal(
+            navStub.lastCall.args,
+            '/c/project/+/1');
+      });
+    });
+  });
+
+  test('openOpenDialog', done => {
+    element.openOpenDialog('test/path.cpp')
+        .then(() => {
+          assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
+          assert.equal(
+              element.$.openDialog.querySelector('gr-autocomplete').text,
+              'test/path.cpp');
+          done();
+        });
+  });
+
+  test('_getDialogFromEvent', () => {
+    const spy = sinon.spy(element, '_getDialogFromEvent');
+    element.addEventListener('tap', element._getDialogFromEvent);
+
+    MockInteractions.tap(element.$.openDialog);
+    flushAsynchronousOperations();
+    assert.equal(spy.lastCall.returnValue.id, 'openDialog');
+
+    MockInteractions.tap(element.$.deleteDialog);
+    flushAsynchronousOperations();
+    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
+
+    MockInteractions.tap(
+        element.$.deleteDialog.querySelector('gr-autocomplete'));
+    flushAsynchronousOperations();
+    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
+
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.notOk(spy.lastCall.returnValue);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
index 8a24e23..b144bad 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-dropdown/gr-dropdown.js';
 import '../../../styles/shared-styles.js';
@@ -25,7 +23,7 @@
 import {htmlTemplate} from './gr-edit-file-controls_html.js';
 import {GrEditConstants} from '../gr-edit-constants.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrEditFileControls extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.js
deleted file mode 100644
index ec0b8b4..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      align-items: center;
-      display: flex;
-      justify-content: flex-end;
-    }
-    #actions {
-      margin-right: var(--spacing-l);
-    }
-    gr-button,
-    gr-dropdown {
-      --gr-button: {
-        height: 1.8em;
-      }
-    }
-    gr-dropdown {
-      --gr-dropdown-item: {
-        background-color: transparent;
-        border: none;
-        color: var(--link-color);
-        text-transform: uppercase;
-      }
-    }
-  </style>
-  <gr-dropdown
-    id="actions"
-    items="[[_fileActions]]"
-    down-arrow=""
-    vertical-offset="20"
-    on-tap-item="_handleActionTap"
-    link=""
-    >Actions</gr-dropdown
-  >
-`;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.ts
new file mode 100644
index 0000000..c6a6de7
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.ts
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      align-items: center;
+      display: flex;
+      justify-content: flex-end;
+    }
+    #actions {
+      margin-right: var(--spacing-l);
+    }
+    gr-button,
+    gr-dropdown {
+      --gr-button: {
+        height: 1.8em;
+      }
+    }
+    gr-dropdown {
+      --gr-dropdown-item: {
+        background-color: transparent;
+        border: none;
+        color: var(--link-color);
+        text-transform: uppercase;
+      }
+    }
+  </style>
+  <gr-dropdown
+    id="actions"
+    items="[[_fileActions]]"
+    down-arrow=""
+    vertical-offset="20"
+    on-tap-item="_handleActionTap"
+    link=""
+    >Actions</gr-dropdown
+  >
+`;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
deleted file mode 100644
index e11a2bd..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.html
+++ /dev/null
@@ -1,109 +0,0 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-edit-file-controls</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-edit-file-controls></gr-edit-file-controls>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../gr-edit-constants.js';
-import './gr-edit-file-controls.js';
-import {GrEditConstants} from '../gr-edit-constants.js';
-
-suite('gr-edit-file-controls tests', () => {
-  let element;
-  let sandbox;
-  let fileActionHandler;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    fileActionHandler = sandbox.stub();
-    element.addEventListener('file-action-tap', fileActionHandler);
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('open tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flushAsynchronousOperations();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="open"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.OPEN.id, path: 'foo'});
-  });
-
-  test('delete tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flushAsynchronousOperations();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="delete"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.DELETE.id, path: 'foo'});
-  });
-
-  test('restore tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flushAsynchronousOperations();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="restore"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.RESTORE.id, path: 'foo'});
-  });
-
-  test('rename tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flushAsynchronousOperations();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="rename"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.RENAME.id, path: 'foo'});
-  });
-
-  test('computed properties', () => {
-    assert.equal(element._allFileActions.length, 4);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
new file mode 100644
index 0000000..8a1c186
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-edit-constants.js';
+import './gr-edit-file-controls.js';
+import {GrEditConstants} from '../gr-edit-constants.js';
+
+const basicFixture = fixtureFromElement('gr-edit-file-controls');
+
+suite('gr-edit-file-controls tests', () => {
+  let element;
+
+  let fileActionHandler;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    fileActionHandler = sinon.stub();
+    element.addEventListener('file-action-tap', fileActionHandler);
+  });
+
+  test('open tap emits event', () => {
+    const actions = element.$.actions;
+    element.filePath = 'foo';
+    actions._open();
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(actions.shadowRoot
+        .querySelector('li [data-id="open"]'));
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
+        {action: GrEditConstants.Actions.OPEN.id, path: 'foo'});
+  });
+
+  test('delete tap emits event', () => {
+    const actions = element.$.actions;
+    element.filePath = 'foo';
+    actions._open();
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(actions.shadowRoot
+        .querySelector('li [data-id="delete"]'));
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
+        {action: GrEditConstants.Actions.DELETE.id, path: 'foo'});
+  });
+
+  test('restore tap emits event', () => {
+    const actions = element.$.actions;
+    element.filePath = 'foo';
+    actions._open();
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(actions.shadowRoot
+        .querySelector('li [data-id="restore"]'));
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
+        {action: GrEditConstants.Actions.RESTORE.id, path: 'foo'});
+  });
+
+  test('rename tap emits event', () => {
+    const actions = element.$.actions;
+    element.filePath = 'foo';
+    actions._open();
+    flushAsynchronousOperations();
+
+    MockInteractions.tap(actions.shadowRoot
+        .querySelector('li [data-id="rename"]'));
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
+        {action: GrEditConstants.Actions.RENAME.id, path: 'foo'});
+  });
+
+  test('computed properties', () => {
+    assert.equal(element._allFileActions.length, 4);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
index d2ffa56..d1677bd 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
 import '../../shared/gr-button/gr-button.js';
@@ -25,15 +23,14 @@
 import '../../shared/gr-storage/gr-storage.js';
 import '../gr-default-editor/gr-default-editor.js';
 import '../../../styles/shared-styles.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-editor-view_html.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
-import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+import {computeTruncatedPath} from '../../../utils/path-list-util.js';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -43,13 +40,9 @@
 const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrEditorView extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-  PatchSetBehavior,
-  PathListBehavior,
-], GestureEventListeners(
+class GrEditorView extends KeyboardShortcutMixin(GestureEventListeners(
     LegacyElementMixin(
         PolymerElement))) {
   static get template() { return htmlTemplate; }
@@ -141,14 +134,14 @@
 
     this._changeNum = value.changeNum;
     this._path = value.path;
-    this._patchNum = value.patchNum || this.EDIT_NAME;
+    this._patchNum = value.patchNum || SPECIAL_PATCH_SET_NUM.EDIT;
     this._lineNum = value.lineNum;
 
     // NOTE: This may be called before attachment (e.g. while parentElement is
     // null). Fire title-change in an async so that, if attachment to the DOM
     // has been queued, the event can bubble up to the handler in gr-app.
     this.async(() => {
-      const title = `Editing ${this.computeTruncatedPath(this._path)}`;
+      const title = `Editing ${computeTruncatedPath(this._path)}`;
       this.dispatchEvent(new CustomEvent('title-change', {
         detail: {title},
         composed: true, bubbles: true,
@@ -184,9 +177,10 @@
   }
 
   _viewEditInChangeView() {
-    const patch = this._successfulSave ? this.EDIT_NAME : this._patchNum;
+    const patch = this._successfulSave ? SPECIAL_PATCH_SET_NUM.EDIT
+      : this._patchNum;
     GerritNav.navigateToChange(this._change, patch, null,
-        patch !== this.EDIT_NAME);
+        patch !== SPECIAL_PATCH_SET_NUM.EDIT);
   }
 
   _getFileData(changeNum, path, patchNum) {
@@ -249,7 +243,7 @@
       content,
       newContent,
       saving,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return true;
     }
 
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.js
deleted file mode 100644
index 9dc35b2..0000000
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.js
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      background-color: var(--view-background-color);
-    }
-    gr-fixed-panel {
-      background-color: var(--edit-mode-background-color);
-      border-bottom: 1px var(--border-color) solid;
-      z-index: 1;
-    }
-    header,
-    .subHeader {
-      align-items: center;
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    header gr-editable-label {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-      --label-style: {
-        text-overflow: initial;
-        white-space: initial;
-        word-break: break-all;
-      }
-      --input-style: {
-        margin-top: var(--spacing-l);
-      }
-    }
-    .textareaWrapper {
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      margin: var(--spacing-l);
-    }
-    .textareaWrapper .editButtons {
-      display: none;
-    }
-    .controlGroup {
-      align-items: center;
-      display: flex;
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-    }
-    .rightControls {
-      justify-content: flex-end;
-    }
-    @media screen and (max-width: 50em) {
-      header,
-      .subHeader {
-        display: block;
-      }
-      .rightControls {
-        float: right;
-      }
-    }
-  </style>
-  <gr-fixed-panel keep-on-scroll="">
-    <header>
-      <span class="controlGroup">
-        <span>Edit mode</span>
-        <span class="separator"></span>
-        <gr-editable-label
-          label-text="File path"
-          value="[[_path]]"
-          placeholder="File path..."
-          on-changed="_handlePathChanged"
-        ></gr-editable-label>
-      </span>
-      <span class="controlGroup rightControls">
-        <gr-button id="close" link="" on-click="_handleCloseTap"
-          >Close</gr-button
-        >
-        <gr-button
-          id="save"
-          disabled$="[[_saveDisabled]]"
-          primary=""
-          link=""
-          on-click="_saveEdit"
-          >Save</gr-button
-        >
-      </span>
-    </header>
-  </gr-fixed-panel>
-  <div class="textareaWrapper">
-    <gr-endpoint-decorator id="editorEndpoint" name="editor">
-      <gr-endpoint-param
-        name="fileContent"
-        value="[[_newContent]]"
-      ></gr-endpoint-param>
-      <gr-endpoint-param name="prefs" value="[[_prefs]]"></gr-endpoint-param>
-      <gr-endpoint-param name="fileType" value="[[_type]]"></gr-endpoint-param>
-      <gr-endpoint-param
-        name="lineNum"
-        value="[[_lineNum]]"
-      ></gr-endpoint-param>
-      <gr-default-editor
-        id="file"
-        file-content="[[_newContent]]"
-      ></gr-default-editor>
-    </gr-endpoint-decorator>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
-`;
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
new file mode 100644
index 0000000..bd8304f
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
@@ -0,0 +1,126 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--view-background-color);
+    }
+    gr-fixed-panel {
+      background-color: var(--edit-mode-background-color);
+      border-bottom: 1px var(--border-color) solid;
+      z-index: 1;
+    }
+    header,
+    .subHeader {
+      align-items: center;
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    header gr-editable-label {
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h3);
+      font-weight: var(--font-weight-h3);
+      line-height: var(--line-height-h3);
+      --label-style: {
+        text-overflow: initial;
+        white-space: initial;
+        word-break: break-all;
+      }
+      --input-style: {
+        margin-top: var(--spacing-l);
+      }
+    }
+    .textareaWrapper {
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      margin: var(--spacing-l);
+    }
+    .textareaWrapper .editButtons {
+      display: none;
+    }
+    .controlGroup {
+      align-items: center;
+      display: flex;
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h3);
+      font-weight: var(--font-weight-h3);
+      line-height: var(--line-height-h3);
+    }
+    .rightControls {
+      justify-content: flex-end;
+    }
+    @media screen and (max-width: 50em) {
+      header,
+      .subHeader {
+        display: block;
+      }
+      .rightControls {
+        float: right;
+      }
+    }
+  </style>
+  <gr-fixed-panel keep-on-scroll="">
+    <header>
+      <span class="controlGroup">
+        <span>Edit mode</span>
+        <span class="separator"></span>
+        <gr-editable-label
+          label-text="File path"
+          value="[[_path]]"
+          placeholder="File path..."
+          on-changed="_handlePathChanged"
+        ></gr-editable-label>
+      </span>
+      <span class="controlGroup rightControls">
+        <gr-button id="close" link="" on-click="_handleCloseTap"
+          >Close</gr-button
+        >
+        <gr-button
+          id="save"
+          disabled$="[[_saveDisabled]]"
+          primary=""
+          link=""
+          on-click="_saveEdit"
+          >Save</gr-button
+        >
+      </span>
+    </header>
+  </gr-fixed-panel>
+  <div class="textareaWrapper">
+    <gr-endpoint-decorator id="editorEndpoint" name="editor">
+      <gr-endpoint-param
+        name="fileContent"
+        value="[[_newContent]]"
+      ></gr-endpoint-param>
+      <gr-endpoint-param name="prefs" value="[[_prefs]]"></gr-endpoint-param>
+      <gr-endpoint-param name="fileType" value="[[_type]]"></gr-endpoint-param>
+      <gr-endpoint-param
+        name="lineNum"
+        value="[[_lineNum]]"
+      ></gr-endpoint-param>
+      <gr-default-editor
+        id="file"
+        file-content="[[_newContent]]"
+      ></gr-default-editor>
+    </gr-endpoint-decorator>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-storage id="storage"></gr-storage>
+`;
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
deleted file mode 100644
index e385854..0000000
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.html
+++ /dev/null
@@ -1,412 +0,0 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-editor-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-editor-view></gr-editor-view>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-editor-view.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-editor-view tests', () => {
-  let element;
-  let sandbox;
-  let savePathStub;
-  let saveFileStub;
-  let changeDetailStub;
-  let navigateStub;
-  const mockParams = {
-    changeNum: '42',
-    path: 'foo/bar.baz',
-    patchNum: 'edit',
-  };
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-      getEditPreferences() { return Promise.resolve({}); },
-    });
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    savePathStub = sandbox.stub(element.$.restAPI, 'renameFileInChangeEdit');
-    saveFileStub = sandbox.stub(element.$.restAPI, 'saveChangeEdit');
-    changeDetailStub = sandbox.stub(element.$.restAPI, 'getDiffChangeDetail');
-    navigateStub = sandbox.stub(element, '_viewEditInChangeView');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  suite('_paramsChanged', () => {
-    test('incorrect view returns immediately', () => {
-      element._paramsChanged(
-          Object.assign({}, mockParams, {view: GerritNav.View.DIFF}));
-      assert.notOk(element._changeNum);
-    });
-
-    test('good params proceed', () => {
-      changeDetailStub.returns(Promise.resolve({}));
-      const fileStub = sandbox.stub(element, '_getFileData', () => {
-        element._content = 'text';
-        element._newContent = 'text';
-        element._type = 'application/octet-stream';
-      });
-
-      const promises = element._paramsChanged(
-          Object.assign({}, mockParams, {view: GerritNav.View.EDIT}));
-
-      flushAsynchronousOperations();
-      assert.equal(element._changeNum, mockParams.changeNum);
-      assert.equal(element._path, mockParams.path);
-      assert.deepEqual(changeDetailStub.lastCall.args[0],
-          mockParams.changeNum);
-      assert.deepEqual(fileStub.lastCall.args,
-          [mockParams.changeNum, mockParams.path, mockParams.patchNum]);
-
-      return promises.then(() => {
-        assert.equal(element._content, 'text');
-        assert.equal(element._newContent, 'text');
-        assert.equal(element._type, 'application/octet-stream');
-      });
-    });
-  });
-
-  test('edit file path', () => {
-    element._changeNum = mockParams.changeNum;
-    element._path = mockParams.path;
-    savePathStub.onFirstCall().returns(Promise.resolve({}));
-    savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
-
-    // Calling with the same path should not navigate.
-    return element._handlePathChanged({detail: mockParams.path}).then(() => {
-      assert.isFalse(savePathStub.called);
-      // !ok response
-      element._handlePathChanged({detail: 'newPath'}).then(() => {
-        assert.isTrue(savePathStub.called);
-        assert.isFalse(navigateStub.called);
-        // ok response
-        element._handlePathChanged({detail: 'newPath'}).then(() => {
-          assert.isTrue(navigateStub.called);
-          assert.isTrue(element._successfulSave);
-        });
-      });
-    });
-  });
-
-  test('reacts to content-change event', () => {
-    const storeStub = sandbox.spy(element.$.storage, 'setEditableContentItem');
-    element._newContent = 'test';
-    element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
-      bubbles: true, composed: true,
-      detail: {value: 'new content value'},
-    }));
-    element.flushDebouncer('store');
-    flushAsynchronousOperations();
-
-    assert.equal(element._newContent, 'new content value');
-    assert.isTrue(storeStub.called);
-    assert.equal(storeStub.lastCall.args[1], 'new content value');
-  });
-
-  suite('edit file content', () => {
-    const originalText = 'file text';
-    const newText = 'file text changed';
-
-    setup(() => {
-      element._changeNum = mockParams.changeNum;
-      element._path = mockParams.path;
-      element._content = originalText;
-      element._newContent = originalText;
-      flushAsynchronousOperations();
-    });
-
-    test('initial load', () => {
-      assert.equal(element.$.file.fileContent, originalText);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-    });
-
-    test('file modification and save, !ok response', () => {
-      const saveSpy = sandbox.spy(element, '_saveEdit');
-      const eraseStub = sandbox.stub(element.$.storage,
-          'eraseEditableContentItem');
-      const alertStub = sandbox.stub(element, '_showAlert');
-      saveFileStub.returns(Promise.resolve({ok: false}));
-      element._newContent = newText;
-      flushAsynchronousOperations();
-
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-      assert.isFalse(element._saving);
-
-      MockInteractions.tap(element.$.save);
-      assert.isTrue(saveSpy.called);
-      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-
-      return saveSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(saveFileStub.called);
-        assert.isTrue(eraseStub.called);
-        assert.isFalse(element._saving);
-        assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
-        assert.deepEqual(saveFileStub.lastCall.args,
-            [mockParams.changeNum, mockParams.path, newText]);
-        assert.isFalse(navigateStub.called);
-        assert.isFalse(element.$.save.hasAttribute('disabled'));
-        assert.notEqual(element._content, element._newContent);
-      });
-    });
-
-    test('file modification and save', () => {
-      const saveSpy = sandbox.spy(element, '_saveEdit');
-      const alertStub = sandbox.stub(element, '_showAlert');
-      saveFileStub.returns(Promise.resolve({ok: true}));
-      element._newContent = newText;
-      flushAsynchronousOperations();
-
-      assert.isFalse(element._saving);
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-
-      MockInteractions.tap(element.$.save);
-      assert.isTrue(saveSpy.called);
-      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-
-      return saveSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(saveFileStub.called);
-        assert.isFalse(element._saving);
-        assert.equal(alertStub.lastCall.args[0], 'All changes saved');
-        assert.isFalse(navigateStub.called);
-        assert.isTrue(element.$.save.hasAttribute('disabled'));
-        assert.equal(element._content, element._newContent);
-        assert.isTrue(element._successfulSave);
-      });
-    });
-
-    test('file modification and close', () => {
-      const closeSpy = sandbox.spy(element, '_handleCloseTap');
-      element._newContent = newText;
-      flushAsynchronousOperations();
-
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-
-      MockInteractions.tap(element.$.close);
-      assert.isTrue(closeSpy.called);
-      assert.isFalse(saveFileStub.called);
-      assert.isTrue(navigateStub.called);
-    });
-  });
-
-  suite('_getFileData', () => {
-    setup(() => {
-      element._newContent = 'initial';
-      element._content = 'initial';
-      element._type = 'initial';
-      sandbox.stub(element.$.storage, 'getEditableContentItem').returns(null);
-    });
-
-    test('res.ok', () => {
-      sandbox.stub(element.$.restAPI, 'getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-            content: 'new content',
-          }));
-
-      // Ensure no data is set with a bad response.
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, 'new content');
-        assert.equal(element._content, 'new content');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('!res.ok', () => {
-      sandbox.stub(element.$.restAPI, 'getFileContent')
-          .returns(Promise.resolve({}));
-
-      // Ensure no data is set with a bad response.
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, '');
-        assert.equal(element._content, '');
-        assert.equal(element._type, '');
-      });
-    });
-
-    test('content is undefined', () => {
-      sandbox.stub(element.$.restAPI, 'getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-          }));
-
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, '');
-        assert.equal(element._content, '');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('content and type is undefined', () => {
-      sandbox.stub(element.$.restAPI, 'getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-          }));
-
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, '');
-        assert.equal(element._content, '');
-        assert.equal(element._type, '');
-      });
-    });
-  });
-
-  test('_showAlert', done => {
-    element.addEventListener('show-alert', e => {
-      assert.deepEqual(e.detail, {message: 'test message'});
-      assert.isTrue(e.bubbles);
-      done();
-    });
-
-    element._showAlert('test message');
-  });
-
-  test('_viewEditInChangeView respects _patchNum', () => {
-    navigateStub.restore();
-    const navStub = sandbox.stub(GerritNav, 'navigateToChange');
-    element._patchNum = element.EDIT_NAME;
-    element._viewEditInChangeView();
-    assert.equal(navStub.lastCall.args[1], element.EDIT_NAME);
-    element._patchNum = '1';
-    element._viewEditInChangeView();
-    assert.equal(navStub.lastCall.args[1], '1');
-    element._successfulSave = true;
-    element._viewEditInChangeView();
-    assert.equal(navStub.lastCall.args[1], element.EDIT_NAME);
-  });
-
-  suite('keyboard shortcuts', () => {
-    // Used as the spy on the handler for each entry in keyBindings.
-    let handleSpy;
-
-    suite('_handleSaveShortcut', () => {
-      let saveStub;
-      setup(() => {
-        handleSpy = sandbox.spy(element, '_handleSaveShortcut');
-        saveStub = sandbox.stub(element, '_saveEdit');
-      });
-
-      test('save enabled', () => {
-        element._content = '';
-        element._newContent = '_test';
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-        flushAsynchronousOperations();
-
-        assert.isTrue(handleSpy.calledOnce);
-        assert.isTrue(saveStub.calledOnce);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-        flushAsynchronousOperations();
-
-        assert.equal(handleSpy.callCount, 2);
-        assert.equal(saveStub.callCount, 2);
-      });
-
-      test('save disabled', () => {
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-        flushAsynchronousOperations();
-
-        assert.isTrue(handleSpy.calledOnce);
-        assert.isFalse(saveStub.called);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-        flushAsynchronousOperations();
-
-        assert.equal(handleSpy.callCount, 2);
-        assert.isFalse(saveStub.called);
-      });
-    });
-  });
-
-  suite('gr-storage caching', () => {
-    test('local edit exists', () => {
-      sandbox.stub(element.$.storage, 'getEditableContentItem')
-          .returns({message: 'pending edit'});
-      sandbox.stub(element.$.restAPI, 'getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-            content: 'old content',
-          }));
-
-      const alertStub = sandbox.stub();
-      element.addEventListener('show-alert', alertStub);
-
-      return element._getFileData(1, 'test', 1).then(() => {
-        flushAsynchronousOperations();
-
-        assert.isTrue(alertStub.called);
-        assert.equal(element._newContent, 'pending edit');
-        assert.equal(element._content, 'old content');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('local edit exists, is same as remote edit', () => {
-      sandbox.stub(element.$.storage, 'getEditableContentItem')
-          .returns({message: 'pending edit'});
-      sandbox.stub(element.$.restAPI, 'getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-            content: 'pending edit',
-          }));
-
-      const alertStub = sandbox.stub();
-      element.addEventListener('show-alert', alertStub);
-
-      return element._getFileData(1, 'test', 1).then(() => {
-        flushAsynchronousOperations();
-
-        assert.isFalse(alertStub.called);
-        assert.equal(element._newContent, 'pending edit');
-        assert.equal(element._content, 'pending edit');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('storage key computation', () => {
-      element._changeNum = 1;
-      element._patchNum = 1;
-      element._path = 'test';
-      assert.equal(element.storageKey, 'c1_ps1_test');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
new file mode 100644
index 0000000..a673955
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
@@ -0,0 +1,397 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-editor-view.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+
+const basicFixture = fixtureFromElement('gr-editor-view');
+
+suite('gr-editor-view tests', () => {
+  let element;
+
+  let savePathStub;
+  let saveFileStub;
+  let changeDetailStub;
+  let navigateStub;
+  const mockParams = {
+    changeNum: '42',
+    path: 'foo/bar.baz',
+    patchNum: 'edit',
+  };
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+      getEditPreferences() { return Promise.resolve({}); },
+    });
+
+    element = basicFixture.instantiate();
+    savePathStub = sinon.stub(element.$.restAPI, 'renameFileInChangeEdit');
+    saveFileStub = sinon.stub(element.$.restAPI, 'saveChangeEdit');
+    changeDetailStub = sinon.stub(element.$.restAPI, 'getDiffChangeDetail');
+    navigateStub = sinon.stub(element, '_viewEditInChangeView');
+  });
+
+  suite('_paramsChanged', () => {
+    test('incorrect view returns immediately', () => {
+      element._paramsChanged(
+          {...mockParams, view: GerritNav.View.DIFF});
+      assert.notOk(element._changeNum);
+    });
+
+    test('good params proceed', () => {
+      changeDetailStub.returns(Promise.resolve({}));
+      const fileStub = sinon.stub(element, '_getFileData').callsFake(() => {
+        element._content = 'text';
+        element._newContent = 'text';
+        element._type = 'application/octet-stream';
+      });
+
+      const promises = element._paramsChanged(
+          {...mockParams, view: GerritNav.View.EDIT});
+
+      flushAsynchronousOperations();
+      assert.equal(element._changeNum, mockParams.changeNum);
+      assert.equal(element._path, mockParams.path);
+      assert.deepEqual(changeDetailStub.lastCall.args[0],
+          mockParams.changeNum);
+      assert.deepEqual(fileStub.lastCall.args,
+          [mockParams.changeNum, mockParams.path, mockParams.patchNum]);
+
+      return promises.then(() => {
+        assert.equal(element._content, 'text');
+        assert.equal(element._newContent, 'text');
+        assert.equal(element._type, 'application/octet-stream');
+      });
+    });
+  });
+
+  test('edit file path', () => {
+    element._changeNum = mockParams.changeNum;
+    element._path = mockParams.path;
+    savePathStub.onFirstCall().returns(Promise.resolve({}));
+    savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
+
+    // Calling with the same path should not navigate.
+    return element._handlePathChanged({detail: mockParams.path}).then(() => {
+      assert.isFalse(savePathStub.called);
+      // !ok response
+      element._handlePathChanged({detail: 'newPath'}).then(() => {
+        assert.isTrue(savePathStub.called);
+        assert.isFalse(navigateStub.called);
+        // ok response
+        element._handlePathChanged({detail: 'newPath'}).then(() => {
+          assert.isTrue(navigateStub.called);
+          assert.isTrue(element._successfulSave);
+        });
+      });
+    });
+  });
+
+  test('reacts to content-change event', () => {
+    const storeStub = sinon.spy(element.$.storage, 'setEditableContentItem');
+    element._newContent = 'test';
+    element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
+      bubbles: true, composed: true,
+      detail: {value: 'new content value'},
+    }));
+    element.flushDebouncer('store');
+    flushAsynchronousOperations();
+
+    assert.equal(element._newContent, 'new content value');
+    assert.isTrue(storeStub.called);
+    assert.equal(storeStub.lastCall.args[1], 'new content value');
+  });
+
+  suite('edit file content', () => {
+    const originalText = 'file text';
+    const newText = 'file text changed';
+
+    setup(() => {
+      element._changeNum = mockParams.changeNum;
+      element._path = mockParams.path;
+      element._content = originalText;
+      element._newContent = originalText;
+      flushAsynchronousOperations();
+    });
+
+    test('initial load', () => {
+      assert.equal(element.$.file.fileContent, originalText);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+    });
+
+    test('file modification and save, !ok response', () => {
+      const saveSpy = sinon.spy(element, '_saveEdit');
+      const eraseStub = sinon.stub(element.$.storage,
+          'eraseEditableContentItem');
+      const alertStub = sinon.stub(element, '_showAlert');
+      saveFileStub.returns(Promise.resolve({ok: false}));
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+      assert.isFalse(element._saving);
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isTrue(eraseStub.called);
+        assert.isFalse(element._saving);
+        assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
+        assert.deepEqual(saveFileStub.lastCall.args,
+            [mockParams.changeNum, mockParams.path, newText]);
+        assert.isFalse(navigateStub.called);
+        assert.isFalse(element.$.save.hasAttribute('disabled'));
+        assert.notEqual(element._content, element._newContent);
+      });
+    });
+
+    test('file modification and save', () => {
+      const saveSpy = sinon.spy(element, '_saveEdit');
+      const alertStub = sinon.stub(element, '_showAlert');
+      saveFileStub.returns(Promise.resolve({ok: true}));
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.isFalse(element._saving);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isFalse(element._saving);
+        assert.equal(alertStub.lastCall.args[0], 'All changes saved');
+        assert.isFalse(navigateStub.called);
+        assert.isTrue(element.$.save.hasAttribute('disabled'));
+        assert.equal(element._content, element._newContent);
+        assert.isTrue(element._successfulSave);
+      });
+    });
+
+    test('file modification and close', () => {
+      const closeSpy = sinon.spy(element, '_handleCloseTap');
+      element._newContent = newText;
+      flushAsynchronousOperations();
+
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.close);
+      assert.isTrue(closeSpy.called);
+      assert.isFalse(saveFileStub.called);
+      assert.isTrue(navigateStub.called);
+    });
+  });
+
+  suite('_getFileData', () => {
+    setup(() => {
+      element._newContent = 'initial';
+      element._content = 'initial';
+      element._type = 'initial';
+      sinon.stub(element.$.storage, 'getEditableContentItem').returns(null);
+    });
+
+    test('res.ok', () => {
+      sinon.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+            content: 'new content',
+          }));
+
+      // Ensure no data is set with a bad response.
+      return element._getFileData('1', 'test/path', 'edit').then(() => {
+        assert.equal(element._newContent, 'new content');
+        assert.equal(element._content, 'new content');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('!res.ok', () => {
+      sinon.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({}));
+
+      // Ensure no data is set with a bad response.
+      return element._getFileData('1', 'test/path', 'edit').then(() => {
+        assert.equal(element._newContent, '');
+        assert.equal(element._content, '');
+        assert.equal(element._type, '');
+      });
+    });
+
+    test('content is undefined', () => {
+      sinon.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+          }));
+
+      return element._getFileData('1', 'test/path', 'edit').then(() => {
+        assert.equal(element._newContent, '');
+        assert.equal(element._content, '');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('content and type is undefined', () => {
+      sinon.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+          }));
+
+      return element._getFileData('1', 'test/path', 'edit').then(() => {
+        assert.equal(element._newContent, '');
+        assert.equal(element._content, '');
+        assert.equal(element._type, '');
+      });
+    });
+  });
+
+  test('_showAlert', done => {
+    element.addEventListener('show-alert', e => {
+      assert.deepEqual(e.detail, {message: 'test message'});
+      assert.isTrue(e.bubbles);
+      done();
+    });
+
+    element._showAlert('test message');
+  });
+
+  test('_viewEditInChangeView respects _patchNum', () => {
+    navigateStub.restore();
+    const navStub = sinon.stub(GerritNav, 'navigateToChange');
+    element._patchNum = SPECIAL_PATCH_SET_NUM.EDIT;
+    element._viewEditInChangeView();
+    assert.equal(navStub.lastCall.args[1], SPECIAL_PATCH_SET_NUM.EDIT);
+    element._patchNum = '1';
+    element._viewEditInChangeView();
+    assert.equal(navStub.lastCall.args[1], '1');
+    element._successfulSave = true;
+    element._viewEditInChangeView();
+    assert.equal(navStub.lastCall.args[1], SPECIAL_PATCH_SET_NUM.EDIT);
+  });
+
+  suite('keyboard shortcuts', () => {
+    // Used as the spy on the handler for each entry in keyBindings.
+    let handleSpy;
+
+    suite('_handleSaveShortcut', () => {
+      let saveStub;
+      setup(() => {
+        handleSpy = sinon.spy(element, '_handleSaveShortcut');
+        saveStub = sinon.stub(element, '_saveEdit');
+      });
+
+      test('save enabled', () => {
+        element._content = '';
+        element._newContent = '_test';
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
+        flushAsynchronousOperations();
+
+        assert.isTrue(handleSpy.calledOnce);
+        assert.isTrue(saveStub.calledOnce);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
+        flushAsynchronousOperations();
+
+        assert.equal(handleSpy.callCount, 2);
+        assert.equal(saveStub.callCount, 2);
+      });
+
+      test('save disabled', () => {
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
+        flushAsynchronousOperations();
+
+        assert.isTrue(handleSpy.calledOnce);
+        assert.isFalse(saveStub.called);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
+        flushAsynchronousOperations();
+
+        assert.equal(handleSpy.callCount, 2);
+        assert.isFalse(saveStub.called);
+      });
+    });
+  });
+
+  suite('gr-storage caching', () => {
+    test('local edit exists', () => {
+      sinon.stub(element.$.storage, 'getEditableContentItem')
+          .returns({message: 'pending edit'});
+      sinon.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+            content: 'old content',
+          }));
+
+      const alertStub = sinon.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      return element._getFileData(1, 'test', 1).then(() => {
+        flushAsynchronousOperations();
+
+        assert.isTrue(alertStub.called);
+        assert.equal(element._newContent, 'pending edit');
+        assert.equal(element._content, 'old content');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('local edit exists, is same as remote edit', () => {
+      sinon.stub(element.$.storage, 'getEditableContentItem')
+          .returns({message: 'pending edit'});
+      sinon.stub(element.$.restAPI, 'getFileContent')
+          .returns(Promise.resolve({
+            ok: true,
+            type: 'text/javascript',
+            content: 'pending edit',
+          }));
+
+      const alertStub = sinon.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      return element._getFileData(1, 'test', 1).then(() => {
+        flushAsynchronousOperations();
+
+        assert.isFalse(alertStub.called);
+        assert.equal(element._newContent, 'pending edit');
+        assert.equal(element._content, 'pending edit');
+        assert.equal(element._type, 'text/javascript');
+      });
+    });
+
+    test('storage key computation', () => {
+      element._changeNum = 1;
+      element._patchNum = 1;
+      element._path = 'test';
+      assert.equal(element.storageKey, 'c1_ps1_test');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index 6d232f8..41802c2 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -14,9 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../scripts/bundled-polymer.js';
 import '../styles/shared-styles.js';
 import '../styles/themes/app-theme.js';
+import {applyTheme as applyDarkTheme} from '../styles/themes/dark-theme.js';
 import './admin/gr-admin-view/gr-admin-view.js';
 import './documentation/gr-documentation-search/gr-documentation-search.js';
 import './change-list/gr-change-list-view/gr-change-list-view.js';
@@ -25,7 +25,6 @@
 import './core/gr-error-manager/gr-error-manager.js';
 import './core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js';
 import './core/gr-main-header/gr-main-header.js';
-import './core/gr-reporting/gr-reporting.js';
 import './core/gr-router/gr-router.js';
 import './core/gr-smart-search/gr-smart-search.js';
 import './diff/gr-diff-view/gr-diff-view.js';
@@ -41,24 +40,25 @@
 import './shared/gr-fixed-panel/gr-fixed-panel.js';
 import './shared/gr-lib-loader/gr-lib-loader.js';
 import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-app-element_html.js';
-import {BaseUrlBehavior} from '../behaviors/base-url-behavior/base-url-behavior.js';
-import {KeyboardShortcutBehavior} from '../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {getBaseUrl} from '../utils/url-util.js';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+  SPECIAL_SHORTCUT,
+} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {GerritNav} from './core/gr-navigation/gr-navigation.js';
+import {appContext} from '../services/app-context.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrAppElement extends mixinBehaviors( [
-  BaseUrlBehavior,
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrAppElement extends KeyboardShortcutMixin(
+    GestureEventListeners(
+        LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-app-element'; }
@@ -148,15 +148,20 @@
 
   keyboardShortcuts() {
     return {
-      [this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
-      [this.Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
-      [this.Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
-      [this.Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
-      [this.Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
-      [this.Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
+      [Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
+      [Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
+      [Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
+      [Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
+      [Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
+      [Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
     };
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   /** @override */
   created() {
     super.created();
@@ -171,17 +176,23 @@
         e => this._handleRpcLog(e));
     this.addEventListener('shortcut-triggered',
         e => this._handleShortcutTriggered(e));
+    // Ideally individual views should handle this event and respond with a soft
+    // reload. This is a catch-all for all views that cannot or have not
+    // implemented that.
+    this.addEventListener('reload', e => window.location.reload());
   }
 
   /** @override */
   ready() {
     super.ready();
     this._updateLoginUrl();
-    this.$.reporting.appStarted();
+    this.reporting.appStarted();
     this.$.router.start();
 
     this.$.restAPI.getAccount().then(account => {
       this._account = account;
+      const role = account ? 'user' : 'guest';
+      this.reporting.reportLifeCycle(`Started as ${role}`);
     });
     this.$.restAPI.getConfig().then(config => {
       this._serverConfig = config;
@@ -196,9 +207,7 @@
     });
 
     if (window.localStorage.getItem('dark-theme')) {
-      // No need to add the style module to element again as it's imported
-      // by importHref already
-      this.$.libLoader.getDarkTheme();
+      applyDarkTheme();
     }
 
     // Note: this is evaluated here to ensure that it only happens after the
@@ -227,131 +236,150 @@
   }
 
   _bindKeyboardShortcuts() {
-    this.bindShortcut(this.Shortcut.SEND_REPLY,
-        this.DOC_ONLY, 'ctrl+enter', 'meta+enter');
-    this.bindShortcut(this.Shortcut.EMOJI_DROPDOWN,
-        this.DOC_ONLY, ':');
+    this.bindShortcut(Shortcut.SEND_REPLY,
+        SPECIAL_SHORTCUT.DOC_ONLY, 'ctrl+enter', 'meta+enter');
+    this.bindShortcut(Shortcut.EMOJI_DROPDOWN,
+        SPECIAL_SHORTCUT.DOC_ONLY, ':');
 
     this.bindShortcut(
-        this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
+        Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
     this.bindShortcut(
-        this.Shortcut.GO_TO_USER_DASHBOARD, this.GO_KEY, 'i');
+        Shortcut.GO_TO_USER_DASHBOARD, SPECIAL_SHORTCUT.GO_KEY, 'i');
     this.bindShortcut(
-        this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o');
+        Shortcut.GO_TO_OPENED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'o');
     this.bindShortcut(
-        this.Shortcut.GO_TO_MERGED_CHANGES, this.GO_KEY, 'm');
+        Shortcut.GO_TO_MERGED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'm');
     this.bindShortcut(
-        this.Shortcut.GO_TO_ABANDONED_CHANGES, this.GO_KEY, 'a');
+        Shortcut.GO_TO_ABANDONED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'a');
     this.bindShortcut(
-        this.Shortcut.GO_TO_WATCHED_CHANGES, this.GO_KEY, 'w');
+        Shortcut.GO_TO_WATCHED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'w');
 
     this.bindShortcut(
-        this.Shortcut.CURSOR_NEXT_CHANGE, 'j');
+        Shortcut.CURSOR_NEXT_CHANGE, 'j');
     this.bindShortcut(
-        this.Shortcut.CURSOR_PREV_CHANGE, 'k');
+        Shortcut.CURSOR_PREV_CHANGE, 'k');
     this.bindShortcut(
-        this.Shortcut.OPEN_CHANGE, 'o');
+        Shortcut.OPEN_CHANGE, 'o');
     this.bindShortcut(
-        this.Shortcut.NEXT_PAGE, 'n', ']');
+        Shortcut.NEXT_PAGE, 'n', ']');
     this.bindShortcut(
-        this.Shortcut.PREV_PAGE, 'p', '[');
+        Shortcut.PREV_PAGE, 'p', '[');
     this.bindShortcut(
-        this.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
+        Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
     this.bindShortcut(
-        this.Shortcut.TOGGLE_CHANGE_STAR, 's:keyup');
+        Shortcut.TOGGLE_CHANGE_STAR, 's:keyup');
     this.bindShortcut(
-        this.Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
+        Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
     this.bindShortcut(
-        this.Shortcut.EDIT_TOPIC, 't');
+        Shortcut.EDIT_TOPIC, 't');
 
     this.bindShortcut(
-        this.Shortcut.OPEN_REPLY_DIALOG, 'a');
+        Shortcut.OPEN_REPLY_DIALOG, 'a:keyup');
     this.bindShortcut(
-        this.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+        Shortcut.OPEN_DOWNLOAD_DIALOG, 'd:keyup');
     this.bindShortcut(
-        this.Shortcut.EXPAND_ALL_MESSAGES, 'x');
+        Shortcut.EXPAND_ALL_MESSAGES, 'x');
     this.bindShortcut(
-        this.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+        Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
     this.bindShortcut(
-        this.Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
+        Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
     this.bindShortcut(
-        this.Shortcut.UP_TO_DASHBOARD, 'u');
+        Shortcut.UP_TO_DASHBOARD, 'u');
     this.bindShortcut(
-        this.Shortcut.UP_TO_CHANGE, 'u');
+        Shortcut.UP_TO_CHANGE, 'u');
     this.bindShortcut(
-        this.Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
+        Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
+    this.bindShortcut(
+        Shortcut.DIFF_AGAINST_BASE, SPECIAL_SHORTCUT.V_KEY, 'down', 's');
+    // this keyboard shortcut is used in toast _displayDiffAgainstLatestToast
+    // in gr-diff-view. Any updates here should be reflected there
+    this.bindShortcut(
+        Shortcut.DIFF_AGAINST_LATEST, SPECIAL_SHORTCUT.V_KEY, 'up', 'w');
+    // this keyboard shortcut is used in toast _displayDiffBaseAgainstLeftToast
+    // in gr-diff-view. Any updates here should be reflected there
+    this.bindShortcut(
+        Shortcut.DIFF_BASE_AGAINST_LEFT,
+        SPECIAL_SHORTCUT.V_KEY, 'left', 'a');
+    this.bindShortcut(
+        Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+        SPECIAL_SHORTCUT.V_KEY, 'right', 'd');
+    this.bindShortcut(
+        Shortcut.DIFF_BASE_AGAINST_LATEST, SPECIAL_SHORTCUT.V_KEY, 'b');
 
     this.bindShortcut(
-        this.Shortcut.NEXT_LINE, 'j', 'down');
+        Shortcut.NEXT_LINE, 'j', 'down');
     this.bindShortcut(
-        this.Shortcut.PREV_LINE, 'k', 'up');
+        Shortcut.PREV_LINE, 'k', 'up');
     if (this._isCursorManagerSupportMoveToVisibleLine()) {
       this.bindShortcut(
-          this.Shortcut.VISIBLE_LINE, '.');
+          Shortcut.VISIBLE_LINE, '.');
     }
     this.bindShortcut(
-        this.Shortcut.NEXT_CHUNK, 'n');
+        Shortcut.NEXT_CHUNK, 'n');
     this.bindShortcut(
-        this.Shortcut.PREV_CHUNK, 'p');
+        Shortcut.PREV_CHUNK, 'p');
     this.bindShortcut(
-        this.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+        Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
     this.bindShortcut(
-        this.Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+        Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
     this.bindShortcut(
-        this.Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+        Shortcut.PREV_COMMENT_THREAD, 'shift+p');
     this.bindShortcut(
-        this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e');
+        Shortcut.EXPAND_ALL_COMMENT_THREADS,
+        SPECIAL_SHORTCUT.DOC_ONLY, 'e');
     this.bindShortcut(
-        this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
-        this.DOC_ONLY, 'shift+e');
+        Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+        SPECIAL_SHORTCUT.DOC_ONLY, 'shift+e');
     this.bindShortcut(
-        this.Shortcut.LEFT_PANE, 'shift+left');
+        Shortcut.LEFT_PANE, 'shift+left');
     this.bindShortcut(
-        this.Shortcut.RIGHT_PANE, 'shift+right');
+        Shortcut.RIGHT_PANE, 'shift+right');
     this.bindShortcut(
-        this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+        Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
     this.bindShortcut(
-        this.Shortcut.NEW_COMMENT, 'c');
+        Shortcut.NEW_COMMENT, 'c');
     this.bindShortcut(
-        this.Shortcut.SAVE_COMMENT,
+        Shortcut.SAVE_COMMENT,
         'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
     this.bindShortcut(
-        this.Shortcut.OPEN_DIFF_PREFS, ',');
+        Shortcut.OPEN_DIFF_PREFS, ',');
     this.bindShortcut(
-        this.Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
+        Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
 
     this.bindShortcut(
-        this.Shortcut.NEXT_FILE, ']');
+        Shortcut.NEXT_FILE, ']');
     this.bindShortcut(
-        this.Shortcut.PREV_FILE, '[');
+        Shortcut.PREV_FILE, '[');
     this.bindShortcut(
-        this.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+        Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
     this.bindShortcut(
-        this.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+        Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
     this.bindShortcut(
-        this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+        Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
     this.bindShortcut(
-        this.Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+        Shortcut.CURSOR_PREV_FILE, 'k', 'up');
     this.bindShortcut(
-        this.Shortcut.OPEN_FILE, 'o', 'enter');
+        Shortcut.OPEN_FILE, 'o', 'enter');
     this.bindShortcut(
-        this.Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
+        Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
     this.bindShortcut(
-        this.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+        Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
     this.bindShortcut(
-        this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+        Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
     this.bindShortcut(
-        this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+        Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
     this.bindShortcut(
-        this.Shortcut.TOGGLE_BLAME, 'b');
+        Shortcut.TOGGLE_BLAME, 'b');
+    this.bindShortcut(
+        Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
 
     this.bindShortcut(
-        this.Shortcut.OPEN_FIRST_FILE, ']');
+        Shortcut.OPEN_FIRST_FILE, ']');
     this.bindShortcut(
-        this.Shortcut.OPEN_LAST_FILE, '[');
+        Shortcut.OPEN_LAST_FILE, '[');
 
     this.bindShortcut(
-        this.Shortcut.SEARCH, '/');
+        Shortcut.SEARCH, '/');
   }
 
   _isCursorManagerSupportMoveToVisibleLine() {
@@ -404,15 +432,16 @@
   }
 
   _handleShortcutTriggered(event) {
-    const {event: e, goKey} = event.detail;
+    const {event: e, goKey, vKey} = event.detail;
     // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
     let key = `${e.key}:${e.type}`;
     if (goKey) key = 'g+' + key;
+    if (vKey) key = 'v+' + key;
     if (e.shiftKey) key = 'shift+' + key;
     if (e.ctrlKey) key = 'ctrl+' + key;
     if (e.metaKey) key = 'meta+' + key;
     if (e.altKey) key = 'alt+' + key;
-    this.$.reporting.reportInteraction('shortcut-triggered', {
+    this.reporting.reportInteraction('shortcut-triggered', {
       key,
       from: event.path && event.path[0]
         && event.path[0].nodeName || 'unknown',
@@ -459,10 +488,10 @@
   }
 
   _updateLoginUrl() {
-    const baseUrl = this.getBaseUrl();
+    const baseUrl = getBaseUrl();
     if (baseUrl) {
       // Strip the canonical path from the path since needing canonical in
-      // the path is uneeded and breaks the url.
+      // the path is unneeded and breaks the url.
       this._loginUrl = baseUrl + '/login/' + encodeURIComponent(
           '/' + window.location.pathname.substring(baseUrl.length) +
           window.location.search +
@@ -491,6 +520,10 @@
     }
   }
 
+  handleShowKeyboardShortcuts() {
+    this.$.keyboardShortcuts.open();
+  }
+
   _showKeyboardShortcuts(e) {
     // same shortcut should close the dialog if pressed again
     // when dialog is open
@@ -546,13 +579,13 @@
 
   _logWelcome() {
     console.group('Runtime Info');
-    console.log('Gerrit UI (PolyGerrit)');
-    console.log(`Gerrit Server Version: ${this._version}`);
+    console.info('Gerrit UI (PolyGerrit)');
+    console.info(`Gerrit Server Version: ${this._version}`);
     if (window.VERSION_INFO) {
-      console.log(`UI Version Info: ${window.VERSION_INFO}`);
+      console.info(`UI Version Info: ${window.VERSION_INFO}`);
     }
     if (this._feedbackUrl) {
-      console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
+      console.info(`Please file bugs and feedback at: ${this._feedbackUrl}`);
     }
     console.groupEnd();
   }
@@ -563,7 +596,7 @@
    * that would create a cyclic dependency.
    */
   _handleRpcLog(e) {
-    this.$.reporting.reportRpcTiming(e.detail.anonymizedUrl,
+    this.reporting.reportRpcTiming(e.detail.anonymizedUrl,
         e.detail.elapsed);
   }
 
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.js b/polygerrit-ui/app/elements/gr-app-element_html.js
deleted file mode 100644
index 2ee48c1..0000000
--- a/polygerrit-ui/app/elements/gr-app-element_html.js
+++ /dev/null
@@ -1,234 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      background-color: var(--background-color-tertiary);
-      display: flex;
-      flex-direction: column;
-      min-height: 100%;
-    }
-    gr-fixed-panel {
-      /**
-         * This one should be greater that the z-index in gr-diff-view
-         * because gr-main-header contains overlay.
-         */
-      z-index: 10;
-    }
-    gr-main-header,
-    footer {
-      color: var(--primary-text-color);
-    }
-    gr-main-header {
-      background: var(
-        --header-background,
-        var(--header-background-color, #eee)
-      );
-      padding: var(--header-padding);
-      border-bottom: var(--header-border-bottom);
-      border-image: var(--header-border-image);
-      border-right: 0;
-      border-left: 0;
-      border-top: 0;
-      box-shadow: var(--header-box-shadow);
-    }
-    footer {
-      background: var(
-        --footer-background,
-        var(--footer-background-color, #eee)
-      );
-      border-top: var(--footer-border-top);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-m) var(--spacing-l);
-      z-index: 100;
-    }
-    main {
-      flex: 1;
-      padding-bottom: var(--spacing-xxl);
-      position: relative;
-    }
-    .errorView {
-      align-items: center;
-      display: none;
-      flex-direction: column;
-      justify-content: center;
-      position: absolute;
-      top: 0;
-      right: 0;
-      bottom: 0;
-      left: 0;
-    }
-    .errorView.show {
-      display: flex;
-    }
-    .errorEmoji {
-      font-size: 2.6rem;
-    }
-    .errorText,
-    .errorMoreInfo {
-      margin-top: var(--spacing-m);
-    }
-    .errorText {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-    }
-    .errorMoreInfo {
-      color: var(--deemphasized-text-color);
-    }
-    .feedback {
-      color: var(--error-text-color);
-    }
-  </style>
-  <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
-  <gr-fixed-panel id="header">
-    <gr-main-header
-      id="mainHeader"
-      search-query="{{params.query}}"
-      on-mobile-search="_mobileSearchToggle"
-      login-url="[[_loginUrl]]"
-    >
-    </gr-main-header>
-  </gr-fixed-panel>
-  <main>
-    <gr-smart-search
-      id="search"
-      search-query="{{params.query}}"
-      hidden="[[!mobileSearch]]"
-    >
-    </gr-smart-search>
-    <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
-      <gr-change-list-view
-        params="[[params]]"
-        account="[[_account]]"
-        view-state="{{_viewState.changeListView}}"
-      ></gr-change-list-view>
-    </template>
-    <template is="dom-if" if="[[_showDashboardView]]" restamp="true">
-      <gr-dashboard-view
-        account="[[_account]]"
-        params="[[params]]"
-        view-state="{{_viewState.dashboardView}}"
-      ></gr-dashboard-view>
-    </template>
-    <template is="dom-if" if="[[_showChangeView]]" restamp="true">
-      <gr-change-view
-        params="[[params]]"
-        view-state="{{_viewState.changeView}}"
-        back-page="[[_lastSearchPage]]"
-      ></gr-change-view>
-    </template>
-    <template is="dom-if" if="[[_showEditorView]]" restamp="true">
-      <gr-editor-view params="[[params]]"></gr-editor-view>
-    </template>
-    <template is="dom-if" if="[[_showDiffView]]" restamp="true">
-      <gr-diff-view
-        params="[[params]]"
-        change-view-state="{{_viewState.changeView}}"
-      ></gr-diff-view>
-    </template>
-    <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
-      <gr-settings-view
-        params="[[params]]"
-        on-account-detail-update="_handleAccountDetailUpdate"
-      >
-      </gr-settings-view>
-    </template>
-    <template is="dom-if" if="[[_showAdminView]]" restamp="true">
-      <gr-admin-view path="[[_path]]" params="[[params]]"></gr-admin-view>
-    </template>
-    <template is="dom-if" if="[[_showPluginScreen]]" restamp="true">
-      <gr-endpoint-decorator name="[[_pluginScreenName]]">
-        <gr-endpoint-param
-          name="token"
-          value="[[params.screen]]"
-        ></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </template>
-    <template is="dom-if" if="[[_showCLAView]]" restamp="true">
-      <gr-cla-view></gr-cla-view>
-    </template>
-    <template is="dom-if" if="[[_showDocumentationSearch]]" restamp="true">
-      <gr-documentation-search params="[[params]]"> </gr-documentation-search>
-    </template>
-    <div id="errorView" class="errorView">
-      <div class="errorEmoji">[[_lastError.emoji]]</div>
-      <div class="errorText">[[_lastError.text]]</div>
-      <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
-    </div>
-  </main>
-  <footer r="contentinfo">
-    <div>
-      Powered by
-      <a href="https://www.gerritcodereview.com/" rel="noopener" target="_blank"
-        >Gerrit Code Review</a
-      >
-      ([[_version]])
-      <gr-endpoint-decorator name="footer-left"></gr-endpoint-decorator>
-    </div>
-    <div>
-      <template is="dom-if" if="[[_feedbackUrl]]">
-        <a
-          class="feedback"
-          href$="[[_feedbackUrl]]"
-          rel="noopener"
-          target="_blank"
-          >Report bug</a
-        >
-        |
-      </template>
-      Press “?” for keyboard shortcuts
-      <gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
-    </div>
-  </footer>
-  <gr-overlay id="keyboardShortcuts" with-backdrop="">
-    <gr-keyboard-shortcuts-dialog
-      on-close="_handleKeyboardShortcutDialogClose"
-    ></gr-keyboard-shortcuts-dialog>
-  </gr-overlay>
-  <gr-overlay id="registrationOverlay" with-backdrop="">
-    <gr-registration-dialog
-      id="registrationDialog"
-      settings-url="[[_settingsUrl]]"
-      on-account-detail-update="_handleAccountDetailUpdate"
-      on-close="_handleRegistrationDialogClose"
-    >
-    </gr-registration-dialog>
-  </gr-overlay>
-  <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
-  <gr-error-manager
-    id="errorManager"
-    login-url="[[_loginUrl]]"
-  ></gr-error-manager>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-reporting id="reporting"></gr-reporting>
-  <gr-router id="router"></gr-router>
-  <gr-plugin-host id="plugins" config="[[_serverConfig]]"> </gr-plugin-host>
-  <gr-lib-loader id="libLoader"></gr-lib-loader>
-  <gr-external-style
-    id="externalStyleForAll"
-    name="app-theme"
-  ></gr-external-style>
-  <gr-external-style
-    id="externalStyleForTheme"
-    name="[[getThemeEndpoint()]]"
-  ></gr-external-style>
-`;
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.ts b/polygerrit-ui/app/elements/gr-app-element_html.ts
new file mode 100644
index 0000000..66624e3
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-element_html.ts
@@ -0,0 +1,235 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background-color: var(--background-color-tertiary);
+      display: flex;
+      flex-direction: column;
+      min-height: 100%;
+    }
+    gr-fixed-panel {
+      /**
+         * This one should be greater that the z-index in gr-diff-view
+         * because gr-main-header contains overlay.
+         */
+      z-index: 10;
+    }
+    gr-main-header,
+    footer {
+      color: var(--primary-text-color);
+    }
+    gr-main-header {
+      background: var(
+        --header-background,
+        var(--header-background-color, #eee)
+      );
+      padding: var(--header-padding);
+      border-bottom: var(--header-border-bottom);
+      border-image: var(--header-border-image);
+      border-right: 0;
+      border-left: 0;
+      border-top: 0;
+      box-shadow: var(--header-box-shadow);
+    }
+    footer {
+      background: var(
+        --footer-background,
+        var(--footer-background-color, #eee)
+      );
+      border-top: var(--footer-border-top);
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-m) var(--spacing-l);
+      z-index: 100;
+    }
+    main {
+      flex: 1;
+      padding-bottom: var(--spacing-xxl);
+      position: relative;
+    }
+    .errorView {
+      align-items: center;
+      display: none;
+      flex-direction: column;
+      justify-content: center;
+      position: absolute;
+      top: 0;
+      right: 0;
+      bottom: 0;
+      left: 0;
+    }
+    .errorView.show {
+      display: flex;
+    }
+    .errorEmoji {
+      font-size: 2.6rem;
+    }
+    .errorText,
+    .errorMoreInfo {
+      margin-top: var(--spacing-m);
+    }
+    .errorText {
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h3);
+      font-weight: var(--font-weight-h3);
+      line-height: var(--line-height-h3);
+    }
+    .errorMoreInfo {
+      color: var(--deemphasized-text-color);
+    }
+    .feedback {
+      color: var(--error-text-color);
+    }
+  </style>
+  <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
+  <gr-fixed-panel id="header">
+    <gr-main-header
+      id="mainHeader"
+      search-query="{{params.query}}"
+      on-mobile-search="_mobileSearchToggle"
+      on-show-keyboard-shortcuts="handleShowKeyboardShortcuts"
+      login-url="[[_loginUrl]]"
+    >
+    </gr-main-header>
+  </gr-fixed-panel>
+  <main>
+    <gr-smart-search
+      id="search"
+      label="Search for changes"
+      search-query="{{params.query}}"
+      hidden="[[!mobileSearch]]"
+    >
+    </gr-smart-search>
+    <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
+      <gr-change-list-view
+        params="[[params]]"
+        account="[[_account]]"
+        view-state="{{_viewState.changeListView}}"
+      ></gr-change-list-view>
+    </template>
+    <template is="dom-if" if="[[_showDashboardView]]" restamp="true">
+      <gr-dashboard-view
+        account="[[_account]]"
+        params="[[params]]"
+        view-state="{{_viewState.dashboardView}}"
+      ></gr-dashboard-view>
+    </template>
+    <template is="dom-if" if="[[_showChangeView]]" restamp="true">
+      <gr-change-view
+        params="[[params]]"
+        view-state="{{_viewState.changeView}}"
+        back-page="[[_lastSearchPage]]"
+      ></gr-change-view>
+    </template>
+    <template is="dom-if" if="[[_showEditorView]]" restamp="true">
+      <gr-editor-view params="[[params]]"></gr-editor-view>
+    </template>
+    <template is="dom-if" if="[[_showDiffView]]" restamp="true">
+      <gr-diff-view
+        params="[[params]]"
+        change-view-state="{{_viewState.changeView}}"
+      ></gr-diff-view>
+    </template>
+    <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
+      <gr-settings-view
+        params="[[params]]"
+        on-account-detail-update="_handleAccountDetailUpdate"
+      >
+      </gr-settings-view>
+    </template>
+    <template is="dom-if" if="[[_showAdminView]]" restamp="true">
+      <gr-admin-view path="[[_path]]" params="[[params]]"></gr-admin-view>
+    </template>
+    <template is="dom-if" if="[[_showPluginScreen]]" restamp="true">
+      <gr-endpoint-decorator name="[[_pluginScreenName]]">
+        <gr-endpoint-param
+          name="token"
+          value="[[params.screen]]"
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    </template>
+    <template is="dom-if" if="[[_showCLAView]]" restamp="true">
+      <gr-cla-view></gr-cla-view>
+    </template>
+    <template is="dom-if" if="[[_showDocumentationSearch]]" restamp="true">
+      <gr-documentation-search params="[[params]]"> </gr-documentation-search>
+    </template>
+    <div id="errorView" class="errorView">
+      <div class="errorEmoji">[[_lastError.emoji]]</div>
+      <div class="errorText">[[_lastError.text]]</div>
+      <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
+    </div>
+  </main>
+  <footer r="contentinfo">
+    <div>
+      Powered by
+      <a href="https://www.gerritcodereview.com/" rel="noopener" target="_blank"
+        >Gerrit Code Review</a
+      >
+      ([[_version]])
+      <gr-endpoint-decorator name="footer-left"></gr-endpoint-decorator>
+    </div>
+    <div>
+      <template is="dom-if" if="[[_feedbackUrl]]">
+        <a
+          class="feedback"
+          href$="[[_feedbackUrl]]"
+          rel="noopener"
+          target="_blank"
+          >Report bug</a
+        >
+        |
+      </template>
+      Press “?” for keyboard shortcuts
+      <gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
+    </div>
+  </footer>
+  <gr-overlay id="keyboardShortcuts" with-backdrop="">
+    <gr-keyboard-shortcuts-dialog
+      on-close="_handleKeyboardShortcutDialogClose"
+    ></gr-keyboard-shortcuts-dialog>
+  </gr-overlay>
+  <gr-overlay id="registrationOverlay" with-backdrop="">
+    <gr-registration-dialog
+      id="registrationDialog"
+      settings-url="[[_settingsUrl]]"
+      on-account-detail-update="_handleAccountDetailUpdate"
+      on-close="_handleRegistrationDialogClose"
+    >
+    </gr-registration-dialog>
+  </gr-overlay>
+  <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
+  <gr-error-manager
+    id="errorManager"
+    login-url="[[_loginUrl]]"
+  ></gr-error-manager>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-router id="router"></gr-router>
+  <gr-plugin-host id="plugins" config="[[_serverConfig]]"> </gr-plugin-host>
+  <gr-lib-loader id="libLoader"></gr-lib-loader>
+  <gr-external-style
+    id="externalStyleForAll"
+    name="app-theme"
+  ></gr-external-style>
+  <gr-external-style
+    id="externalStyleForTheme"
+    name="[[getThemeEndpoint()]]"
+  ></gr-external-style>
+`;
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.js b/polygerrit-ui/app/elements/gr-app-global-var-init.js
index f0a9131..95c16e2 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.js
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.js
@@ -22,7 +22,7 @@
  * expose these variables until plugins switch to direct import from polygerrit.
  */
 
-import {GrDisplayNameUtils} from '../scripts/gr-display-name-utils/gr-display-name-utils.js';
+import {getAccountDisplayName, getDisplayName, getGroupDisplayName, getUserName} from '../utils/display-name-util.js';
 import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation.js';
 import {GrAttributeHelper} from './plugins/gr-attribute-helper/gr-attribute-helper.js';
 import {GrDiffLine} from './diff/gr-diff/gr-diff-line.js';
@@ -41,17 +41,15 @@
 import {GrThemeApi} from './plugins/gr-theme-api/gr-theme-api.js';
 import {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js';
 import {GrLinkTextParser} from './shared/gr-linked-text/link-text-parser.js';
-import {pluginEndpoints, GrPluginEndpoints} from './shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {getPluginEndpoints, GrPluginEndpoints} from './shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {GrReviewerUpdatesParser} from './shared/gr-rest-api-interface/gr-reviewer-updates-parser.js';
 import {GrPopupInterface} from './plugins/gr-popup-interface/gr-popup-interface.js';
 import {GrRangeNormalizer} from './diff/gr-diff-highlight/gr-range-normalizer.js';
 import {GrCountStringFormatter} from './shared/gr-count-string-formatter/gr-count-string-formatter.js';
 import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
 import {util} from '../scripts/util.js';
-import moment from 'moment/src/moment.js';
 import page from 'page/page.mjs';
-import {Auth} from './shared/gr-rest-api-interface/gr-auth.js';
-import {EventEmitter} from './shared/gr-event-interface/gr-event-interface.js';
+import {appContext} from '../services/app-context.js';
 import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api.js';
 import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context.js';
 import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api.js';
@@ -65,7 +63,8 @@
 import {GrStylesApi} from './plugins/gr-styles-api/gr-styles-api.js';
 import {pluginLoader, PluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
 import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context.js';
-import {getBaseUrl, getPluginNameFromUrl, getRestAPI, PLUGIN_LOADING_TIMEOUT_MS, PRELOADED_PROTOCOL, send} from './shared/gr-js-api-interface/gr-api-utils.js';
+import {getPluginNameFromUrl, getRestAPI, PLUGIN_LOADING_TIMEOUT_MS, PRELOADED_PROTOCOL, send} from './shared/gr-js-api-interface/gr-api-utils.js';
+import {getBaseUrl} from '../utils/url-util.js';
 import {GerritNav} from './core/gr-navigation/gr-navigation.js';
 import {getRootElement} from '../scripts/rootElement.js';
 import {rangesEqual} from './diff/gr-diff/gr-diff-utils.js';
@@ -74,7 +73,12 @@
 import {_setHiddenScroll, getHiddenScroll} from '../scripts/hiddenscroll.js';
 
 export function initGlobalVariables() {
-  window.GrDisplayNameUtils = GrDisplayNameUtils;
+  window.GrDisplayNameUtils = {
+    getUserName,
+    getDisplayName,
+    getAccountDisplayName,
+    getGroupDisplayName,
+  };
   window.GrAnnotation = GrAnnotation;
   window.GrAttributeHelper = GrAttributeHelper;
   window.GrDiffLine = GrDiffLine;
@@ -103,10 +107,9 @@
   window.GrCountStringFormatter = GrCountStringFormatter;
   window.GrReviewerSuggestionsProvider = GrReviewerSuggestionsProvider;
   window.util = util;
-  window.moment = moment;
   window.page = page;
-  window.Auth = Auth;
-  window.EventEmitter = EventEmitter;
+  window.Auth = appContext.authService;
+  window.EventEmitter = appContext.eventEmitter;
   window.GrAdminApi = GrAdminApi;
   window.GrAnnotationActionsContext = GrAnnotationActionsContext;
   window.GrAnnotationActionsInterface = GrAnnotationActionsInterface;
@@ -133,9 +136,11 @@
   window.Gerrit = window.Gerrit || {};
   window.Gerrit.Nav = GerritNav;
   window.Gerrit.getRootElement = getRootElement;
+  window.Gerrit.Auth = appContext.authService;
 
   window.Gerrit._pluginLoader = pluginLoader;
-  window.Gerrit._endpoints = pluginEndpoints;
+  // TODO: should define as a getter
+  window.Gerrit._endpoints = getPluginEndpoints();
 
   window.Gerrit.slotToContent = slot => slot;
   window.Gerrit.rangesEqual = rangesEqual;
diff --git a/polygerrit-ui/app/elements/gr-app-init.js b/polygerrit-ui/app/elements/gr-app-init.js
index 14dbd87..df2d58b 100644
--- a/polygerrit-ui/app/elements/gr-app-init.js
+++ b/polygerrit-ui/app/elements/gr-app-init.js
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 import {initAppContext} from '../services/app-context-init.js';
+import {initVisibilityReporter, initPerformanceReporter, initErrorReporter} from '../services/gr-reporting/gr-reporting_impl.js';
+import {appContext} from '../services/app-context.js';
 
 if (!window.Polymer) {
   window.Polymer = {
@@ -23,4 +25,7 @@
 }
 window.Gerrit = window.Gerrit || {};
 
-initAppContext();
\ No newline at end of file
+initAppContext();
+initVisibilityReporter(appContext);
+initPerformanceReporter(appContext);
+initErrorReporter(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
deleted file mode 100644
index 1483f7a..0000000
--- a/polygerrit-ui/app/elements/gr-app.html
+++ /dev/null
@@ -1 +0,0 @@
-<script src='./gr-app.js' type='module'></script>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 6bc79f7a..fef7ab9 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -15,13 +15,15 @@
  * limitations under the License.
  */
 
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
+import {safeTypesBridge} from '../utils/safe-types-util.js';
+
+// We need to use goog.declareModuleId internally in google for TS-imports-JS
+// case. To avoid errors when goog is not available, the empty implementation is
+// added.
+window.goog = window.goog || {declareModuleId(name) {}};
 import './gr-app-init.js';
 import './font-roboto-local-loader.js';
+// Sets up global Polymer variable, because plugins requires it.
 import '../scripts/bundled-polymer.js';
 
 /**
@@ -37,21 +39,20 @@
 import 'polymer-resin/standalone/polymer-resin.js';
 import {initGlobalVariables} from './gr-app-global-var-init.js';
 import './gr-app-element.js';
-import './change-list/gr-embed-dashboard/gr-embed-dashboard.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-app_html.js';
-import {SafeTypes} from '../behaviors/safe-types-behavior/safe-types-behavior.js';
 import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit.js';
+import {appContext} from '../services/app-context.js';
 
 security.polymer_resin.install({
   allowedIdentifierPrefixes: [''],
   reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
-  safeTypesBridge: SafeTypes.safeTypesBridge,
+  safeTypesBridge,
 });
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrApp extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -63,4 +64,4 @@
 customElements.define(GrApp.is, GrApp);
 
 initGlobalVariables();
-initGerritPluginApi();
+initGerritPluginApi(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app_html.js b/polygerrit-ui/app/elements/gr-app_html.js
deleted file mode 100644
index 3da1b69..0000000
--- a/polygerrit-ui/app/elements/gr-app_html.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <gr-app-element id="app-element"></gr-app-element>
-`;
diff --git a/polygerrit-ui/app/elements/gr-app_html.ts b/polygerrit-ui/app/elements/gr-app_html.ts
new file mode 100644
index 0000000..f6172c9
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app_html.ts
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <gr-app-element id="app-element"></gr-app-element>
+`;
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
deleted file mode 100644
index 6a13789..0000000
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ /dev/null
@@ -1,107 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-app</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-app id="app"></gr-app>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../test/common-test-setup.js';
-import './gr-app.js';
-import {GerritNav} from './core/gr-navigation/gr-navigation.js';
-
-suite('gr-app tests', () => {
-  let sandbox;
-  let element;
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-reporting', {
-      appStarted: sandbox.stub(),
-    });
-    stub('gr-account-dropdown', {
-      _getTopContent: sinon.stub(),
-    });
-    stub('gr-router', {
-      start: sandbox.stub(),
-    });
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve({}); },
-      getAccountCapabilities() { return Promise.resolve({}); },
-      getConfig() {
-        return Promise.resolve({
-          plugin: {},
-          auth: {
-            auth_type: undefined,
-          },
-        });
-      },
-      getPreferences() { return Promise.resolve({my: []}); },
-      getDiffPreferences() { return Promise.resolve({}); },
-      getEditPreferences() { return Promise.resolve({}); },
-      getVersion() { return Promise.resolve(42); },
-      probePath() { return Promise.resolve(42); },
-    });
-
-    element = fixture('basic');
-    flush(done);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  const appElement = () => element.$['app-element'];
-
-  test('reporting', () => {
-    assert.isTrue(appElement().$.reporting.appStarted.calledOnce);
-  });
-
-  test('reporting called before router start', () => {
-    const element = appElement();
-    const appStartedStub = element.$.reporting.appStarted;
-    const routerStartStub = element.$.router.start;
-    sinon.assert.callOrder(appStartedStub, routerStartStub);
-  });
-
-  test('passes config to gr-plugin-host', () => {
-    const config = appElement().$.restAPI.getConfig;
-    return config.lastCall.returnValue.then(config => {
-      assert.deepEqual(appElement().$.plugins.config, config);
-    });
-  });
-
-  test('_paramsChanged sets search page', () => {
-    appElement()._paramsChanged({base: {view: GerritNav.View.CHANGE}});
-    assert.notOk(appElement()._lastSearchPage);
-    appElement()._paramsChanged({base: {view: GerritNav.View.SEARCH}});
-    assert.ok(appElement()._lastSearchPage);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/gr-app_test.js b/polygerrit-ui/app/elements/gr-app_test.js
new file mode 100644
index 0000000..4c5e965
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app_test.js
@@ -0,0 +1,86 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import './gr-app.js';
+import {appContext} from '../services/app-context.js';
+import {GerritNav} from './core/gr-navigation/gr-navigation.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`<gr-app id="app"></gr-app>`);
+
+suite('gr-app tests', () => {
+  let element;
+
+  setup(done => {
+    sinon.stub(appContext.reportingService, 'appStarted');
+    stub('gr-account-dropdown', {
+      _getTopContent: sinon.stub(),
+    });
+    stub('gr-router', {
+      start: sinon.stub(),
+    });
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve({}); },
+      getAccountCapabilities() { return Promise.resolve({}); },
+      getConfig() {
+        return Promise.resolve({
+          plugin: {},
+          auth: {
+            auth_type: undefined,
+          },
+        });
+      },
+      getPreferences() { return Promise.resolve({my: []}); },
+      getDiffPreferences() { return Promise.resolve({}); },
+      getEditPreferences() { return Promise.resolve({}); },
+      getVersion() { return Promise.resolve(42); },
+      probePath() { return Promise.resolve(42); },
+    });
+
+    element = basicFixture.instantiate();
+    flush(done);
+  });
+
+  const appElement = () => element.$['app-element'];
+
+  test('reporting', () => {
+    assert.isTrue(appElement().reporting.appStarted.calledOnce);
+  });
+
+  test('reporting called before router start', () => {
+    const element = appElement();
+    const appStartedStub = element.reporting.appStarted;
+    const routerStartStub = element.$.router.start;
+    sinon.assert.callOrder(appStartedStub, routerStartStub);
+  });
+
+  test('passes config to gr-plugin-host', () => {
+    const config = appElement().$.restAPI.getConfig;
+    return config.lastCall.returnValue.then(config => {
+      assert.deepEqual(appElement().$.plugins.config, config);
+    });
+  });
+
+  test('_paramsChanged sets search page', () => {
+    appElement()._paramsChanged({base: {view: GerritNav.View.CHANGE}});
+    assert.notOk(appElement()._lastSearchPage);
+    appElement()._paramsChanged({base: {view: GerritNav.View.SEARCH}});
+    assert.ok(appElement()._lastSearchPage);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
deleted file mode 100644
index a865233..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.html
+++ /dev/null
@@ -1,72 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-admin-api</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-admin-api tests', () => {
-  let sandbox;
-  let adminApi;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    pluginLoader.loadPlugins([]);
-    adminApi = plugin.admin();
-  });
-
-  teardown(() => {
-    adminApi = null;
-    sandbox.restore();
-  });
-
-  test('exists', () => {
-    assert.isOk(adminApi);
-  });
-
-  test('addMenuLink', () => {
-    adminApi.addMenuLink('text', 'url');
-    const links = adminApi.getMenuLinks();
-    assert.equal(links.length, 1);
-    assert.deepEqual(links[0], {text: 'text', url: 'url', capability: null});
-  });
-
-  test('addMenuLinkWithCapability', () => {
-    adminApi.addMenuLink('text', 'url', 'capability');
-    const links = adminApi.getMenuLinks();
-    assert.equal(links.length, 1);
-    assert.deepEqual(links[0],
-        {text: 'text', url: 'url', capability: 'capability'});
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
new file mode 100644
index 0000000..c3552cb
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
@@ -0,0 +1,59 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-admin-api tests', () => {
+  let adminApi;
+
+  setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    pluginLoader.loadPlugins([]);
+    adminApi = plugin.admin();
+  });
+
+  teardown(() => {
+    adminApi = null;
+  });
+
+  test('exists', () => {
+    assert.isOk(adminApi);
+  });
+
+  test('addMenuLink', () => {
+    adminApi.addMenuLink('text', 'url');
+    const links = adminApi.getMenuLinks();
+    assert.equal(links.length, 1);
+    assert.deepEqual(links[0], {text: 'text', url: 'url', capability: null});
+  });
+
+  test('addMenuLinkWithCapability', () => {
+    adminApi.addMenuLink('text', 'url', 'capability');
+    const links = adminApi.getMenuLinks();
+    assert.equal(links.length, 1);
+    assert.deepEqual(links[0],
+        {text: 'text', url: 'url', capability: 'capability'});
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
deleted file mode 100644
index d5ebb65..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-attribute-helper">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/** @constructor */
-export function GrAttributeHelper(element) {
-  this.element = element;
-  this._promises = {};
-}
-
-GrAttributeHelper.prototype._getChangedEventName = function(name) {
-  return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed';
-};
-
-/**
- * Returns true if the property is defined on wrapped element.
- *
- * @param {string} name
- * @return {boolean}
- */
-GrAttributeHelper.prototype._elementHasProperty = function(name) {
-  return this.element[name] !== undefined;
-};
-
-GrAttributeHelper.prototype._reportValue = function(callback, value) {
-  try {
-    callback(value);
-  } catch (e) {
-    console.info(e);
-  }
-};
-
-/**
- * Binds callback to property updates.
- *
- * @param {string} name Property name.
- * @param {function(?)} callback
- * @return {function()} Unbind function.
- */
-GrAttributeHelper.prototype.bind = function(name, callback) {
-  const attributeChangedEventName = this._getChangedEventName(name);
-  const changedHandler = e => this._reportValue(callback, e.detail.value);
-  const unbind = () => this.element.removeEventListener(
-      attributeChangedEventName, changedHandler);
-  this.element.addEventListener(
-      attributeChangedEventName, changedHandler);
-  if (this._elementHasProperty(name)) {
-    this._reportValue(callback, this.element[name]);
-  }
-  return unbind;
-};
-
-/**
- * Get value of the property from wrapped object. Waits for the property
- * to be initialized if it isn't defined.
- *
- * @param {string} name Property name.
- * @return {!Promise<?>}
- */
-GrAttributeHelper.prototype.get = function(name) {
-  if (this._elementHasProperty(name)) {
-    return Promise.resolve(this.element[name]);
-  }
-  if (!this._promises[name]) {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const unbind = this.bind(name, value => {
-      resolve(value);
-      unbind();
-    });
-    this._promises[name] = promise;
-  }
-  return this._promises[name];
-};
-
-/**
- * Sets value and dispatches event to force notify.
- *
- * @param {string} name Property name.
- * @param {?} value
- */
-GrAttributeHelper.prototype.set = function(name, value) {
-  this.element[name] = value;
-  this.element.dispatchEvent(
-      new CustomEvent(this._getChangedEventName(name), {detail: {value}}));
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
new file mode 100644
index 0000000..6c6321b
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
@@ -0,0 +1,97 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export class GrAttributeHelper {
+  private readonly _promises = new Map<string, Promise<any>>();
+
+  constructor(public element: any) {}
+
+  _getChangedEventName(name: string): string {
+    return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed';
+  }
+
+  /**
+   * Returns true if the property is defined on wrapped element.
+   */
+  _elementHasProperty(name: string) {
+    return this.element[name] !== undefined;
+  }
+
+  _reportValue(callback: (value: any) => void, value: any) {
+    try {
+      callback(value);
+    } catch (e) {
+      console.info(e);
+    }
+  }
+
+  /**
+   * Binds callback to property updates.
+   *
+   * @param {string} name Property name.
+   * @param {function(?)} callback
+   * @return {function()} Unbind function.
+   */
+  bind(name: string, callback: (value: any) => void) {
+    const attributeChangedEventName = this._getChangedEventName(name);
+    const changedHandler = (e: CustomEvent) =>
+      this._reportValue(callback, e.detail.value);
+    const unbind = () =>
+      this.element.removeEventListener(
+        attributeChangedEventName,
+        changedHandler
+      );
+    this.element.addEventListener(attributeChangedEventName, changedHandler);
+    if (this._elementHasProperty(name)) {
+      this._reportValue(callback, this.element[name]);
+    }
+    return unbind;
+  }
+
+  /**
+   * Get value of the property from wrapped object. Waits for the property
+   * to be initialized if it isn't defined.
+   *
+   * @param {string} name Property name.
+   * @return {!Promise<?>}
+   */
+  get(name: string) {
+    if (this._elementHasProperty(name)) {
+      return Promise.resolve(this.element[name]);
+    }
+    if (!this._promises.has(name)) {
+      let resolve: (value: any) => void;
+      const promise = new Promise(r => (resolve = r));
+      const unbind = this.bind(name, value => {
+        resolve(value);
+        unbind();
+      });
+      this._promises.set(name, promise);
+    }
+    return this._promises.get(name);
+  }
+
+  /**
+   * Sets value and dispatches event to force notify.
+   */
+  set(name: string, value: any) {
+    this.element[name] = value;
+    this.element.dispatchEvent(
+      new CustomEvent(this._getChangedEventName(name), {detail: {value}})
+    );
+  }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
deleted file mode 100644
index 50f9002..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
+++ /dev/null
@@ -1,101 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-attribute-helper</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<dom-element id="some-element">
-  <script type="module">
-import '../../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-Polymer({
-  is: 'some-element',
-  properties: {
-    fooBar: {
-      type: Object,
-      notify: true,
-    },
-  },
-});
-</script>
-
-</dom-element>
-
-<test-fixture id="basic">
-  <template>
-    <some-element></some-element>
-  </template>
-</test-fixture>
-
-<script type="module">
-import {GrAttributeHelper} from './gr-attribute-helper.js';
-
-suite('gr-attribute-helper tests', () => {
-  let element;
-  let instance;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    instance = new GrAttributeHelper(element);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('resolved on value change from undefined', () => {
-    const promise = instance.get('fooBar').then(value => {
-      assert.equal(value, 'foo! bar!');
-    });
-    element.fooBar = 'foo! bar!';
-    return promise;
-  });
-
-  test('resolves to current attribute value', () => {
-    element.fooBar = 'foo-foo-bar';
-    const promise = instance.get('fooBar').then(value => {
-      assert.equal(value, 'foo-foo-bar');
-    });
-    element.fooBar = 'no bar';
-    return promise;
-  });
-
-  test('bind', () => {
-    const stub = sandbox.stub();
-    element.fooBar = 'bar foo';
-    const unbind = instance.bind('fooBar', stub);
-    element.fooBar = 'partridge in a foo tree';
-    element.fooBar = 'five gold bars';
-    assert.equal(stub.callCount, 3);
-    assert.deepEqual(stub.args[0], ['bar foo']);
-    assert.deepEqual(stub.args[1], ['partridge in a foo tree']);
-    assert.deepEqual(stub.args[2], ['five gold bars']);
-    stub.reset();
-    unbind();
-    instance.fooBar = 'ladies dancing';
-    assert.isFalse(stub.called);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
new file mode 100644
index 0000000..ea7bdc3
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {GrAttributeHelper} from './gr-attribute-helper.js';
+
+Polymer({
+  is: 'gr-attrubute-helper-some-element',
+  properties: {
+    fooBar: {
+      type: Object,
+      notify: true,
+    },
+  },
+});
+
+const basicFixture = fixtureFromElement('gr-attrubute-helper-some-element');
+
+suite('gr-attribute-helper tests', () => {
+  let element;
+  let instance;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    instance = new GrAttributeHelper(element);
+  });
+
+  test('resolved on value change from undefined', () => {
+    const promise = instance.get('fooBar').then(value => {
+      assert.equal(value, 'foo! bar!');
+    });
+    element.fooBar = 'foo! bar!';
+    return promise;
+  });
+
+  test('resolves to current attribute value', () => {
+    element.fooBar = 'foo-foo-bar';
+    const promise = instance.get('fooBar').then(value => {
+      assert.equal(value, 'foo-foo-bar');
+    });
+    element.fooBar = 'no bar';
+    return promise;
+  });
+
+  test('bind', () => {
+    const stub = sinon.stub();
+    element.fooBar = 'bar foo';
+    const unbind = instance.bind('fooBar', stub);
+    element.fooBar = 'partridge in a foo tree';
+    element.fooBar = 'five gold bars';
+    assert.equal(stub.callCount, 3);
+    assert.deepEqual(stub.args[0], ['bar foo']);
+    assert.deepEqual(stub.args[1], ['partridge in a foo tree']);
+    assert.deepEqual(stub.args[2], ['five gold bars']);
+    stub.reset();
+    unbind();
+    instance.fooBar = 'ladies dancing';
+    assert.isFalse(stub.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
index 8be50b1..89d8ec2 100644
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
@@ -14,15 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-change-metadata-api">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 /** @constructor */
 export function GrChangeMetadataApi(plugin) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
index b998733..93cbcf5 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
@@ -14,16 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-dom-hooks">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 
 /** @constructor */
 export function GrDomHooksManager(plugin) {
@@ -68,13 +59,18 @@
 }
 
 GrDomHook.prototype._createPlaceholder = function(hookName) {
-  Polymer({
-    is: hookName,
-    properties: {
-      plugin: Object,
-      content: Object,
-    },
-  });
+  class HookPlaceholder extends PolymerElement {
+    static get is() { return hookName; }
+
+    static get properties() {
+      return {
+        plugin: Object,
+        content: Object,
+      };
+    }
+  }
+
+  customElements.define(HookPlaceholder.is, HookPlaceholder);
 };
 
 GrDomHook.prototype.handleInstanceDetached = function(instance) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
deleted file mode 100644
index 17a22e9..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
+++ /dev/null
@@ -1,166 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-dom-hooks</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {GrDomHook, GrDomHooksManager} from './gr-dom-hooks.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-dom-hooks tests', () => {
-  const PUBLIC_METHODS =[
-    'onAttached',
-    'onDetached',
-    'getLastAttached',
-    'getAllAttached',
-    'getModuleName',
-  ];
-
-  let instance;
-  let sandbox;
-  let hook;
-  let hookInternal;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    instance = new GrDomHooksManager(plugin);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('placeholder', () => {
-    setup(()=>{
-      sandbox.stub(GrDomHook.prototype, '_createPlaceholder');
-      hookInternal = instance.getDomHook('foo-bar');
-      hook = hookInternal.getPublicAPI();
-    });
-
-    test('public hook API has only public methods', () => {
-      assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
-    });
-
-    test('registers placeholder class', () => {
-      assert.isTrue(hookInternal._createPlaceholder.calledWithExactly(
-          'testplugin-autogenerated-foo-bar'));
-    });
-
-    test('getModuleName()', () => {
-      const hookName = Object.keys(instance._hooks).pop();
-      assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
-      assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
-    });
-  });
-
-  suite('custom element', () => {
-    setup(() => {
-      hookInternal = instance.getDomHook('foo-bar', 'my-el');
-      hook = hookInternal.getPublicAPI();
-    });
-
-    test('public hook API has only public methods', () => {
-      assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
-    });
-
-    test('getModuleName()', () => {
-      const hookName = Object.keys(instance._hooks).pop();
-      assert.equal(hookName, 'foo-bar my-el');
-      assert.equal(hook.getModuleName(), 'my-el');
-    });
-
-    test('onAttached', () => {
-      const onAttachedSpy = sandbox.spy();
-      hook.onAttached(onAttachedSpy);
-      const [el1, el2] = [
-        document.createElement(hook.getModuleName()),
-        document.createElement(hook.getModuleName()),
-      ];
-      hookInternal.handleInstanceAttached(el1);
-      hookInternal.handleInstanceAttached(el2);
-      assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
-      assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
-    });
-
-    test('onDetached', () => {
-      const onDetachedSpy = sandbox.spy();
-      hook.onDetached(onDetachedSpy);
-      const [el1, el2] = [
-        document.createElement(hook.getModuleName()),
-        document.createElement(hook.getModuleName()),
-      ];
-      hookInternal.handleInstanceDetached(el1);
-      assert.isTrue(onDetachedSpy.firstCall.calledWithExactly(el1));
-      hookInternal.handleInstanceDetached(el2);
-      assert.isTrue(onDetachedSpy.secondCall.calledWithExactly(el2));
-    });
-
-    test('getAllAttached', () => {
-      const [el1, el2] = [
-        document.createElement(hook.getModuleName()),
-        document.createElement(hook.getModuleName()),
-      ];
-      el1.textContent = 'one';
-      el2.textContent = 'two';
-      hookInternal.handleInstanceAttached(el1);
-      hookInternal.handleInstanceAttached(el2);
-      assert.deepEqual([el1, el2], hook.getAllAttached());
-      hookInternal.handleInstanceDetached(el1);
-      assert.deepEqual([el2], hook.getAllAttached());
-    });
-
-    test('getLastAttached', () => {
-      const beforeAttachedPromise = hook.getLastAttached().then(
-          el => assert.strictEqual(el1, el));
-      const [el1, el2] = [
-        document.createElement(hook.getModuleName()),
-        document.createElement(hook.getModuleName()),
-      ];
-      el1.textContent = 'one';
-      el2.textContent = 'two';
-      hookInternal.handleInstanceAttached(el1);
-      hookInternal.handleInstanceAttached(el2);
-      const afterAttachedPromise = hook.getLastAttached().then(
-          el => assert.strictEqual(el2, el));
-      return Promise.all([
-        beforeAttachedPromise,
-        afterAttachedPromise,
-      ]);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
new file mode 100644
index 0000000..a2b92ec
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
@@ -0,0 +1,145 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {GrDomHook, GrDomHooksManager} from './gr-dom-hooks.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-dom-hooks tests', () => {
+  const PUBLIC_METHODS =[
+    'onAttached',
+    'onDetached',
+    'getLastAttached',
+    'getAllAttached',
+    'getModuleName',
+  ];
+
+  let instance;
+
+  let hook;
+  let hookInternal;
+
+  setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    instance = new GrDomHooksManager(plugin);
+  });
+
+  suite('placeholder', () => {
+    setup(()=>{
+      sinon.stub(GrDomHook.prototype, '_createPlaceholder');
+      hookInternal = instance.getDomHook('foo-bar');
+      hook = hookInternal.getPublicAPI();
+    });
+
+    test('public hook API has only public methods', () => {
+      assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
+    });
+
+    test('registers placeholder class', () => {
+      assert.isTrue(hookInternal._createPlaceholder.calledWithExactly(
+          'testplugin-autogenerated-foo-bar'));
+    });
+
+    test('getModuleName()', () => {
+      const hookName = Object.keys(instance._hooks).pop();
+      assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
+      assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
+    });
+  });
+
+  suite('custom element', () => {
+    setup(() => {
+      hookInternal = instance.getDomHook('foo-bar', 'my-el');
+      hook = hookInternal.getPublicAPI();
+    });
+
+    test('public hook API has only public methods', () => {
+      assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
+    });
+
+    test('getModuleName()', () => {
+      const hookName = Object.keys(instance._hooks).pop();
+      assert.equal(hookName, 'foo-bar my-el');
+      assert.equal(hook.getModuleName(), 'my-el');
+    });
+
+    test('onAttached', () => {
+      const onAttachedSpy = sinon.spy();
+      hook.onAttached(onAttachedSpy);
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      hookInternal.handleInstanceAttached(el1);
+      hookInternal.handleInstanceAttached(el2);
+      assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
+      assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
+    });
+
+    test('onDetached', () => {
+      const onDetachedSpy = sinon.spy();
+      hook.onDetached(onDetachedSpy);
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      hookInternal.handleInstanceDetached(el1);
+      assert.isTrue(onDetachedSpy.firstCall.calledWithExactly(el1));
+      hookInternal.handleInstanceDetached(el2);
+      assert.isTrue(onDetachedSpy.secondCall.calledWithExactly(el2));
+    });
+
+    test('getAllAttached', () => {
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      el1.textContent = 'one';
+      el2.textContent = 'two';
+      hookInternal.handleInstanceAttached(el1);
+      hookInternal.handleInstanceAttached(el2);
+      assert.deepEqual([el1, el2], hook.getAllAttached());
+      hookInternal.handleInstanceDetached(el1);
+      assert.deepEqual([el2], hook.getAllAttached());
+    });
+
+    test('getLastAttached', () => {
+      const beforeAttachedPromise = hook.getLastAttached().then(
+          el => assert.strictEqual(el1, el));
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      el1.textContent = 'one';
+      el2.textContent = 'two';
+      hookInternal.handleInstanceAttached(el1);
+      hookInternal.handleInstanceAttached(el2);
+      const afterAttachedPromise = hook.getLastAttached().then(
+          el => assert.strictEqual(el2, el));
+      return Promise.all([
+        beforeAttachedPromise,
+        afterAttachedPromise,
+      ]);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
index b40ea15..b26e565 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
@@ -14,21 +14,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {importHref} from '../../../scripts/import-href.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-endpoint-decorator_html.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
 const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrEndpointDecorator extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -64,16 +61,7 @@
     for (const [el, domHook] of this._domHooks) {
       domHook.handleInstanceDetached(el);
     }
-    pluginEndpoints.onDetachedEndpoint(this.name, this._endpointCallBack);
-  }
-
-  /**
-   * @suppress {checkTypes}
-   */
-  _import(url) {
-    return new Promise((resolve, reject) => {
-      importHref(url, resolve, reject);
-    });
+    getPluginEndpoints().onDetachedEndpoint(this.name, this._endpointCallBack);
   }
 
   _initDecoration(name, plugin, slot) {
@@ -135,9 +123,9 @@
             `plugin ${plugin.getPluginName()}, endpoint ${this.name}`);
         }, INIT_PROPERTIES_TIMEOUT_MS));
     return Promise.race([timeout, Promise.all(expectProperties)])
-        .then(() => {
-          clearTimeout(timeoutId);
-          return el;
+        .then(() => el)
+        .finally(() => {
+          if (timeoutId) clearTimeout(timeoutId);
         });
   }
 
@@ -173,15 +161,12 @@
   ready() {
     super.ready();
     this._endpointCallBack = this._initModule.bind(this);
-    pluginEndpoints.onNewEndpoint(this.name, this._endpointCallBack);
+    getPluginEndpoints().onNewEndpoint(this.name, this._endpointCallBack);
     if (this.name) {
       pluginLoader.awaitPluginsLoaded()
-          .then(() => Promise.all(
-              pluginEndpoints.getPlugins(this.name).map(
-                  pluginUrl => this._import(pluginUrl)))
-          )
+          .then(() => getPluginEndpoints().getAndImportPlugins(this.name))
           .then(() =>
-            pluginEndpoints
+            getPluginEndpoints()
                 .getDetails(this.name)
                 .forEach(this._initModule, this)
           );
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.js
deleted file mode 100644
index c4310fc..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts
new file mode 100644
index 0000000..94196df
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
deleted file mode 100644
index 890a457..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
+++ /dev/null
@@ -1,228 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-endpoint-decorator</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <div>
-      <gr-endpoint-decorator name="first">
-        <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
-        <p>
-          <span>test slot</span>
-          <gr-endpoint-slot name="test"></gr-endpoint-slot>
-        </p>
-      </gr-endpoint-decorator>
-      <gr-endpoint-decorator name="second">
-        <gr-endpoint-param name="someparam" value="foofoo"></gr-endpoint-param>
-      </gr-endpoint-decorator>
-      <gr-endpoint-decorator name="banana">
-        <gr-endpoint-param name="someParam" value="yes"></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-endpoint-decorator.js';
-import '../gr-endpoint-param/gr-endpoint-param.js';
-import '../gr-endpoint-slot/gr-endpoint-slot.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-endpoint-decorator', () => {
-  let container;
-  let sandbox;
-  let plugin;
-  let decorationHook;
-  let decorationHookWithSlot;
-  let replacementHook;
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-endpoint-decorator', {
-      _import: sandbox.stub().returns(Promise.resolve()),
-    });
-    resetPlugins();
-    container = fixture('basic');
-    pluginApi.install(p => plugin = p, '0.1',
-        'http://some/plugin/url.html');
-    // Decoration
-    decorationHook = plugin.registerCustomComponent('first', 'some-module');
-    decorationHookWithSlot = plugin.registerCustomComponent(
-        'first',
-        'some-module-2',
-        {slot: 'test'}
-    );
-    // Replacement
-    replacementHook = plugin.registerCustomComponent(
-        'second', 'other-module', {replace: true});
-    // Mimic all plugins loaded.
-    pluginLoader.loadPlugins([]);
-    flush(done);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('imports plugin-provided modules into endpoints', () => {
-    const endpoints =
-        Array.from(container.querySelectorAll('gr-endpoint-decorator'));
-    assert.equal(endpoints.length, 3);
-    endpoints.forEach(element => {
-      assert.isTrue(
-          element._import.calledWith(new URL('http://some/plugin/url.html')));
-    });
-  });
-
-  test('decoration', () => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="first"]');
-    const modules = Array.from(dom(element.root).children).filter(
-        element => element.nodeName === 'SOME-MODULE');
-    assert.equal(modules.length, 1);
-    const [module] = modules;
-    assert.isOk(module);
-    assert.equal(module['someparam'], 'barbar');
-    return decorationHook.getLastAttached()
-        .then(element => {
-          assert.strictEqual(element, module);
-        })
-        .then(() => {
-          element.remove();
-          assert.equal(decorationHook.getAllAttached().length, 0);
-        });
-  });
-
-  test('decoration with slot', () => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="first"]');
-    const modules = [...dom(element).querySelectorAll('p > some-module-2')];
-    assert.equal(modules.length, 1);
-    const [module] = modules;
-    assert.isOk(module);
-    assert.equal(module['someparam'], 'barbar');
-    return decorationHookWithSlot.getLastAttached()
-        .then(element => {
-          assert.strictEqual(element, module);
-        })
-        .then(() => {
-          element.remove();
-          assert.equal(decorationHookWithSlot.getAllAttached().length, 0);
-        });
-  });
-
-  test('replacement', () => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="second"]');
-    const module = Array.from(dom(element.root).children).find(
-        element => element.nodeName === 'OTHER-MODULE');
-    assert.isOk(module);
-    assert.equal(module['someparam'], 'foofoo');
-    return replacementHook.getLastAttached()
-        .then(element => {
-          assert.strictEqual(element, module);
-        })
-        .then(() => {
-          element.remove();
-          assert.equal(replacementHook.getAllAttached().length, 0);
-        });
-  });
-
-  test('late registration', done => {
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    flush(() => {
-      const element =
-          container.querySelector('gr-endpoint-decorator[name="banana"]');
-      const module = Array.from(dom(element.root).children).find(
-          element => element.nodeName === 'NOOB-NOOB');
-      assert.isOk(module);
-      done();
-    });
-  });
-
-  test('two modules', done => {
-    plugin.registerCustomComponent('banana', 'mod-one');
-    plugin.registerCustomComponent('banana', 'mod-two');
-    flush(() => {
-      const element =
-          container.querySelector('gr-endpoint-decorator[name="banana"]');
-      const module1 = Array.from(dom(element.root).children).find(
-          element => element.nodeName === 'MOD-ONE');
-      assert.isOk(module1);
-      const module2 = Array.from(dom(element.root).children).find(
-          element => element.nodeName === 'MOD-TWO');
-      assert.isOk(module2);
-      done();
-    });
-  });
-
-  test('late param setup', done => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="banana"]');
-    const param = dom(element).querySelector('gr-endpoint-param');
-    param['value'] = undefined;
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    flush(() => {
-      let module = Array.from(dom(element.root).children).find(
-          element => element.nodeName === 'NOOB-NOOB');
-      // Module waits for param to be defined.
-      assert.isNotOk(module);
-      const value = {abc: 'def'};
-      param.value = value;
-      flush(() => {
-        module = Array.from(dom(element.root).children).find(
-            element => element.nodeName === 'NOOB-NOOB');
-        assert.isOk(module);
-        assert.strictEqual(module['someParam'], value);
-        done();
-      });
-    });
-  });
-
-  test('param is bound', done => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="banana"]');
-    const param = dom(element).querySelector('gr-endpoint-param');
-    const value1 = {abc: 'def'};
-    const value2 = {def: 'abc'};
-    param.value = value1;
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    flush(() => {
-      const module = Array.from(dom(element.root).children).find(
-          element => element.nodeName === 'NOOB-NOOB');
-      assert.strictEqual(module['someParam'], value1);
-      param.value = value2;
-      assert.strictEqual(module['someParam'], value2);
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
new file mode 100644
index 0000000..ba9220b
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
@@ -0,0 +1,214 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-endpoint-decorator.js';
+import '../gr-endpoint-param/gr-endpoint-param.js';
+import '../gr-endpoint-slot/gr-endpoint-slot.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+const basicFixture = fixtureFromTemplate(
+    html`<div>
+  <gr-endpoint-decorator name="first">
+    <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
+    <p>
+      <span>test slot</span>
+      <gr-endpoint-slot name="test"></gr-endpoint-slot>
+    </p>
+  </gr-endpoint-decorator>
+  <gr-endpoint-decorator name="second">
+    <gr-endpoint-param name="someparam" value="foofoo"></gr-endpoint-param>
+  </gr-endpoint-decorator>
+  <gr-endpoint-decorator name="banana">
+    <gr-endpoint-param name="someParam" value="yes"></gr-endpoint-param>
+  </gr-endpoint-decorator>
+</div>`
+);
+
+suite('gr-endpoint-decorator', () => {
+  let container;
+
+  let plugin;
+  let decorationHook;
+  let decorationHookWithSlot;
+  let replacementHook;
+
+  setup(done => {
+    resetPlugins();
+    container = basicFixture.instantiate();
+    sinon.stub(getPluginEndpoints(), 'importUrl')
+        .callsFake( url => Promise.resolve());
+    pluginApi.install(p => plugin = p, '0.1',
+        'http://some/plugin/url.html');
+    // Decoration
+    decorationHook = plugin.registerCustomComponent('first', 'some-module');
+    decorationHookWithSlot = plugin.registerCustomComponent(
+        'first',
+        'some-module-2',
+        {slot: 'test'}
+    );
+    // Replacement
+    replacementHook = plugin.registerCustomComponent(
+        'second', 'other-module', {replace: true});
+    // Mimic all plugins loaded.
+    pluginLoader.loadPlugins([]);
+    flush(done);
+  });
+
+  teardown(() => {
+    resetPlugins();
+  });
+
+  test('imports plugin-provided modules into endpoints', () => {
+    const endpoints =
+        Array.from(container.querySelectorAll('gr-endpoint-decorator'));
+    assert.equal(endpoints.length, 3);
+    assert.isTrue(getPluginEndpoints().importUrl.calledWith(
+        new URL('http://some/plugin/url.html')
+    ));
+  });
+
+  test('decoration', () => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="first"]');
+    const modules = Array.from(dom(element.root).children).filter(
+        element => element.nodeName === 'SOME-MODULE');
+    assert.equal(modules.length, 1);
+    const [module] = modules;
+    assert.isOk(module);
+    assert.equal(module['someparam'], 'barbar');
+    return decorationHook.getLastAttached()
+        .then(element => {
+          assert.strictEqual(element, module);
+        })
+        .then(() => {
+          element.remove();
+          assert.equal(decorationHook.getAllAttached().length, 0);
+        });
+  });
+
+  test('decoration with slot', () => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="first"]');
+    const modules = [...dom(element).querySelectorAll('some-module-2')];
+    assert.equal(modules.length, 1);
+    const [module] = modules;
+    assert.isOk(module);
+    assert.equal(module['someparam'], 'barbar');
+    return decorationHookWithSlot.getLastAttached()
+        .then(element => {
+          assert.strictEqual(element, module);
+        })
+        .then(() => {
+          element.remove();
+          assert.equal(decorationHookWithSlot.getAllAttached().length, 0);
+        });
+  });
+
+  test('replacement', () => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="second"]');
+    const module = Array.from(dom(element.root).children).find(
+        element => element.nodeName === 'OTHER-MODULE');
+    assert.isOk(module);
+    assert.equal(module['someparam'], 'foofoo');
+    return replacementHook.getLastAttached()
+        .then(element => {
+          assert.strictEqual(element, module);
+        })
+        .then(() => {
+          element.remove();
+          assert.equal(replacementHook.getAllAttached().length, 0);
+        });
+  });
+
+  test('late registration', done => {
+    plugin.registerCustomComponent('banana', 'noob-noob');
+    flush(() => {
+      const element =
+          container.querySelector('gr-endpoint-decorator[name="banana"]');
+      const module = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'NOOB-NOOB');
+      assert.isOk(module);
+      done();
+    });
+  });
+
+  test('two modules', done => {
+    plugin.registerCustomComponent('banana', 'mod-one');
+    plugin.registerCustomComponent('banana', 'mod-two');
+    flush(() => {
+      const element =
+          container.querySelector('gr-endpoint-decorator[name="banana"]');
+      const module1 = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'MOD-ONE');
+      assert.isOk(module1);
+      const module2 = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'MOD-TWO');
+      assert.isOk(module2);
+      done();
+    });
+  });
+
+  test('late param setup', done => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="banana"]');
+    const param = dom(element).querySelector('gr-endpoint-param');
+    param['value'] = undefined;
+    plugin.registerCustomComponent('banana', 'noob-noob');
+    flush(() => {
+      let module = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'NOOB-NOOB');
+      // Module waits for param to be defined.
+      assert.isNotOk(module);
+      const value = {abc: 'def'};
+      param.value = value;
+      flush(() => {
+        module = Array.from(dom(element.root).children).find(
+            element => element.nodeName === 'NOOB-NOOB');
+        assert.isOk(module);
+        assert.strictEqual(module['someParam'], value);
+        done();
+      });
+    });
+  });
+
+  test('param is bound', done => {
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="banana"]');
+    const param = dom(element).querySelector('gr-endpoint-param');
+    const value1 = {abc: 'def'};
+    const value2 = {def: 'abc'};
+    param.value = value1;
+    plugin.registerCustomComponent('banana', 'noob-noob');
+    flush(() => {
+      const module = Array.from(dom(element.root).children).find(
+          element => element.nodeName === 'NOOB-NOOB');
+      assert.strictEqual(module['someParam'], value1);
+      param.value = value2;
+      assert.strictEqual(module['someParam'], value2);
+      done();
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
deleted file mode 100644
index 9574391..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-
-/** @extends Polymer.Element */
-class GrEndpointParam extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get is() { return 'gr-endpoint-param'; }
-
-  static get properties() {
-    return {
-      name: String,
-      value: {
-        type: Object,
-        notify: true,
-        observer: '_valueChanged',
-      },
-    };
-  }
-
-  _valueChanged(newValue, oldValue) {
-    /* In polymer 2 the following change was made:
-    "Property change notifications (property-changed events) aren't fired when
-    the value changes as a result of a binding from the host"
-    (see https://polymer-library.polymer-project.org/2.0/docs/about_20).
-    To workaround this problem, we fire the event from the observer.
-    In some cases this fire the event twice, but our code is
-    ready for it.
-    */
-    const detail = {
-      value: newValue,
-    };
-    this.dispatchEvent(new CustomEvent('value-changed', {detail}));
-  }
-}
-
-customElements.define(GrEndpointParam.is, GrEndpointParam);
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
new file mode 100644
index 0000000..d731ef0
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
@@ -0,0 +1,59 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-endpoint-param': GrEndpointParam;
+  }
+}
+
+@customElement('gr-endpoint-param')
+export class GrEndpointParam extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  @property({type: String})
+  name = '';
+
+  @property({
+    type: Object,
+    notify: true,
+    observer: GrEndpointParam.prototype._valueChanged,
+  })
+  value: Record<string, unknown> | undefined = undefined;
+
+  private _valueChanged(
+    newValue: Record<string, unknown>,
+    _oldValue: Record<string, unknown>
+  ) {
+    /* In polymer 2 the following change was made:
+    "Property change notifications (property-changed events) aren't fired when
+    the value changes as a result of a binding from the host"
+    (see https://polymer-library.polymer-project.org/2.0/docs/about_20).
+    To workaround this problem, we fire the event from the observer.
+    In some cases this fire the event twice, but our code is
+    ready for it.
+    */
+    const detail = {
+      value: newValue,
+    };
+    this.dispatchEvent(new CustomEvent('value-changed', {detail}));
+  }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
deleted file mode 100644
index 63d40fc..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-event-helper">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/** @constructor */
-export function GrEventHelper(element) {
-  this.element = element;
-  this._unsubscribers = [];
-}
-
-/**
- * Add a callback to arbitrary event.
- * The callback may return false to prevent event bubbling.
- *
- * @param {string} event Event name
- * @param {function(Event):boolean} callback
- * @return {function()} Unsubscribe function.
- */
-GrEventHelper.prototype.on = function(event, callback) {
-  return this._listen(this.element, callback, {event});
-};
-
-/**
- * Alias of onClick
- *
- * @see onClick
- */
-GrEventHelper.prototype.onTap = function(callback) {
-  return this._listen(this.element, callback);
-};
-
-/**
- * Add a callback to element click or touch.
- * The callback may return false to prevent event bubbling.
- *
- * @param {function(Event):boolean} callback
- * @return {function()} Unsubscribe function.
- */
-GrEventHelper.prototype.onClick = function(callback) {
-  return this._listen(this.element, callback);
-};
-
-/**
- * Alias of captureClick
- *
- * @see captureClick
- */
-GrEventHelper.prototype.captureTap = function(callback) {
-  return this._listen(this.element.parentElement, callback, {capture: true});
-};
-
-/**
- * Add a callback to element click or touch ahead of normal flow.
- * Callback is installed on parent during capture phase.
- * https://www.w3.org/TR/DOM-Level-3-Events/#event-flow
- * The callback may return false to cancel regular event listeners.
- *
- * @param {function(Event):boolean} callback
- * @return {function()} Unsubscribe function.
- */
-GrEventHelper.prototype.captureClick = function(callback) {
-  return this._listen(this.element.parentElement, callback, {capture: true});
-};
-
-GrEventHelper.prototype._listen = function(container, callback, opt_options) {
-  const capture = opt_options && opt_options.capture;
-  const event = opt_options && opt_options.event || 'click';
-  const handler = e => {
-    if (e.path.indexOf(this.element) !== -1) {
-      let mayContinue = true;
-      try {
-        mayContinue = callback(e);
-      } catch (e) {
-        console.warn(`Plugin error handing event: ${e}`);
-      }
-      if (mayContinue === false) {
-        e.stopImmediatePropagation();
-        e.stopPropagation();
-        e.preventDefault();
-      }
-    }
-  };
-  container.addEventListener(event, handler, capture);
-  const unsubscribe = () =>
-    container.removeEventListener(event, handler, capture);
-  this._unsubscribers.push(unsubscribe);
-  return unsubscribe;
-};
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
new file mode 100644
index 0000000..bf08b8e
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+interface EventWithPath extends Event {
+  path?: HTMLElement[];
+}
+
+export interface ListenOptions {
+  event?: string;
+  capture?: boolean;
+}
+
+export class GrEventHelper {
+  constructor(readonly element: HTMLElement) {}
+
+  /**
+   * Add a callback to arbitrary event.
+   * The callback may return false to prevent event bubbling.
+   */
+  on(event: string, callback: (event: Event) => boolean) {
+    return this._listen(this.element, callback, {event});
+  }
+
+  /**
+   * Alias for @see onClick
+   */
+  onTap(callback: (event: Event) => boolean) {
+    return this.onClick(callback);
+  }
+
+  /**
+   * Add a callback to element click or touch.
+   * The callback may return false to prevent event bubbling.
+   */
+  onClick(callback: (event: Event) => boolean) {
+    return this._listen(this.element, callback);
+  }
+
+  /**
+   * Alias for @see captureClick
+   */
+  captureTap(callback: (event: Event) => boolean) {
+    this.captureClick(callback);
+  }
+
+  /**
+   * Add a callback to element click or touch ahead of normal flow.
+   * Callback is installed on parent during capture phase.
+   * https://www.w3.org/TR/DOM-Level-3-Events/#event-flow
+   * The callback may return false to cancel regular event listeners.
+   */
+  captureClick(callback: (event: Event) => boolean) {
+    const parent = this.element.parentElement!;
+    return this._listen(parent, callback, {capture: true});
+  }
+
+  _listen(
+    container: HTMLElement,
+    callback: (event: Event) => boolean,
+    opt_options?: ListenOptions | null
+  ) {
+    const capture = opt_options?.capture;
+    const event = opt_options?.event || 'click';
+    const handler = (e: EventWithPath) => {
+      if (!e.path) return;
+      if (e.path.indexOf(this.element) !== -1) {
+        let mayContinue = true;
+        try {
+          mayContinue = callback(e);
+        } catch (exception) {
+          console.warn(`Plugin error handing event: ${exception}`);
+        }
+        if (mayContinue === false) {
+          e.stopImmediatePropagation();
+          e.stopPropagation();
+          e.preventDefault();
+        }
+      }
+    };
+    container.addEventListener(event, handler, capture);
+    const unsubscribe = () =>
+      container.removeEventListener(event, handler, capture);
+    return unsubscribe;
+  }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
deleted file mode 100644
index a27c817..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
+++ /dev/null
@@ -1,138 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-event-helper</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<dom-element id="some-element">
-  <script type="module">
-import '../../../test/common-test-setup.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-Polymer({
-  is: 'some-element',
-
-  properties: {
-    fooBar: {
-      type: Object,
-      notify: true,
-    },
-  },
-});
-</script>
-
-</dom-element>
-
-<test-fixture id="basic">
-  <template>
-    <some-element></some-element>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
-import {GrEventHelper} from './gr-event-helper.js';
-
-suite('gr-event-helper tests', () => {
-  let element;
-  let instance;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    instance = new GrEventHelper(element);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('onTap()', done => {
-    instance.onTap(() => {
-      done();
-    });
-    MockInteractions.tap(element);
-  });
-
-  test('onTap() cancel', () => {
-    const tapStub = sandbox.stub();
-    addListener(element.parentElement, 'tap', tapStub);
-    instance.onTap(() => false);
-    MockInteractions.tap(element);
-    flushAsynchronousOperations();
-    assert.isFalse(tapStub.called);
-  });
-
-  test('onClick() cancel', () => {
-    const tapStub = sandbox.stub();
-    element.parentElement.addEventListener('click', tapStub);
-    instance.onTap(() => false);
-    MockInteractions.tap(element);
-    flushAsynchronousOperations();
-    assert.isFalse(tapStub.called);
-  });
-
-  test('captureTap()', done => {
-    instance.captureTap(() => {
-      done();
-    });
-    MockInteractions.tap(element);
-  });
-
-  test('captureClick()', done => {
-    instance.captureClick(() => {
-      done();
-    });
-    MockInteractions.tap(element);
-  });
-
-  test('captureTap() cancels tap()', () => {
-    const tapStub = sandbox.stub();
-    addListener(element.parentElement, 'tap', tapStub);
-    instance.captureTap(() => false);
-    MockInteractions.tap(element);
-    flushAsynchronousOperations();
-    assert.isFalse(tapStub.called);
-  });
-
-  test('captureClick() cancels click()', () => {
-    const tapStub = sandbox.stub();
-    element.addEventListener('click', tapStub);
-    instance.captureTap(() => false);
-    MockInteractions.tap(element);
-    flushAsynchronousOperations();
-    assert.isFalse(tapStub.called);
-  });
-
-  test('on()', done => {
-    instance.on('foo', () => {
-      done();
-    });
-    element.dispatchEvent(
-        new CustomEvent('foo', {
-          composed: true, bubbles: true,
-        }));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
new file mode 100644
index 0000000..e56278f
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
@@ -0,0 +1,112 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
+import {GrEventHelper} from './gr-event-helper.js';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+
+Polymer({
+  is: 'gr-event-helper-some-element',
+
+  properties: {
+    fooBar: {
+      type: Object,
+      notify: true,
+    },
+  },
+});
+
+const basicFixture = fixtureFromElement('gr-event-helper-some-element');
+
+suite('gr-event-helper tests', () => {
+  let element;
+  let instance;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    instance = new GrEventHelper(element);
+  });
+
+  test('onTap()', done => {
+    instance.onTap(() => {
+      done();
+    });
+    MockInteractions.tap(element);
+  });
+
+  test('onTap() cancel', () => {
+    const tapStub = sinon.stub();
+    addListener(element.parentElement, 'tap', tapStub);
+    instance.onTap(() => false);
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('onClick() cancel', () => {
+    const tapStub = sinon.stub();
+    element.parentElement.addEventListener('click', tapStub);
+    instance.onTap(() => false);
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('captureTap()', done => {
+    instance.captureTap(() => {
+      done();
+    });
+    MockInteractions.tap(element);
+  });
+
+  test('captureClick()', done => {
+    instance.captureClick(() => {
+      done();
+    });
+    MockInteractions.tap(element);
+  });
+
+  test('captureTap() cancels tap()', () => {
+    const tapStub = sinon.stub();
+    addListener(element.parentElement, 'tap', tapStub);
+    instance.captureTap(() => false);
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('captureClick() cancels click()', () => {
+    const tapStub = sinon.stub();
+    element.addEventListener('click', tapStub);
+    instance.captureTap(() => false);
+    MockInteractions.tap(element);
+    flushAsynchronousOperations();
+    assert.isFalse(tapStub.called);
+  });
+
+  test('on()', done => {
+    instance.on('foo', () => {
+      done();
+    });
+    element.dispatchEvent(
+        new CustomEvent('foo', {
+          composed: true, bubbles: true,
+        }));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
index 68b1494..5d29a4c 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
@@ -14,19 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {importHref} from '../../../scripts/import-href.js';
 import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-external-style_html.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrExternalStyle extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -37,10 +34,6 @@
   static get properties() {
     return {
       name: String,
-      _urlsImported: {
-        type: Array,
-        value() { return []; },
-      },
       _stylesApplied: {
         type: Array,
         value() { return []; },
@@ -48,23 +41,6 @@
     };
   }
 
-  _importHref(url, resolve, reject) {
-    // It is impossible to mock es6-module imported function.
-    // The _importHref function is mocked in test.
-    importHref(url, resolve, reject);
-  }
-
-  /**
-   * @suppress {checkTypes}
-   */
-  _import(url) {
-    if (this._urlsImported.includes(url)) { return Promise.resolve(); }
-    this._urlsImported.push(url);
-    return new Promise((resolve, reject) => {
-      this._importHref(url, resolve, reject);
-    });
-  }
-
   _applyStyle(name) {
     if (this._stylesApplied.includes(name)) { return; }
     this._stylesApplied.push(name);
@@ -81,14 +57,13 @@
   }
 
   _importAndApply() {
-    Promise.all(pluginEndpoints.getPlugins(this.name).map(
-        pluginUrl => this._import(pluginUrl))
-    ).then(() => {
-      const moduleNames = pluginEndpoints.getModules(this.name);
-      for (const name of moduleNames) {
-        this._applyStyle(name);
-      }
-    });
+    getPluginEndpoints().getAndImportPlugins(this.name)
+        .then(() => {
+          const moduleNames = getPluginEndpoints().getModules(this.name);
+          for (const name of moduleNames) {
+            this._applyStyle(name);
+          }
+        });
   }
 
   /** @override */
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.js
deleted file mode 100644
index c4310fc..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.ts
new file mode 100644
index 0000000..94196df
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.ts
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
deleted file mode 100644
index 8f85348..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
+++ /dev/null
@@ -1,133 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-external-style</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<test-fixture id="basic">
-  <template>
-    <gr-external-style name="foo"></gr-external-style>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-external-style.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-external-style integration tests', () => {
-  const TEST_URL = 'http://some/plugin/url.html';
-
-  let sandbox;
-  let element;
-  let plugin;
-  let importHrefStub;
-
-  const installPlugin = () => {
-    if (plugin) { return; }
-    pluginApi.install(p => {
-      plugin = p;
-    }, '0.1', TEST_URL);
-  };
-
-  const createElement = () => {
-    element = fixture('basic');
-    sandbox.spy(element, '_applyStyle');
-  };
-
-  /**
-   * Installs the plugin, creates the element, registers style module.
-   */
-  const lateRegister = () => {
-    installPlugin();
-    createElement();
-    plugin.registerStyleModule('foo', 'some-module');
-  };
-
-  /**
-   * Installs the plugin, registers style module, creates the element.
-   */
-  const earlyRegister = () => {
-    installPlugin();
-    plugin.registerStyleModule('foo', 'some-module');
-    createElement();
-  };
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    importHrefStub = sandbox.stub().callsArg(1);
-    stub('gr-external-style', {
-      _importHref: (url, resolve, reject) => {
-        importHrefStub(url, resolve, reject);
-      },
-    });
-    sandbox.stub(pluginLoader, 'awaitPluginsLoaded')
-        .returns(Promise.resolve());
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('imports plugin-provided module', async () => {
-    lateRegister();
-    await new Promise(flush);
-    assert.isTrue(importHrefStub.calledWith(new URL(TEST_URL)));
-  });
-
-  test('applies plugin-provided styles', async () => {
-    lateRegister();
-    await new Promise(flush);
-    assert.isTrue(element._applyStyle.calledWith('some-module'));
-  });
-
-  test('does not double import', async () => {
-    earlyRegister();
-    await new Promise(flush);
-    plugin.registerStyleModule('foo', 'some-module');
-    await new Promise(flush);
-    const urlsImported =
-        element._urlsImported.filter(url => url.toString() === TEST_URL);
-    assert.strictEqual(urlsImported.length, 1);
-  });
-
-  test('does not double apply', async () => {
-    earlyRegister();
-    await new Promise(flush);
-    plugin.registerStyleModule('foo', 'some-module');
-    await new Promise(flush);
-    const stylesApplied =
-        element._stylesApplied.filter(name => name === 'some-module');
-    assert.strictEqual(stylesApplied.length, 1);
-  });
-
-  test('loads and applies preloaded modules', async () => {
-    earlyRegister();
-    await new Promise(flush);
-    assert.isTrue(importHrefStub.calledWith(new URL(TEST_URL)));
-    assert.isTrue(element._applyStyle.calledWith('some-module'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
new file mode 100644
index 0000000..6e57994
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import './gr-external-style.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+const basicFixture = fixtureFromTemplate(
+    html`<gr-external-style name="foo"></gr-external-style>`
+);
+
+suite('gr-external-style integration tests', () => {
+  const TEST_URL = 'http://some.com/plugins/url.html';
+
+  let element;
+  let plugin;
+
+  const installPlugin = () => {
+    if (plugin) { return; }
+    pluginApi.install(p => {
+      plugin = p;
+    }, '0.1', TEST_URL);
+  };
+
+  const createElement = () => {
+    element = basicFixture.instantiate();
+    sinon.spy(element, '_applyStyle');
+  };
+
+  /**
+   * Installs the plugin, creates the element, registers style module.
+   */
+  const lateRegister = () => {
+    installPlugin();
+    createElement();
+    plugin.registerStyleModule('foo', 'some-module');
+  };
+
+  /**
+   * Installs the plugin, registers style module, creates the element.
+   */
+  const earlyRegister = () => {
+    installPlugin();
+    plugin.registerStyleModule('foo', 'some-module');
+    createElement();
+  };
+
+  setup(() => {
+    sinon.stub(getPluginEndpoints(), 'importUrl')
+        .callsFake( url => Promise.resolve());
+    sinon.stub(pluginLoader, 'awaitPluginsLoaded')
+        .returns(Promise.resolve());
+  });
+
+  teardown(() => {
+    resetPlugins();
+    document.body.querySelectorAll('custom-style')
+        .forEach(style => style.remove());
+  });
+
+  test('imports plugin-provided module', async () => {
+    lateRegister();
+    await new Promise(flush);
+    assert.isTrue(getPluginEndpoints().importUrl.calledWith(new URL(TEST_URL)));
+  });
+
+  test('applies plugin-provided styles', async () => {
+    lateRegister();
+    await new Promise(flush);
+    assert.isTrue(element._applyStyle.calledWith('some-module'));
+  });
+
+  test('does not double import', async () => {
+    earlyRegister();
+    await new Promise(flush);
+    plugin.registerStyleModule('foo', 'some-module');
+    await new Promise(flush);
+    // since loaded, should not call again
+    assert.isFalse(getPluginEndpoints().importUrl.calledOnce);
+  });
+
+  test('does not double apply', async () => {
+    earlyRegister();
+    await new Promise(flush);
+    plugin.registerStyleModule('foo', 'some-module');
+    await new Promise(flush);
+    const stylesApplied =
+        element._stylesApplied.filter(name => name === 'some-module');
+    assert.strictEqual(stylesApplied.length, 1);
+  });
+
+  test('loads and applies preloaded modules', async () => {
+    earlyRegister();
+    await new Promise(flush);
+    assert.isTrue(element._applyStyle.calledWith('some-module'));
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
index d1b2106..46e37e1 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
@@ -14,15 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrPluginHost extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -39,9 +37,10 @@
 
   _configChanged(config) {
     const plugins = config.plugin;
-    const htmlPlugins = (plugins.html_resource_paths || []);
-    const jsPlugins =
-        this._handleMigrations(plugins.js_resource_paths || [], htmlPlugins);
+    const htmlPlugins = (plugins && plugins.html_resource_paths || []);
+    const jsPlugins = this._handleMigrations(
+        plugins && plugins.js_resource_paths || [], htmlPlugins
+    );
     const shouldLoadTheme = config.default_theme &&
           !pluginLoader.isPluginPreloaded('preloaded:gerrit-theme');
     const themeToLoad =
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
deleted file mode 100644
index 2c8a8c0..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
+++ /dev/null
@@ -1,94 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-plugin-host</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-plugin-host></gr-plugin-host>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-plugin-host.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-
-suite('gr-plugin-host tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    sandbox.stub(document.body, 'appendChild');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('load plugins should be called', () => {
-    sandbox.stub(pluginLoader, 'loadPlugins');
-    element.config = {
-      plugin: {
-        html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
-        js_resource_paths: ['plugins/42'],
-      },
-    };
-    assert.isTrue(pluginLoader.loadPlugins.calledOnce);
-    assert.isTrue(pluginLoader.loadPlugins.calledWith([
-      'plugins/42', 'plugins/foo/bar', 'plugins/baz',
-    ], {}));
-  });
-
-  test('theme plugins should be loaded if enabled', () => {
-    sandbox.stub(pluginLoader, 'loadPlugins');
-    element.config = {
-      default_theme: 'gerrit-theme.html',
-      plugin: {
-        html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
-        js_resource_paths: ['plugins/42'],
-      },
-    };
-    assert.isTrue(pluginLoader.loadPlugins.calledOnce);
-    assert.isTrue(pluginLoader.loadPlugins.calledWith([
-      'gerrit-theme.html', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
-    ], {'gerrit-theme.html': {sync: true}}));
-  });
-
-  test('skip theme if preloaded', () => {
-    sandbox.stub(pluginLoader, 'isPluginPreloaded')
-        .withArgs('preloaded:gerrit-theme')
-        .returns(true);
-    sandbox.stub(pluginLoader, 'loadPlugins');
-    element.config = {
-      default_theme: '/oof',
-      plugin: {},
-    };
-    assert.isTrue(pluginLoader.loadPlugins.calledOnce);
-    assert.isTrue(pluginLoader.loadPlugins.calledWith([], {}));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
new file mode 100644
index 0000000..83e0a84
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-plugin-host.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+
+const basicFixture = fixtureFromElement('gr-plugin-host');
+
+suite('gr-plugin-host tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    sinon.stub(document.body, 'appendChild');
+  });
+
+  test('load plugins should be called', () => {
+    sinon.stub(pluginLoader, 'loadPlugins');
+    element.config = {
+      plugin: {
+        html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
+        js_resource_paths: ['plugins/42'],
+      },
+    };
+    assert.isTrue(pluginLoader.loadPlugins.calledOnce);
+    assert.isTrue(pluginLoader.loadPlugins.calledWith([
+      'plugins/42', 'plugins/foo/bar', 'plugins/baz',
+    ], {}));
+  });
+
+  test('theme plugins should be loaded if enabled', () => {
+    sinon.stub(pluginLoader, 'loadPlugins');
+    element.config = {
+      default_theme: 'gerrit-theme.html',
+      plugin: {
+        html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
+        js_resource_paths: ['plugins/42'],
+      },
+    };
+    assert.isTrue(pluginLoader.loadPlugins.calledOnce);
+    assert.isTrue(pluginLoader.loadPlugins.calledWith([
+      'gerrit-theme.html', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
+    ], {'gerrit-theme.html': {sync: true}}));
+  });
+
+  test('skip theme if preloaded', () => {
+    sinon.stub(pluginLoader, 'isPluginPreloaded')
+        .withArgs('preloaded:gerrit-theme')
+        .returns(true);
+    sinon.stub(pluginLoader, 'loadPlugins');
+    element.config = {
+      default_theme: '/oof',
+      plugin: {},
+    };
+    assert.isTrue(pluginLoader.loadPlugins.calledOnce);
+    assert.isTrue(pluginLoader.loadPlugins.calledWith([], {}));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
index db44cea..eaecd29 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../shared/gr-overlay/gr-overlay.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
@@ -25,7 +23,7 @@
 (function(window) {
   'use strict';
 
-  /** @extends Polymer.Element */
+  /** @extends PolymerElement */
   class GrPluginPopup extends GestureEventListeners(
       LegacyElementMixin(
           PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.js
deleted file mode 100644
index 5d2cae7..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <gr-overlay id="overlay" with-backdrop="">
-    <slot></slot>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.ts
new file mode 100644
index 0000000..aa7a92d
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.ts
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <gr-overlay id="overlay" with-backdrop="">
+    <slot></slot>
+  </gr-overlay>
+`;
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
deleted file mode 100644
index 2e65365..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
+++ /dev/null
@@ -1,69 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-plugin-popup</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-plugin-popup></gr-plugin-popup>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-plugin-popup.js';
-suite('gr-plugin-popup tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    stub('gr-overlay', {
-      open: sandbox.stub().returns(Promise.resolve()),
-      close: sandbox.stub(),
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('exists', () => {
-    assert.isOk(element);
-  });
-
-  test('open uses open() from gr-overlay', done => {
-    element.open().then(() => {
-      assert.isTrue(element.$.overlay.open.called);
-      done();
-    });
-  });
-
-  test('close uses close() from gr-overlay', () => {
-    element.close();
-    assert.isTrue(element.$.overlay.close.called);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.js
new file mode 100644
index 0000000..f2d83e8
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.js
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-plugin-popup.js';
+
+const basicFixture = fixtureFromElement('gr-plugin-popup');
+
+suite('gr-plugin-popup tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    stub('gr-overlay', {
+      open: sinon.stub().returns(Promise.resolve()),
+      close: sinon.stub(),
+    });
+  });
+
+  test('exists', () => {
+    assert.isOk(element);
+  });
+
+  test('open uses open() from gr-overlay', done => {
+    element.open().then(() => {
+      assert.isTrue(element.$.overlay.open.called);
+      done();
+    });
+  });
+
+  test('close uses close() from gr-overlay', () => {
+    element.close();
+    assert.isTrue(element.$.overlay.close.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
index 3363d72..e2dd047 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
@@ -14,17 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import './gr-plugin-popup.js';
 import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-popup-interface">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 /**
  * Plugin popup API.
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
deleted file mode 100644
index 62ab0e7..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
+++ /dev/null
@@ -1,127 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-popup-interface</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="container">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<dom-module id="gr-user-test-popup">
-  <template>
-    <div id="barfoo">some test module</div>
-  </template>
-  <script type="module">
-import '../../../test/common-test-setup.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-Polymer({is: 'gr-user-test-popup'});
-</script>
-</dom-module>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GrPopupInterface} from './gr-popup-interface.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-suite('gr-popup-interface tests', () => {
-  let container;
-  let instance;
-  let plugin;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    container = fixture('container');
-    sandbox.stub(plugin, 'hook').returns({
-      getLastAttached() {
-        return Promise.resolve(container);
-      },
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('manual', () => {
-    setup(() => {
-      instance = new GrPopupInterface(plugin);
-    });
-
-    test('open', done => {
-      instance.open().then(api => {
-        assert.strictEqual(api, instance);
-        const manual = document.createElement('div');
-        manual.id = 'foobar';
-        manual.innerHTML = 'manual content';
-        api._getElement().appendChild(manual);
-        flushAsynchronousOperations();
-        assert.equal(
-            container.querySelector('#foobar').textContent, 'manual content');
-        done();
-      });
-    });
-
-    test('close', done => {
-      instance.open().then(api => {
-        assert.isTrue(api._getElement().node.opened);
-        api.close();
-        assert.isFalse(api._getElement().node.opened);
-        done();
-      });
-    });
-  });
-
-  suite('components', () => {
-    setup(() => {
-      instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
-    });
-
-    test('open', done => {
-      instance.open().then(api => {
-        assert.isNotNull(
-            dom(container).querySelector('gr-user-test-popup'));
-        done();
-      });
-    });
-
-    test('close', done => {
-      instance.open().then(api => {
-        assert.isTrue(api._getElement().node.opened);
-        api.close();
-        assert.isFalse(api._getElement().node.opened);
-        done();
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
new file mode 100644
index 0000000..9312332
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrPopupInterface} from './gr-popup-interface.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+
+class GrUserTestPopupElement extends PolymerElement {
+  static get is() { return 'gr-user-test-popup'; }
+
+  static get template() {
+    return html`<div id="barfoo">some test module</div>`;
+  }
+}
+
+customElements.define(GrUserTestPopupElement.is, GrUserTestPopupElement);
+
+const containerFixture = fixtureFromElement('div');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+suite('gr-popup-interface tests', () => {
+  let container;
+  let instance;
+  let plugin;
+
+  setup(() => {
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    container = containerFixture.instantiate();
+    sinon.stub(plugin, 'hook').returns({
+      getLastAttached() {
+        return Promise.resolve(container);
+      },
+    });
+  });
+
+  suite('manual', () => {
+    setup(() => {
+      instance = new GrPopupInterface(plugin);
+    });
+
+    test('open', done => {
+      instance.open().then(api => {
+        assert.strictEqual(api, instance);
+        const manual = document.createElement('div');
+        manual.id = 'foobar';
+        manual.innerHTML = 'manual content';
+        api._getElement().appendChild(manual);
+        flushAsynchronousOperations();
+        assert.equal(
+            container.querySelector('#foobar').textContent, 'manual content');
+        done();
+      });
+    });
+
+    test('close', done => {
+      instance.open().then(api => {
+        assert.isTrue(api._getElement().node.opened);
+        api.close();
+        assert.isFalse(api._getElement().node.opened);
+        done();
+      });
+    });
+  });
+
+  suite('components', () => {
+    setup(() => {
+      instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
+    });
+
+    test('open', done => {
+      instance.open().then(api => {
+        assert.isNotNull(
+            dom(container).querySelector('gr-user-test-popup'));
+        done();
+      });
+    });
+
+    test('close', done => {
+      instance.open().then(api => {
+        assert.isTrue(api._getElement().node.opened);
+        api.close();
+        assert.isFalse(api._getElement().node.opened);
+        done();
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
index f9a2bdf..1a2cd28 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
@@ -14,22 +14,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {htmlTemplate} from './gr-plugin-repo-command_html.js';
 
-import '../../admin/gr-repo-command/gr-repo-command.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-Polymer({
-  _template: html`
-    <gr-repo-command title="[[title]]">
-    </gr-repo-command>
-`,
+class GrPluginRepoCommand extends PolymerElement {
+  static get is() {
+    return 'gr-plugin-repo-command';
+  }
 
-  is: 'gr-plugin-repo-command',
+  static get properties() {
+    return {
+      title: String,
+      repoName: String,
+      config: Object,
+    };
+  }
 
-  properties: {
-    title: String,
-    repoName: String,
-    config: Object,
-  },
-});
+  static get template() {
+    return htmlTemplate;
+  }
+
+  _handleClick() {
+    this.dispatchEvent(
+        new CustomEvent('command-tap', {composed: true, bubbles: true})
+    );
+  }
+}
+
+customElements.define(GrPluginRepoCommand.is, GrPluginRepoCommand);
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.ts
new file mode 100644
index 0000000..6eee643
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command_html.ts
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-xxl);
+    }
+  </style>
+  <h3>[[title]]</h3>
+  <gr-button on-click="_handleClick">[[title]]</gr-button>
+`;
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
index 36d822c..04408f8 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
@@ -14,16 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import './gr-plugin-repo-command.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-repo-api">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 /** @constructor */
 export function GrRepoApi(plugin) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
deleted file mode 100644
index 32ae959..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.html
+++ /dev/null
@@ -1,88 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-repo-api</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-endpoint-decorator name="repo-command">
-    </gr-endpoint-decorator>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-repo-api tests', () => {
-  let sandbox;
-  let repoApi;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    pluginLoader.loadPlugins([]);
-    repoApi = plugin.project();
-  });
-
-  teardown(() => {
-    repoApi = null;
-    sandbox.restore();
-  });
-
-  test('exists', () => {
-    assert.isOk(repoApi);
-  });
-
-  test('works', done => {
-    const attachedStub = sandbox.stub();
-    const tapStub = sandbox.stub();
-    repoApi
-        .createCommand('foo', attachedStub)
-        .onTap(tapStub);
-    const element = fixture('basic');
-    flush(() => {
-      assert.isTrue(attachedStub.called);
-      const pluginCommand = element.shadowRoot
-          .querySelector('gr-plugin-repo-command');
-      assert.isOk(pluginCommand);
-      const command = pluginCommand.shadowRoot
-          .querySelector('gr-repo-command');
-      assert.isOk(command);
-      assert.equal(command.title, 'foo');
-      assert.isFalse(tapStub.called);
-      MockInteractions.tap(command.shadowRoot
-          .querySelector('gr-button'));
-      assert.isTrue(tapStub.called);
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.js
new file mode 100644
index 0000000..c7f1b06
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.js
@@ -0,0 +1,73 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-endpoint-decorator name="repo-command">
+    </gr-endpoint-decorator>
+`);
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-repo-api tests', () => {
+  let repoApi;
+
+  setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    pluginLoader.loadPlugins([]);
+    repoApi = plugin.project();
+  });
+
+  teardown(() => {
+    repoApi = null;
+  });
+
+  test('exists', () => {
+    assert.isOk(repoApi);
+  });
+
+  test('works', done => {
+    const attachedStub = sinon.stub();
+    const tapStub = sinon.stub();
+    repoApi
+        .createCommand('foo', attachedStub)
+        .onTap(tapStub);
+    const element = basicFixture.instantiate();
+    flush(() => {
+      assert.isTrue(attachedStub.called);
+      const pluginCommand = element.shadowRoot
+          .querySelector('gr-plugin-repo-command');
+      assert.isOk(pluginCommand);
+      const btn = pluginCommand.shadowRoot
+          .querySelector('gr-button');
+      assert.isOk(btn);
+      assert.equal(btn.textContent, 'foo');
+      assert.isFalse(tapStub.called);
+      MockInteractions.tap(btn);
+      assert.isTrue(tapStub.called);
+      done();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
index 4fb971f..8050cd6 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
@@ -14,17 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../settings/gr-settings-view/gr-settings-item.js';
 import '../../settings/gr-settings-view/gr-settings-menu-item.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-settings-api">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 /** @constructor */
 export function GrSettingsApi(plugin) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
deleted file mode 100644
index 5057992..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.html
+++ /dev/null
@@ -1,89 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Settings
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-settings-api</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-endpoint-decorator name="settings-menu-item">
-    </gr-endpoint-decorator>
-    <gr-endpoint-decorator name="settings-screen">
-    </gr-endpoint-decorator>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-settings-api tests', () => {
-  let sandbox;
-  let settingsApi;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    pluginLoader.loadPlugins([]);
-    settingsApi = plugin.settings();
-  });
-
-  teardown(() => {
-    settingsApi = null;
-    sandbox.restore();
-  });
-
-  test('exists', () => {
-    assert.isOk(settingsApi);
-  });
-
-  test('works', done => {
-    settingsApi
-        .title('foo')
-        .token('bar')
-        .module('some-settings-screen')
-        .build();
-    const element = fixture('basic');
-    flush(() => {
-      const [menuItemEl, itemEl] = element;
-      const menuItem = menuItemEl.shadowRoot
-          .querySelector('gr-settings-menu-item');
-      assert.isOk(menuItem);
-      assert.equal(menuItem.title, 'foo');
-      assert.equal(menuItem.href, '#x/testplugin/bar');
-      const item = itemEl.shadowRoot
-          .querySelector('gr-settings-item');
-      assert.isOk(item);
-      assert.equal(item.title, 'foo');
-      assert.equal(item.anchor, 'x/testplugin/bar');
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.js b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.js
new file mode 100644
index 0000000..82d58fe
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.js
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-endpoint-decorator name="settings-menu-item">
+    </gr-endpoint-decorator>
+    <gr-endpoint-decorator name="settings-screen">
+    </gr-endpoint-decorator>
+`);
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-settings-api tests', () => {
+  let settingsApi;
+
+  setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    pluginLoader.loadPlugins([]);
+    settingsApi = plugin.settings();
+  });
+
+  teardown(() => {
+    settingsApi = null;
+  });
+
+  test('exists', () => {
+    assert.isOk(settingsApi);
+  });
+
+  test('works', done => {
+    settingsApi
+        .title('foo')
+        .token('bar')
+        .module('some-settings-screen')
+        .build();
+    const element = basicFixture.instantiate();
+    flush(() => {
+      const [menuItemEl, itemEl] = element;
+      const menuItem = menuItemEl.shadowRoot
+          .querySelector('gr-settings-menu-item');
+      assert.isOk(menuItem);
+      assert.equal(menuItem.title, 'foo');
+      assert.equal(menuItem.href, '#x/testplugin/bar');
+      const item = itemEl.shadowRoot
+          .querySelector('gr-settings-item');
+      assert.isOk(item);
+      assert.equal(item.title, 'foo');
+      assert.equal(item.anchor, 'x/testplugin/bar');
+      done();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js
index 3da60db..8a1b601 100644
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {useShadow} from '@polymer/polymer/lib/utils/settings.js';
 
 let styleObjectCount = 0;
 
@@ -34,7 +35,7 @@
  * @return {string} Appropriate class name for the element is returned
  */
 GrStyleObject.prototype.getClassName = function(element) {
-  let rootNode = Polymer.Settings.useShadow
+  let rootNode = useShadow
     ? element.getRootNode() : document.body;
   if (rootNode === document) {
     rootNode = document.head;
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html
deleted file mode 100644
index d6bae9b..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.html
+++ /dev/null
@@ -1,188 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-admin-api</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<dom-module id="gr-style-test-element">
-  <template>
-    <div id="wrapper"></div>
-  </template>
-  <script type="module">
-import '../../../test/common-test-setup.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-Polymer({is: 'gr-style-test-element'});
-</script>
-</dom-module>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-styles-api tests', () => {
-  let sandbox;
-  let stylesApi;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    pluginLoader.loadPlugins([]);
-    stylesApi = plugin.styles();
-  });
-
-  teardown(() => {
-    stylesApi = null;
-    sandbox.restore();
-  });
-
-  test('exists', () => {
-    assert.isOk(stylesApi);
-  });
-
-  test('css', () => {
-    const styleObject = stylesApi.css('background: red');
-    assert.isDefined(styleObject);
-  });
-
-  suite('GrStyleObject tests', () => {
-    let sandbox;
-    let stylesApi;
-    let displayInlineStyle;
-    let displayNoneStyle;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      pluginApi.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      pluginLoader.loadPlugins([]);
-      stylesApi = plugin.styles();
-      displayInlineStyle = stylesApi.css('display: inline');
-      displayNoneStyle = stylesApi.css('display: none');
-    });
-
-    teardown(() => {
-      displayInlineStyle = null;
-      displayNoneStyle = null;
-      stylesApi = null;
-      sandbox.restore();
-    });
-
-    function createNestedElements(parentElement) {
-      /* parentElement
-      *  |--- element1
-      *  |--- element2
-      *       |--- element3
-      **/
-      const element1 = document.createElement('div');
-      const element2 = document.createElement('div');
-      const element3 = document.createElement('div');
-      dom(parentElement).appendChild(element1);
-      dom(parentElement).appendChild(element2);
-      dom(element2).appendChild(element3);
-
-      return [element1, element2, element3];
-    }
-
-    test('getClassName  - body level elements', () => {
-      const bodyLevelElements = createNestedElements(document.body);
-
-      testGetClassName(bodyLevelElements);
-    });
-
-    test('getClassName  - elements inside polymer element', () => {
-      const polymerElement = document.createElement('gr-style-test-element');
-      dom(document.body).appendChild(polymerElement);
-      const contentElements = createNestedElements(polymerElement.$.wrapper);
-
-      testGetClassName(contentElements);
-    });
-
-    function testGetClassName(elements) {
-      assertAllElementsHaveDefaultStyle(elements);
-
-      const className1 = displayInlineStyle.getClassName(elements[0]);
-      const className2 = displayNoneStyle.getClassName(elements[1]);
-      const className3 = displayInlineStyle.getClassName(elements[2]);
-
-      assert.notEqual(className2, className1);
-      assert.equal(className3, className1);
-
-      assertAllElementsHaveDefaultStyle(elements);
-
-      elements[0].classList.add(className1);
-      elements[1].classList.add(className2);
-      elements[2].classList.add(className1);
-
-      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
-    }
-
-    test('apply - body level elements', () => {
-      const bodyLevelElements = createNestedElements(document.body);
-
-      testApply(bodyLevelElements);
-    });
-
-    test('apply - elements inside polymer element', () => {
-      const polymerElement = document.createElement('gr-style-test-element');
-      dom(document.body).appendChild(polymerElement);
-      const contentElements = createNestedElements(polymerElement.$.wrapper);
-
-      testApply(contentElements);
-    });
-
-    function testApply(elements) {
-      assertAllElementsHaveDefaultStyle(elements);
-      displayInlineStyle.apply(elements[0]);
-      displayNoneStyle.apply(elements[1]);
-      displayInlineStyle.apply(elements[2]);
-      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
-    }
-
-    function assertAllElementsHaveDefaultStyle(elements) {
-      for (const element of elements) {
-        assert.equal(getComputedStyle(element).getPropertyValue('display'),
-            'block');
-      }
-    }
-
-    function assertDisplayPropertyValues(elements, expectedDisplayValues) {
-      for (const key in elements) {
-        if (elements.hasOwnProperty(key)) {
-          assert.equal(
-              getComputedStyle(elements[key]).getPropertyValue('display'),
-              expectedDisplayValues[key]);
-        }
-      }
-    }
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
new file mode 100644
index 0000000..5ccda28
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
@@ -0,0 +1,186 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+class GrStyleTestElement extends PolymerElement {
+  static get is() { return 'gr-style-test-element'; }
+
+  static get template() {
+    return html`<div id="wrapper"></div>`;
+  }
+}
+
+customElements.define(GrStyleTestElement.is, GrStyleTestElement);
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-styles-api tests', () => {
+  let stylesApi;
+
+  setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    pluginLoader.loadPlugins([]);
+    stylesApi = plugin.styles();
+  });
+
+  teardown(() => {
+    stylesApi = null;
+  });
+
+  test('exists', () => {
+    assert.isOk(stylesApi);
+  });
+
+  test('css', () => {
+    const styleObject = stylesApi.css('background: red');
+    assert.isDefined(styleObject);
+  });
+
+  suite('GrStyleObject tests', () => {
+    let stylesApi;
+    let displayInlineStyle;
+    let displayNoneStyle;
+    let elementsToRemove;
+
+    setup(() => {
+      let plugin;
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      pluginLoader.loadPlugins([]);
+      stylesApi = plugin.styles();
+      displayInlineStyle = stylesApi.css('display: inline');
+      displayNoneStyle = stylesApi.css('display: none');
+      elementsToRemove = [];
+    });
+
+    teardown(() => {
+      displayInlineStyle = null;
+      displayNoneStyle = null;
+      stylesApi = null;
+      elementsToRemove.forEach(element => {
+        element.remove();
+      });
+      elementsToRemove = null;
+      sinon.restore();
+    });
+
+    function createNestedElements(parentElement) {
+      /* parentElement
+      *  |--- element1
+      *  |--- element2
+      *       |--- element3
+      **/
+      const element1 = document.createElement('div');
+      const element2 = document.createElement('div');
+      const element3 = document.createElement('div');
+      dom(parentElement).appendChild(element1);
+      dom(parentElement).appendChild(element2);
+      dom(element2).appendChild(element3);
+
+      if (parentElement === document.body) {
+        elementsToRemove.push(element1);
+        elementsToRemove.push(element2);
+      }
+
+      return [element1, element2, element3];
+    }
+
+    test('getClassName  - body level elements', () => {
+      const bodyLevelElements = createNestedElements(document.body);
+
+      testGetClassName(bodyLevelElements);
+    });
+
+    test('getClassName  - elements inside polymer element', () => {
+      const polymerElement = document.createElement('gr-style-test-element');
+      dom(document.body).appendChild(polymerElement);
+      elementsToRemove.push(polymerElement);
+      const contentElements = createNestedElements(polymerElement.$.wrapper);
+
+      testGetClassName(contentElements);
+    });
+
+    function testGetClassName(elements) {
+      assertAllElementsHaveDefaultStyle(elements);
+
+      const className1 = displayInlineStyle.getClassName(elements[0]);
+      const className2 = displayNoneStyle.getClassName(elements[1]);
+      const className3 = displayInlineStyle.getClassName(elements[2]);
+
+      assert.notEqual(className2, className1);
+      assert.equal(className3, className1);
+
+      assertAllElementsHaveDefaultStyle(elements);
+
+      elements[0].classList.add(className1);
+      elements[1].classList.add(className2);
+      elements[2].classList.add(className1);
+
+      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
+    }
+
+    test('apply - body level elements', () => {
+      const bodyLevelElements = createNestedElements(document.body);
+
+      testApply(bodyLevelElements);
+    });
+
+    test('apply - elements inside polymer element', () => {
+      const polymerElement = document.createElement('gr-style-test-element');
+      dom(document.body).appendChild(polymerElement);
+      elementsToRemove.push(polymerElement);
+      const contentElements = createNestedElements(polymerElement.$.wrapper);
+
+      testApply(contentElements);
+    });
+
+    function testApply(elements) {
+      assertAllElementsHaveDefaultStyle(elements);
+      displayInlineStyle.apply(elements[0]);
+      displayNoneStyle.apply(elements[1]);
+      displayInlineStyle.apply(elements[2]);
+      assertDisplayPropertyValues(elements, ['inline', 'none', 'inline']);
+    }
+
+    function assertAllElementsHaveDefaultStyle(elements) {
+      for (const element of elements) {
+        assert.equal(getComputedStyle(element).getPropertyValue('display'),
+            'block');
+      }
+    }
+
+    function assertDisplayPropertyValues(elements, expectedDisplayValues) {
+      for (const key in elements) {
+        if (elements.hasOwnProperty(key)) {
+          assert.equal(
+              getComputedStyle(elements[key]).getPropertyValue('display'),
+              expectedDisplayValues[key]);
+        }
+      }
+    }
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
index 411a7c8..1e37603 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
@@ -14,12 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-Polymer({
-  _template: html`
+
+class CustomPluginHeader extends PolymerElement {
+  static get is() {
+    return 'gr-custom-plugin-header';
+  }
+
+  static get properties() {
+    return {
+      logoUrl: String,
+      title: String,
+    };
+  }
+
+  static get template() {
+    return html`
     <style>
       img {
         width: 1em;
@@ -34,12 +45,8 @@
       <img src="[[logoUrl]]" hidden\$="[[!logoUrl]]">
       <span class="title">[[title]]</span>
     </span>
-`,
+`;
+  }
+}
 
-  is: 'gr-custom-plugin-header',
-
-  properties: {
-    logoUrl: String,
-    title: String,
-  },
-});
+customElements.define(CustomPluginHeader.is, CustomPluginHeader);
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
index c987af3..48b14d3 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
@@ -14,16 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import './gr-custom-plugin-header.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-theme-api">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 /** @constructor */
 export function GrThemeApi(plugin) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
deleted file mode 100644
index 9e2e190..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
+++ /dev/null
@@ -1,87 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-theme-api</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="header-title">
-  <template>
-    <gr-endpoint-decorator name="header-title">
-      <span class="titleText"></span>
-    </gr-endpoint-decorator>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-theme-api tests', () => {
-  let sandbox;
-  let theme;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    theme = plugin.theme();
-  });
-
-  teardown(() => {
-    theme = null;
-    sandbox.restore();
-  });
-
-  test('exists', () => {
-    assert.isOk(theme);
-  });
-
-  suite('header-title', () => {
-    let customHeader;
-
-    setup(() => {
-      fixture('header-title');
-      stub('gr-custom-plugin-header', {
-        /** @override */
-        ready() { customHeader = this; },
-      });
-      pluginLoader.loadPlugins([]);
-    });
-
-    test('sets logo and title', done => {
-      theme.setHeaderLogoAndTitle('foo.jpg', 'bar');
-      flush(() => {
-        assert.isNotNull(customHeader);
-        assert.equal(customHeader.logoUrl, 'foo.jpg');
-        assert.equal(customHeader.title, 'bar');
-        done();
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.js
new file mode 100644
index 0000000..8a70303
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.js
@@ -0,0 +1,73 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
+import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const headerTitleFixture = fixtureFromTemplate(html`
+<gr-endpoint-decorator name="header-title">
+      <span class="titleText"></span>
+    </gr-endpoint-decorator>
+`);
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-theme-api tests', () => {
+  let theme;
+
+  setup(() => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    theme = plugin.theme();
+  });
+
+  teardown(() => {
+    theme = null;
+  });
+
+  test('exists', () => {
+    assert.isOk(theme);
+  });
+
+  suite('header-title', () => {
+    let customHeader;
+
+    setup(() => {
+      headerTitleFixture.instantiate();
+      stub('gr-custom-plugin-header', {
+        /** @override */
+        ready() { customHeader = this; },
+      });
+      pluginLoader.loadPlugins([]);
+    });
+
+    test('sets logo and title', done => {
+      theme.setHeaderLogoAndTitle('foo.jpg', 'bar');
+      flush(() => {
+        assert.isNotNull(customHeader);
+        assert.equal(customHeader.logoUrl, 'foo.jpg');
+        assert.equal(customHeader.title, 'bar');
+        done();
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index 7e312d3..32921d9 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 import '@polymer/iron-input/iron-input.js';
 import '../../shared/gr-avatar/gr-avatar.js';
 import '../../shared/gr-date-formatter/gr-date-formatter.js';
@@ -27,7 +26,7 @@
 import {htmlTemplate} from './gr-account-info_html.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrAccountInfo extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -181,7 +180,7 @@
     if ([
       config,
       username,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return undefined;
     }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js
deleted file mode 100644
index fd94821..0000000
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.js
+++ /dev/null
@@ -1,132 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-avatar {
-      height: 120px;
-      width: 120px;
-      margin-right: var(--spacing-xs);
-      vertical-align: -0.25em;
-    }
-    div section.hide {
-      display: none;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="gr-form-styles">
-    <section>
-      <span class="title"></span>
-      <span class="value">
-        <gr-avatar account="[[_account]]" image-size="120"></gr-avatar>
-      </span>
-    </section>
-    <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
-      <span class="title"></span>
-      <span class="value">
-        <a href$="[[_avatarChangeUrl]]">
-          Change avatar
-        </a>
-      </span>
-    </section>
-    <section>
-      <span class="title">ID</span>
-      <span class="value">[[_account._account_id]]</span>
-    </section>
-    <section>
-      <span class="title">Email</span>
-      <span class="value">[[_account.email]]</span>
-    </section>
-    <section>
-      <span class="title">Registered</span>
-      <span class="value">
-        <gr-date-formatter
-          has-tooltip=""
-          date-str="[[_account.registered_on]]"
-        ></gr-date-formatter>
-      </span>
-    </section>
-    <section id="usernameSection">
-      <span class="title">Username</span>
-      <span hidden$="[[usernameMutable]]" class="value">[[_username]]</span>
-      <span hidden$="[[!usernameMutable]]" class="value">
-        <iron-input on-keydown="_handleKeydown" bind-value="{{_username}}">
-          <input
-            is="iron-input"
-            id="usernameInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-            bind-value="{{_username}}"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section id="nameSection">
-      <span class="title">Full name</span>
-      <span hidden$="[[nameMutable]]" class="value">[[_account.name]]</span>
-      <span hidden$="[[!nameMutable]]" class="value">
-        <iron-input on-keydown="_handleKeydown" bind-value="{{_account.name}}">
-          <input
-            is="iron-input"
-            id="nameInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-            bind-value="{{_account.name}}"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <span class="title">Display name</span>
-      <span class="value">
-        <iron-input
-          on-keydown="_handleKeydown"
-          bind-value="{{_account.display_name}}"
-        >
-          <input
-            is="iron-input"
-            id="displayNameInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-            bind-value="{{_account.display_name}}"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <span class="title">Status (e.g. "Vacation")</span>
-      <span class="value">
-        <iron-input
-          on-keydown="_handleKeydown"
-          bind-value="{{_account.status}}"
-        >
-          <input
-            is="iron-input"
-            id="statusInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-            bind-value="{{_account.status}}"
-          />
-        </iron-input>
-      </span>
-    </section>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
new file mode 100644
index 0000000..d69a279
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
@@ -0,0 +1,132 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    gr-avatar {
+      height: 120px;
+      width: 120px;
+      margin-right: var(--spacing-xs);
+      vertical-align: -0.25em;
+    }
+    div section.hide {
+      display: none;
+    }
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <div class="gr-form-styles">
+    <section>
+      <span class="title"></span>
+      <span class="value">
+        <gr-avatar account="[[_account]]" image-size="120"></gr-avatar>
+      </span>
+    </section>
+    <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
+      <span class="title"></span>
+      <span class="value">
+        <a href$="[[_avatarChangeUrl]]">
+          Change avatar
+        </a>
+      </span>
+    </section>
+    <section>
+      <span class="title">ID</span>
+      <span class="value">[[_account._account_id]]</span>
+    </section>
+    <section>
+      <span class="title">Email</span>
+      <span class="value">[[_account.email]]</span>
+    </section>
+    <section>
+      <span class="title">Registered</span>
+      <span class="value">
+        <gr-date-formatter
+          has-tooltip=""
+          date-str="[[_account.registered_on]]"
+        ></gr-date-formatter>
+      </span>
+    </section>
+    <section id="usernameSection">
+      <span class="title">Username</span>
+      <span hidden$="[[usernameMutable]]" class="value">[[_username]]</span>
+      <span hidden$="[[!usernameMutable]]" class="value">
+        <iron-input on-keydown="_handleKeydown" bind-value="{{_username}}">
+          <input
+            is="iron-input"
+            id="usernameInput"
+            disabled="[[_saving]]"
+            on-keydown="_handleKeydown"
+            bind-value="{{_username}}"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section id="nameSection">
+      <span class="title">Full name</span>
+      <span hidden$="[[nameMutable]]" class="value">[[_account.name]]</span>
+      <span hidden$="[[!nameMutable]]" class="value">
+        <iron-input on-keydown="_handleKeydown" bind-value="{{_account.name}}">
+          <input
+            is="iron-input"
+            id="nameInput"
+            disabled="[[_saving]]"
+            on-keydown="_handleKeydown"
+            bind-value="{{_account.name}}"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Display name</span>
+      <span class="value">
+        <iron-input
+          on-keydown="_handleKeydown"
+          bind-value="{{_account.display_name}}"
+        >
+          <input
+            is="iron-input"
+            id="displayNameInput"
+            disabled="[[_saving]]"
+            on-keydown="_handleKeydown"
+            bind-value="{{_account.display_name}}"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Status (e.g. "Vacation")</span>
+      <span class="value">
+        <iron-input
+          on-keydown="_handleKeydown"
+          bind-value="{{_account.status}}"
+        >
+          <input
+            is="iron-input"
+            id="statusInput"
+            disabled="[[_saving]]"
+            on-keydown="_handleKeydown"
+            bind-value="{{_account.status}}"
+          />
+        </iron-input>
+      </span>
+    </section>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
deleted file mode 100644
index 53641d9..0000000
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
+++ /dev/null
@@ -1,342 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-account-info</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-account-info></gr-account-info>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-account-info.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-account-info tests', () => {
-  let element;
-  let account;
-  let config;
-  let sandbox;
-
-  function valueOf(title) {
-    const sections = dom(element.root).querySelectorAll('section');
-    let titleEl;
-    for (let i = 0; i < sections.length; i++) {
-      titleEl = sections[i].querySelector('.title');
-      if (titleEl.textContent === title) {
-        return sections[i].querySelector('.value');
-      }
-    }
-  }
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    account = {
-      _account_id: 123,
-      name: 'user name',
-      email: 'user@email',
-      username: 'user username',
-      registered: '2000-01-01 00:00:00.000000000',
-    };
-    config = {auth: {editable_account_fields: []}};
-
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(account); },
-      getConfig() { return Promise.resolve(config); },
-      getPreferences() {
-        return Promise.resolve({time_format: 'HHMM_12'});
-      },
-    });
-    element = fixture('basic');
-    // Allow the element to render.
-    element.loadData().then(() => { flush(done); });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('basic account info render', () => {
-    assert.isFalse(element._loading);
-
-    assert.equal(valueOf('ID').textContent, account._account_id);
-    assert.equal(valueOf('Email').textContent, account.email);
-    assert.equal(valueOf('Username').textContent, account.username);
-  });
-
-  test('full name render (immutable)', () => {
-    const section = element.$.nameSection;
-    const displaySpan = section.querySelectorAll('.value')[0];
-    const inputSpan = section.querySelectorAll('.value')[1];
-
-    assert.isFalse(element.nameMutable);
-    assert.isFalse(displaySpan.hasAttribute('hidden'));
-    assert.equal(displaySpan.textContent, account.name);
-    assert.isTrue(inputSpan.hasAttribute('hidden'));
-  });
-
-  test('full name render (mutable)', () => {
-    element.set('_serverConfig',
-        {auth: {editable_account_fields: ['FULL_NAME']}});
-
-    const section = element.$.nameSection;
-    const displaySpan = section.querySelectorAll('.value')[0];
-    const inputSpan = section.querySelectorAll('.value')[1];
-
-    assert.isTrue(element.nameMutable);
-    assert.isTrue(displaySpan.hasAttribute('hidden'));
-    assert.equal(element.$.nameInput.bindValue, account.name);
-    assert.isFalse(inputSpan.hasAttribute('hidden'));
-  });
-
-  test('username render (immutable)', () => {
-    const section = element.$.usernameSection;
-    const displaySpan = section.querySelectorAll('.value')[0];
-    const inputSpan = section.querySelectorAll('.value')[1];
-
-    assert.isFalse(element.usernameMutable);
-    assert.isFalse(displaySpan.hasAttribute('hidden'));
-    assert.equal(displaySpan.textContent, account.username);
-    assert.isTrue(inputSpan.hasAttribute('hidden'));
-  });
-
-  test('username render (mutable)', () => {
-    element.set('_serverConfig',
-        {auth: {editable_account_fields: ['USER_NAME']}});
-    element.set('_account.username', '');
-    element.set('_username', '');
-
-    const section = element.$.usernameSection;
-    const displaySpan = section.querySelectorAll('.value')[0];
-    const inputSpan = section.querySelectorAll('.value')[1];
-
-    assert.isTrue(element.usernameMutable);
-    assert.isTrue(displaySpan.hasAttribute('hidden'));
-    assert.equal(element.$.usernameInput.bindValue, account.username);
-    assert.isFalse(inputSpan.hasAttribute('hidden'));
-  });
-
-  suite('account info edit', () => {
-    let nameChangedSpy;
-    let usernameChangedSpy;
-    let statusChangedSpy;
-    let nameStub;
-    let usernameStub;
-    let statusStub;
-
-    setup(() => {
-      nameChangedSpy = sandbox.spy(element, '_nameChanged');
-      usernameChangedSpy = sandbox.spy(element, '_usernameChanged');
-      statusChangedSpy = sandbox.spy(element, '_statusChanged');
-      element.set('_serverConfig',
-          {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
-
-      nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
-          name => Promise.resolve());
-      usernameStub = sandbox.stub(element.$.restAPI, 'setAccountUsername',
-          username => Promise.resolve());
-      statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-          status => Promise.resolve());
-    });
-
-    test('name', done => {
-      assert.isTrue(element.nameMutable);
-      assert.isFalse(element.hasUnsavedChanges);
-
-      element.set('_account.name', 'new name');
-
-      assert.isTrue(nameChangedSpy.called);
-      assert.isFalse(statusChangedSpy.called);
-      assert.isTrue(element.hasUnsavedChanges);
-
-      element.save().then(() => {
-        assert.isFalse(usernameStub.called);
-        assert.isTrue(nameStub.called);
-        assert.isFalse(statusStub.called);
-        nameStub.lastCall.returnValue.then(() => {
-          assert.equal(nameStub.lastCall.args[0], 'new name');
-          done();
-        });
-      });
-    });
-
-    test('username', done => {
-      element.set('_account.username', '');
-      element._hasUsernameChange = false;
-      assert.isTrue(element.usernameMutable);
-
-      element.set('_username', 'new username');
-
-      assert.isTrue(usernameChangedSpy.called);
-      assert.isFalse(statusChangedSpy.called);
-      assert.isTrue(element.hasUnsavedChanges);
-
-      element.save().then(() => {
-        assert.isTrue(usernameStub.called);
-        assert.isFalse(nameStub.called);
-        assert.isFalse(statusStub.called);
-        usernameStub.lastCall.returnValue.then(() => {
-          assert.equal(usernameStub.lastCall.args[0], 'new username');
-          done();
-        });
-      });
-    });
-
-    test('status', done => {
-      assert.isFalse(element.hasUnsavedChanges);
-
-      element.set('_account.status', 'new status');
-
-      assert.isFalse(nameChangedSpy.called);
-      assert.isTrue(statusChangedSpy.called);
-      assert.isTrue(element.hasUnsavedChanges);
-
-      element.save().then(() => {
-        assert.isFalse(usernameStub.called);
-        assert.isTrue(statusStub.called);
-        assert.isFalse(nameStub.called);
-        statusStub.lastCall.returnValue.then(() => {
-          assert.equal(statusStub.lastCall.args[0], 'new status');
-          done();
-        });
-      });
-    });
-  });
-
-  suite('edit name and status', () => {
-    let nameChangedSpy;
-    let statusChangedSpy;
-    let nameStub;
-    let statusStub;
-
-    setup(() => {
-      nameChangedSpy = sandbox.spy(element, '_nameChanged');
-      statusChangedSpy = sandbox.spy(element, '_statusChanged');
-      element.set('_serverConfig',
-          {auth: {editable_account_fields: ['FULL_NAME']}});
-
-      nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
-          name => Promise.resolve());
-      statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-          status => Promise.resolve());
-      sandbox.stub(element.$.restAPI, 'setAccountUsername',
-          username => Promise.resolve());
-    });
-
-    test('set name and status', done => {
-      assert.isTrue(element.nameMutable);
-      assert.isFalse(element.hasUnsavedChanges);
-
-      element.set('_account.name', 'new name');
-
-      assert.isTrue(nameChangedSpy.called);
-
-      element.set('_account.status', 'new status');
-
-      assert.isTrue(statusChangedSpy.called);
-
-      assert.isTrue(element.hasUnsavedChanges);
-
-      element.save().then(() => {
-        assert.isTrue(statusStub.called);
-        assert.isTrue(nameStub.called);
-
-        assert.equal(nameStub.lastCall.args[0], 'new name');
-
-        assert.equal(statusStub.lastCall.args[0], 'new status');
-
-        done();
-      });
-    });
-  });
-
-  suite('set status but read name', () => {
-    let statusChangedSpy;
-    let statusStub;
-
-    setup(() => {
-      statusChangedSpy = sandbox.spy(element, '_statusChanged');
-      element.set('_serverConfig',
-          {auth: {editable_account_fields: []}});
-
-      statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-          status => Promise.resolve());
-    });
-
-    test('read full name but set status', done => {
-      const section = element.$.nameSection;
-      const displaySpan = section.querySelectorAll('.value')[0];
-      const inputSpan = section.querySelectorAll('.value')[1];
-
-      assert.isFalse(element.nameMutable);
-
-      assert.isFalse(element.hasUnsavedChanges);
-
-      assert.isFalse(displaySpan.hasAttribute('hidden'));
-      assert.equal(displaySpan.textContent, account.name);
-      assert.isTrue(inputSpan.hasAttribute('hidden'));
-
-      element.set('_account.status', 'new status');
-
-      assert.isTrue(statusChangedSpy.called);
-
-      assert.isTrue(element.hasUnsavedChanges);
-
-      element.save().then(() => {
-        assert.isTrue(statusStub.called);
-        statusStub.lastCall.returnValue.then(() => {
-          assert.equal(statusStub.lastCall.args[0], 'new status');
-          done();
-        });
-      });
-    });
-  });
-
-  test('_usernameChanged compares usernames with loose equality', () => {
-    element._account = {};
-    element._username = '';
-    element._hasUsernameChange = false;
-    element._loading = false;
-    // _usernameChanged is an observer, but call it here after setting
-    // _hasUsernameChange in the test to force recomputation.
-    element._usernameChanged();
-    flushAsynchronousOperations();
-
-    assert.isFalse(element._hasUsernameChange);
-
-    element.set('_username', 'test');
-    flushAsynchronousOperations();
-
-    assert.isTrue(element._hasUsernameChange);
-  });
-
-  test('_hideAvatarChangeUrl', () => {
-    assert.equal(element._hideAvatarChangeUrl(''), 'hide');
-
-    assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
new file mode 100644
index 0000000..4a62bab
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
@@ -0,0 +1,322 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-account-info.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-account-info');
+
+suite('gr-account-info tests', () => {
+  let element;
+  let account;
+  let config;
+
+  function valueOf(title) {
+    const sections = dom(element.root).querySelectorAll('section');
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl.textContent === title) {
+        return sections[i].querySelector('.value');
+      }
+    }
+  }
+
+  setup(done => {
+    account = {
+      _account_id: 123,
+      name: 'user name',
+      email: 'user@email',
+      username: 'user username',
+      registered: '2000-01-01 00:00:00.000000000',
+    };
+    config = {auth: {editable_account_fields: []}};
+
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(account); },
+      getConfig() { return Promise.resolve(config); },
+      getPreferences() {
+        return Promise.resolve({time_format: 'HHMM_12'});
+      },
+    });
+    element = basicFixture.instantiate();
+    // Allow the element to render.
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('basic account info render', () => {
+    assert.isFalse(element._loading);
+
+    assert.equal(valueOf('ID').textContent, account._account_id);
+    assert.equal(valueOf('Email').textContent, account.email);
+    assert.equal(valueOf('Username').textContent, account.username);
+  });
+
+  test('full name render (immutable)', () => {
+    const section = element.$.nameSection;
+    const displaySpan = section.querySelectorAll('.value')[0];
+    const inputSpan = section.querySelectorAll('.value')[1];
+
+    assert.isFalse(element.nameMutable);
+    assert.isFalse(displaySpan.hasAttribute('hidden'));
+    assert.equal(displaySpan.textContent, account.name);
+    assert.isTrue(inputSpan.hasAttribute('hidden'));
+  });
+
+  test('full name render (mutable)', () => {
+    element.set('_serverConfig',
+        {auth: {editable_account_fields: ['FULL_NAME']}});
+
+    const section = element.$.nameSection;
+    const displaySpan = section.querySelectorAll('.value')[0];
+    const inputSpan = section.querySelectorAll('.value')[1];
+
+    assert.isTrue(element.nameMutable);
+    assert.isTrue(displaySpan.hasAttribute('hidden'));
+    assert.equal(element.$.nameInput.bindValue, account.name);
+    assert.isFalse(inputSpan.hasAttribute('hidden'));
+  });
+
+  test('username render (immutable)', () => {
+    const section = element.$.usernameSection;
+    const displaySpan = section.querySelectorAll('.value')[0];
+    const inputSpan = section.querySelectorAll('.value')[1];
+
+    assert.isFalse(element.usernameMutable);
+    assert.isFalse(displaySpan.hasAttribute('hidden'));
+    assert.equal(displaySpan.textContent, account.username);
+    assert.isTrue(inputSpan.hasAttribute('hidden'));
+  });
+
+  test('username render (mutable)', () => {
+    element.set('_serverConfig',
+        {auth: {editable_account_fields: ['USER_NAME']}});
+    element.set('_account.username', '');
+    element.set('_username', '');
+
+    const section = element.$.usernameSection;
+    const displaySpan = section.querySelectorAll('.value')[0];
+    const inputSpan = section.querySelectorAll('.value')[1];
+
+    assert.isTrue(element.usernameMutable);
+    assert.isTrue(displaySpan.hasAttribute('hidden'));
+    assert.equal(element.$.usernameInput.bindValue, account.username);
+    assert.isFalse(inputSpan.hasAttribute('hidden'));
+  });
+
+  suite('account info edit', () => {
+    let nameChangedSpy;
+    let usernameChangedSpy;
+    let statusChangedSpy;
+    let nameStub;
+    let usernameStub;
+    let statusStub;
+
+    setup(() => {
+      nameChangedSpy = sinon.spy(element, '_nameChanged');
+      usernameChangedSpy = sinon.spy(element, '_usernameChanged');
+      statusChangedSpy = sinon.spy(element, '_statusChanged');
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
+
+      nameStub = sinon.stub(element.$.restAPI, 'setAccountName').callsFake(
+          name => Promise.resolve());
+      usernameStub = sinon.stub(element.$.restAPI, 'setAccountUsername')
+          .callsFake(username => Promise.resolve());
+      statusStub = sinon.stub(element.$.restAPI, 'setAccountStatus').callsFake(
+          status => Promise.resolve());
+    });
+
+    test('name', done => {
+      assert.isTrue(element.nameMutable);
+      assert.isFalse(element.hasUnsavedChanges);
+
+      element.set('_account.name', 'new name');
+
+      assert.isTrue(nameChangedSpy.called);
+      assert.isFalse(statusChangedSpy.called);
+      assert.isTrue(element.hasUnsavedChanges);
+
+      element.save().then(() => {
+        assert.isFalse(usernameStub.called);
+        assert.isTrue(nameStub.called);
+        assert.isFalse(statusStub.called);
+        nameStub.lastCall.returnValue.then(() => {
+          assert.equal(nameStub.lastCall.args[0], 'new name');
+          done();
+        });
+      });
+    });
+
+    test('username', done => {
+      element.set('_account.username', '');
+      element._hasUsernameChange = false;
+      assert.isTrue(element.usernameMutable);
+
+      element.set('_username', 'new username');
+
+      assert.isTrue(usernameChangedSpy.called);
+      assert.isFalse(statusChangedSpy.called);
+      assert.isTrue(element.hasUnsavedChanges);
+
+      element.save().then(() => {
+        assert.isTrue(usernameStub.called);
+        assert.isFalse(nameStub.called);
+        assert.isFalse(statusStub.called);
+        usernameStub.lastCall.returnValue.then(() => {
+          assert.equal(usernameStub.lastCall.args[0], 'new username');
+          done();
+        });
+      });
+    });
+
+    test('status', done => {
+      assert.isFalse(element.hasUnsavedChanges);
+
+      element.set('_account.status', 'new status');
+
+      assert.isFalse(nameChangedSpy.called);
+      assert.isTrue(statusChangedSpy.called);
+      assert.isTrue(element.hasUnsavedChanges);
+
+      element.save().then(() => {
+        assert.isFalse(usernameStub.called);
+        assert.isTrue(statusStub.called);
+        assert.isFalse(nameStub.called);
+        statusStub.lastCall.returnValue.then(() => {
+          assert.equal(statusStub.lastCall.args[0], 'new status');
+          done();
+        });
+      });
+    });
+  });
+
+  suite('edit name and status', () => {
+    let nameChangedSpy;
+    let statusChangedSpy;
+    let nameStub;
+    let statusStub;
+
+    setup(() => {
+      nameChangedSpy = sinon.spy(element, '_nameChanged');
+      statusChangedSpy = sinon.spy(element, '_statusChanged');
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: ['FULL_NAME']}});
+
+      nameStub = sinon.stub(element.$.restAPI, 'setAccountName').callsFake(
+          name => Promise.resolve());
+      statusStub = sinon.stub(element.$.restAPI, 'setAccountStatus').callsFake(
+          status => Promise.resolve());
+      sinon.stub(element.$.restAPI, 'setAccountUsername').callsFake(
+          username => Promise.resolve());
+    });
+
+    test('set name and status', done => {
+      assert.isTrue(element.nameMutable);
+      assert.isFalse(element.hasUnsavedChanges);
+
+      element.set('_account.name', 'new name');
+
+      assert.isTrue(nameChangedSpy.called);
+
+      element.set('_account.status', 'new status');
+
+      assert.isTrue(statusChangedSpy.called);
+
+      assert.isTrue(element.hasUnsavedChanges);
+
+      element.save().then(() => {
+        assert.isTrue(statusStub.called);
+        assert.isTrue(nameStub.called);
+
+        assert.equal(nameStub.lastCall.args[0], 'new name');
+
+        assert.equal(statusStub.lastCall.args[0], 'new status');
+
+        done();
+      });
+    });
+  });
+
+  suite('set status but read name', () => {
+    let statusChangedSpy;
+    let statusStub;
+
+    setup(() => {
+      statusChangedSpy = sinon.spy(element, '_statusChanged');
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: []}});
+
+      statusStub = sinon.stub(element.$.restAPI, 'setAccountStatus').callsFake(
+          status => Promise.resolve());
+    });
+
+    test('read full name but set status', done => {
+      const section = element.$.nameSection;
+      const displaySpan = section.querySelectorAll('.value')[0];
+      const inputSpan = section.querySelectorAll('.value')[1];
+
+      assert.isFalse(element.nameMutable);
+
+      assert.isFalse(element.hasUnsavedChanges);
+
+      assert.isFalse(displaySpan.hasAttribute('hidden'));
+      assert.equal(displaySpan.textContent, account.name);
+      assert.isTrue(inputSpan.hasAttribute('hidden'));
+
+      element.set('_account.status', 'new status');
+
+      assert.isTrue(statusChangedSpy.called);
+
+      assert.isTrue(element.hasUnsavedChanges);
+
+      element.save().then(() => {
+        assert.isTrue(statusStub.called);
+        statusStub.lastCall.returnValue.then(() => {
+          assert.equal(statusStub.lastCall.args[0], 'new status');
+          done();
+        });
+      });
+    });
+  });
+
+  test('_usernameChanged compares usernames with loose equality', () => {
+    element._account = {};
+    element._username = '';
+    element._hasUsernameChange = false;
+    element._loading = false;
+    // _usernameChanged is an observer, but call it here after setting
+    // _hasUsernameChange in the test to force recomputation.
+    element._usernameChanged();
+    flushAsynchronousOperations();
+
+    assert.isFalse(element._hasUsernameChange);
+
+    element.set('_username', 'test');
+    flushAsynchronousOperations();
+
+    assert.isTrue(element._hasUsernameChange);
+  });
+
+  test('_hideAvatarChangeUrl', () => {
+    assert.equal(element._hideAvatarChangeUrl(''), 'hide');
+
+    assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
index 390baf6..e0da53d 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
@@ -15,25 +15,21 @@
  * limitations under the License.
  */
 
-import '../../../scripts/bundled-polymer.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-agreements-list_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrAgreementsList extends mixinBehaviors( [
-  BaseUrlBehavior,
-], GestureEventListeners(
+class GrAgreementsList extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-agreements-list'; }
@@ -57,11 +53,11 @@
   }
 
   getUrl() {
-    return this.getBaseUrl() + '/settings/new-agreement';
+    return getBaseUrl() + '/settings/new-agreement';
   }
 
   getUrlBase(item) {
-    return this.getBaseUrl() + '/' + item;
+    return getBaseUrl() + '/' + item;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.js
deleted file mode 100644
index 1cd9ce2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    #agreements .nameColumn {
-      min-width: 15em;
-      width: auto;
-    }
-    #agreements .descriptionColumn {
-      width: auto;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="gr-form-styles">
-    <table id="agreements">
-      <thead>
-        <tr>
-          <th class="nameColumn">Name</th>
-          <th class="descriptionColumn">Description</th>
-        </tr>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[_agreements]]">
-          <tr>
-            <td class="nameColumn">
-              <a href$="[[getUrlBase(item.url)]]" rel="external">
-                [[item.name]]
-              </a>
-            </td>
-            <td class="descriptionColumn">[[item.description]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-    <a href$="[[getUrl()]]">New Contributor Agreement</a>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.ts
new file mode 100644
index 0000000..194ca2b
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    #agreements .nameColumn {
+      min-width: 15em;
+      width: auto;
+    }
+    #agreements .descriptionColumn {
+      width: auto;
+    }
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <div class="gr-form-styles">
+    <table id="agreements">
+      <thead>
+        <tr>
+          <th class="nameColumn">Name</th>
+          <th class="descriptionColumn">Description</th>
+        </tr>
+      </thead>
+      <tbody>
+        <template is="dom-repeat" items="[[_agreements]]">
+          <tr>
+            <td class="nameColumn">
+              <a href$="[[getUrlBase(item.url)]]" rel="external">
+                [[item.name]]
+              </a>
+            </td>
+            <td class="descriptionColumn">[[item.description]]</td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+    <a href$="[[getUrl()]]">New Contributor Agreement</a>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
deleted file mode 100644
index 3a2b86d..0000000
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
+++ /dev/null
@@ -1,70 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-settings-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-agreements-list></gr-agreements-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-agreements-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-agreements-list tests', () => {
-  let element;
-  let agreements;
-
-  setup(done => {
-    agreements = [{
-      url: 'some url',
-      description: 'Agreements 1 description',
-      name: 'Agreements 1',
-    }];
-
-    stub('gr-rest-api-interface', {
-      getAccountAgreements() { return Promise.resolve(agreements); },
-    });
-
-    element = fixture('basic');
-
-    element.loadData().then(() => { flush(done); });
-  });
-
-  test('renders', () => {
-    const rows = dom(element.root).querySelectorAll('tbody tr');
-
-    assert.equal(rows.length, 1);
-
-    const nameCells = Array.from(rows).map(row =>
-      row.querySelectorAll('td')[0].textContent.trim()
-    );
-
-    assert.equal(nameCells[0], 'Agreements 1');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js
new file mode 100644
index 0000000..ed0bdb3
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-agreements-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-agreements-list');
+
+suite('gr-agreements-list tests', () => {
+  let element;
+  let agreements;
+
+  setup(done => {
+    agreements = [{
+      url: 'some url',
+      description: 'Agreements 1 description',
+      name: 'Agreements 1',
+    }];
+
+    stub('gr-rest-api-interface', {
+      getAccountAgreements() { return Promise.resolve(agreements); },
+    });
+
+    element = basicFixture.instantiate();
+
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('renders', () => {
+    const rows = dom(element.root).querySelectorAll('tbody tr');
+
+    assert.equal(rows.length, 1);
+
+    const nameCells = Array.from(rows).map(row =>
+      row.querySelectorAll('td')[0].textContent.trim()
+    );
+
+    assert.equal(nameCells[0], 'Agreements 1');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
index 61e8e93..55ce596 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
@@ -14,28 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-date-formatter/gr-date-formatter.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../../styles/shared-styles.js';
 import '../../../styles/gr-form-styles.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-change-table-editor_html.js';
-import {ChangeTableBehavior} from '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrChangeTableEditor extends mixinBehaviors( [
-  ChangeTableBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrChangeTableEditor extends ChangeTableMixin(GestureEventListeners(
+    LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-change-table-editor'; }
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.js
deleted file mode 100644
index d63e627..0000000
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    #changeCols {
-      width: auto;
-    }
-    #changeCols .visibleHeader {
-      text-align: center;
-    }
-    .checkboxContainer {
-      cursor: pointer;
-      text-align: center;
-    }
-    .checkboxContainer input {
-      cursor: pointer;
-    }
-    .checkboxContainer:hover {
-      outline: 1px solid var(--border-color);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="changeCols">
-      <thead>
-        <tr>
-          <th class="nameHeader">Column</th>
-          <th class="visibleHeader">Visible</th>
-        </tr>
-      </thead>
-      <tbody>
-        <tr>
-          <td>Number</td>
-          <td
-            class="checkboxContainer"
-            on-click="_handleCheckboxContainerClick"
-          >
-            <input
-              type="checkbox"
-              name="number"
-              on-click="_handleNumberCheckboxClick"
-              checked$="[[showNumber]]"
-            />
-          </td>
-        </tr>
-        <template is="dom-repeat" items="[[columnNames]]">
-          <tr>
-            <td>[[item]]</td>
-            <td
-              class="checkboxContainer"
-              on-click="_handleCheckboxContainerClick"
-            >
-              <input
-                type="checkbox"
-                name="[[item]]"
-                on-click="_handleTargetClick"
-                checked$="[[!isColumnHidden(item, displayedColumns)]]"
-              />
-            </td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
new file mode 100644
index 0000000..1233cf1
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
@@ -0,0 +1,83 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    #changeCols {
+      width: auto;
+    }
+    #changeCols .visibleHeader {
+      text-align: center;
+    }
+    .checkboxContainer {
+      cursor: pointer;
+      text-align: center;
+    }
+    .checkboxContainer input {
+      cursor: pointer;
+    }
+    .checkboxContainer:hover {
+      outline: 1px solid var(--border-color);
+    }
+  </style>
+  <div class="gr-form-styles">
+    <table id="changeCols">
+      <thead>
+        <tr>
+          <th class="nameHeader">Column</th>
+          <th class="visibleHeader">Visible</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr>
+          <td>Number</td>
+          <td
+            class="checkboxContainer"
+            on-click="_handleCheckboxContainerClick"
+          >
+            <input
+              type="checkbox"
+              name="number"
+              on-click="_handleNumberCheckboxClick"
+              checked$="[[showNumber]]"
+            />
+          </td>
+        </tr>
+        <template is="dom-repeat" items="[[columnNames]]">
+          <tr>
+            <td>[[item]]</td>
+            <td
+              class="checkboxContainer"
+              on-click="_handleCheckboxContainerClick"
+            >
+              <input
+                type="checkbox"
+                name="[[item]]"
+                on-click="_handleTargetClick"
+                checked$="[[!isColumnHidden(item, displayedColumns)]]"
+              />
+            </td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
deleted file mode 100644
index 79d1390..0000000
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
+++ /dev/null
@@ -1,171 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-settings-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-table-editor></gr-change-table-editor>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-change-table-editor.js';
-suite('gr-change-table-editor tests', () => {
-  let element;
-  let columns;
-  let sandbox;
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-
-    columns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-    ];
-
-    element.set('displayedColumns', columns);
-    element.showNumber = false;
-    flushAsynchronousOperations();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('tbody').querySelectorAll('tr');
-    let tds;
-
-    // The `+ 1` is for the number column, which isn't included in the change
-    // table behavior's list.
-    assert.equal(rows.length, element.columnNames.length + 1);
-    for (let i = 0; i < columns.length; i++) {
-      tds = rows[i + 1].querySelectorAll('td');
-      assert.equal(tds[0].textContent, columns[i]);
-    }
-  });
-
-  test('hide item', () => {
-    const checkbox = element.shadowRoot
-        .querySelector('table tr:nth-child(2) input');
-    const isChecked = checkbox.checked;
-    const displayedLength = element.displayedColumns.length;
-    assert.isTrue(isChecked);
-
-    MockInteractions.tap(checkbox);
-    flushAsynchronousOperations();
-
-    assert.equal(element.displayedColumns.length, displayedLength - 1);
-  });
-
-  test('show item', () => {
-    element.set('displayedColumns', [
-      'Status',
-      'Owner',
-      'Assignee',
-      'Repo',
-      'Branch',
-      'Updated',
-    ]);
-    flushAsynchronousOperations();
-    const checkbox = element.shadowRoot
-        .querySelector('table tr:nth-child(2) input');
-    const isChecked = checkbox.checked;
-    const displayedLength = element.displayedColumns.length;
-    assert.isFalse(isChecked);
-    assert.equal(element.shadowRoot
-        .querySelector('table').style.display, '');
-
-    MockInteractions.tap(checkbox);
-    flushAsynchronousOperations();
-
-    assert.equal(element.displayedColumns.length,
-        displayedLength + 1);
-  });
-
-  test('_getDisplayedColumns', () => {
-    assert.deepEqual(element._getDisplayedColumns(), columns);
-    MockInteractions.tap(
-        element.shadowRoot
-            .querySelector('.checkboxContainer input[name=Assignee]'));
-    assert.deepEqual(element._getDisplayedColumns(),
-        columns.filter(c => c !== 'Assignee'));
-  });
-
-  test('_handleCheckboxContainerClick relayes taps to checkboxes', () => {
-    sandbox.stub(element, '_handleNumberCheckboxClick');
-    sandbox.stub(element, '_handleTargetClick');
-
-    MockInteractions.tap(
-        element.shadowRoot
-            .querySelector('table tr:first-of-type .checkboxContainer'));
-    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-    assert.isFalse(element._handleTargetClick.called);
-
-    MockInteractions.tap(
-        element.shadowRoot
-            .querySelector('table tr:last-of-type .checkboxContainer'));
-    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-    assert.isTrue(element._handleTargetClick.calledOnce);
-  });
-
-  test('_handleNumberCheckboxClick', () => {
-    sandbox.spy(element, '_handleNumberCheckboxClick');
-
-    MockInteractions
-        .tap(element.shadowRoot
-            .querySelector('.checkboxContainer input[name=number]'));
-    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
-    assert.isTrue(element.showNumber);
-
-    MockInteractions
-        .tap(element.shadowRoot
-            .querySelector('.checkboxContainer input[name=number]'));
-    assert.isTrue(element._handleNumberCheckboxClick.calledTwice);
-    assert.isFalse(element.showNumber);
-  });
-
-  test('_handleTargetClick', () => {
-    sandbox.spy(element, '_handleTargetClick');
-    assert.include(element.displayedColumns, 'Assignee');
-    MockInteractions
-        .tap(element.shadowRoot
-            .querySelector('.checkboxContainer input[name=Assignee]'));
-    assert.isTrue(element._handleTargetClick.calledOnce);
-    assert.notInclude(element.displayedColumns, 'Assignee');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
new file mode 100644
index 0000000..3fca9d2
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
@@ -0,0 +1,151 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-table-editor.js';
+
+const basicFixture = fixtureFromElement('gr-change-table-editor');
+
+suite('gr-change-table-editor tests', () => {
+  let element;
+  let columns;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    columns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Repo',
+      'Branch',
+      'Updated',
+    ];
+
+    element.set('displayedColumns', columns);
+    element.showNumber = false;
+    flushAsynchronousOperations();
+  });
+
+  test('renders', () => {
+    const rows = element.shadowRoot
+        .querySelector('tbody').querySelectorAll('tr');
+    let tds;
+
+    // The `+ 1` is for the number column, which isn't included in the change
+    // table behavior's list.
+    assert.equal(rows.length, element.columnNames.length + 1);
+    for (let i = 0; i < columns.length; i++) {
+      tds = rows[i + 1].querySelectorAll('td');
+      assert.equal(tds[0].textContent, columns[i]);
+    }
+  });
+
+  test('hide item', () => {
+    const checkbox = element.shadowRoot
+        .querySelector('table tr:nth-child(2) input');
+    const isChecked = checkbox.checked;
+    const displayedLength = element.displayedColumns.length;
+    assert.isTrue(isChecked);
+
+    MockInteractions.tap(checkbox);
+    flushAsynchronousOperations();
+
+    assert.equal(element.displayedColumns.length, displayedLength - 1);
+  });
+
+  test('show item', () => {
+    element.set('displayedColumns', [
+      'Status',
+      'Owner',
+      'Assignee',
+      'Repo',
+      'Branch',
+      'Updated',
+    ]);
+    flushAsynchronousOperations();
+    const checkbox = element.shadowRoot
+        .querySelector('table tr:nth-child(2) input');
+    const isChecked = checkbox.checked;
+    const displayedLength = element.displayedColumns.length;
+    assert.isFalse(isChecked);
+    assert.equal(element.shadowRoot
+        .querySelector('table').style.display, '');
+
+    MockInteractions.tap(checkbox);
+    flushAsynchronousOperations();
+
+    assert.equal(element.displayedColumns.length,
+        displayedLength + 1);
+  });
+
+  test('_getDisplayedColumns', () => {
+    assert.deepEqual(element._getDisplayedColumns(), columns);
+    MockInteractions.tap(
+        element.shadowRoot
+            .querySelector('.checkboxContainer input[name=Assignee]'));
+    assert.deepEqual(element._getDisplayedColumns(),
+        columns.filter(c => c !== 'Assignee'));
+  });
+
+  test('_handleCheckboxContainerClick relays taps to checkboxes', () => {
+    sinon.stub(element, '_handleNumberCheckboxClick');
+    sinon.stub(element, '_handleTargetClick');
+
+    MockInteractions.tap(
+        element.shadowRoot
+            .querySelector('table tr:first-of-type .checkboxContainer'));
+    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
+    assert.isFalse(element._handleTargetClick.called);
+
+    MockInteractions.tap(
+        element.shadowRoot
+            .querySelector('table tr:last-of-type .checkboxContainer'));
+    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
+    assert.isTrue(element._handleTargetClick.calledOnce);
+  });
+
+  test('_handleNumberCheckboxClick', () => {
+    sinon.spy(element, '_handleNumberCheckboxClick');
+
+    MockInteractions
+        .tap(element.shadowRoot
+            .querySelector('.checkboxContainer input[name=number]'));
+    assert.isTrue(element._handleNumberCheckboxClick.calledOnce);
+    assert.isTrue(element.showNumber);
+
+    MockInteractions
+        .tap(element.shadowRoot
+            .querySelector('.checkboxContainer input[name=number]'));
+    assert.isTrue(element._handleNumberCheckboxClick.calledTwice);
+    assert.isFalse(element.showNumber);
+  });
+
+  test('_handleTargetClick', () => {
+    sinon.spy(element, '_handleTargetClick');
+    assert.include(element.displayedColumns, 'Assignee');
+    MockInteractions
+        .tap(element.shadowRoot
+            .querySelector('.checkboxContainer input[name=Assignee]'));
+    assert.isTrue(element._handleTargetClick.calledOnce);
+    assert.notInclude(element.displayedColumns, 'Assignee');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
index 957eb48..023eee8 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
@@ -16,26 +16,22 @@
  */
 
 import '@polymer/iron-input/iron-input.js';
-import '../../../scripts/bundled-polymer.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/shared-styles.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-cla-view_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrClaView extends mixinBehaviors( [
-  BaseUrlBehavior,
-], GestureEventListeners(
+class GrClaView extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-cla-view'; }
@@ -92,7 +88,7 @@
     if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
       url = configUrl;
     } else {
-      url = this.getBaseUrl() + '/' + configUrl;
+      url = getBaseUrl() + '/' + configUrl;
     }
 
     return url;
@@ -112,7 +108,7 @@
     return this.$.restAPI.saveAccountAgreement({name}).then(res => {
       let message = 'Agreement failed to be submitted, please try again';
       if (res.status === 200) {
-        message = 'Agreement has been successfully submited.';
+        message = 'Agreement has been successfully submitted.';
       }
       this._createToast(message);
       this.loadData();
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js
deleted file mode 100644
index 2d371e2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.js
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    h1 {
-      margin-bottom: var(--spacing-m);
-    }
-    h3 {
-      margin-bottom: var(--spacing-m);
-    }
-    .agreementsUrl {
-      border: 1px solid #b0bdcc;
-      margin-bottom: var(--spacing-xl);
-      margin-left: var(--spacing-xl);
-      margin-right: var(--spacing-xl);
-      padding: var(--spacing-s);
-    }
-    #claNewAgreementsLabel {
-      font-weight: var(--font-weight-bold);
-    }
-    #claNewAgreement {
-      display: none;
-    }
-    #claNewAgreement.show {
-      display: block;
-    }
-    .contributorAgreementButton {
-      font-weight: var(--font-weight-bold);
-    }
-    .alreadySubmittedText {
-      color: var(--error-text-color);
-      margin: 0 var(--spacing-xxl);
-      padding: var(--spacing-m);
-    }
-    .alreadySubmittedText.hide,
-    .hideAgreementsTextBox {
-      display: none;
-    }
-    main {
-      margin: var(--spacing-xxl) auto;
-      max-width: 50em;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <main>
-    <h1>New Contributor Agreement</h1>
-    <h3>Select an agreement type:</h3>
-    <template
-      is="dom-repeat"
-      items="[[_serverConfig.auth.contributor_agreements]]"
-    >
-      <span class="contributorAgreementButton">
-        <input
-          id$="claNewAgreementsInput[[item.name]]"
-          name="claNewAgreementsRadio"
-          type="radio"
-          data-name$="[[item.name]]"
-          data-url$="[[item.url]]"
-          on-click="_handleShowAgreement"
-          disabled$="[[_disableAgreements(item, _groups, _signedAgreements)]]"
-        />
-        <label id="claNewAgreementsLabel">[[item.name]]</label>
-      </span>
-      <div
-        class$="alreadySubmittedText [[_hideAgreements(item, _groups, _signedAgreements)]]"
-      >
-        Agreement already submitted.
-      </div>
-      <div class="agreementsUrl">
-        [[item.description]]
-      </div>
-    </template>
-    <div
-      id="claNewAgreement"
-      class$="[[_computeShowAgreementsClass(_showAgreements)]]"
-    >
-      <h3 class="smallHeading">Review the agreement:</h3>
-      <div id="agreementsUrl" class="agreementsUrl">
-        <a href$="[[_agreementsUrl]]" target="blank" rel="noopener">
-          Please review the agreement.</a
-        >
-      </div>
-      <div
-        class$="agreementsTextBox [[_computeHideAgreementClass(_agreementName, _serverConfig.auth.contributor_agreements)]]"
-      >
-        <h3 class="smallHeading">Complete the agreement:</h3>
-        <iron-input
-          bind-value="{{_agreementsText}}"
-          placeholder="Enter 'I agree' here"
-        >
-          <input
-            id="input-agreements"
-            is="iron-input"
-            bind-value="{{_agreementsText}}"
-            placeholder="Enter 'I agree' here"
-          />
-        </iron-input>
-        <gr-button
-          on-click="_handleSaveAgreements"
-          disabled="[[_disableAgreementsText(_agreementsText)]]"
-        >
-          Submit
-        </gr-button>
-      </div>
-    </div>
-  </main>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
new file mode 100644
index 0000000..c461718
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
@@ -0,0 +1,126 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    h1 {
+      margin-bottom: var(--spacing-m);
+    }
+    h3 {
+      margin-bottom: var(--spacing-m);
+    }
+    .agreementsUrl {
+      border: 1px solid #b0bdcc;
+      margin-bottom: var(--spacing-xl);
+      margin-left: var(--spacing-xl);
+      margin-right: var(--spacing-xl);
+      padding: var(--spacing-s);
+    }
+    #claNewAgreementsLabel {
+      font-weight: var(--font-weight-bold);
+    }
+    #claNewAgreement {
+      display: none;
+    }
+    #claNewAgreement.show {
+      display: block;
+    }
+    .contributorAgreementButton {
+      font-weight: var(--font-weight-bold);
+    }
+    .alreadySubmittedText {
+      color: var(--error-text-color);
+      margin: 0 var(--spacing-xxl);
+      padding: var(--spacing-m);
+    }
+    .alreadySubmittedText.hide,
+    .hideAgreementsTextBox {
+      display: none;
+    }
+    main {
+      margin: var(--spacing-xxl) auto;
+      max-width: 50em;
+    }
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <main>
+    <h1 class="heading-1">New Contributor Agreement</h1>
+    <h3 class="heading-3">Select an agreement type:</h3>
+    <template
+      is="dom-repeat"
+      items="[[_serverConfig.auth.contributor_agreements]]"
+    >
+      <span class="contributorAgreementButton">
+        <input
+          id$="claNewAgreementsInput[[item.name]]"
+          name="claNewAgreementsRadio"
+          type="radio"
+          data-name$="[[item.name]]"
+          data-url$="[[item.url]]"
+          on-click="_handleShowAgreement"
+          disabled$="[[_disableAgreements(item, _groups, _signedAgreements)]]"
+        />
+        <label id="claNewAgreementsLabel">[[item.name]]</label>
+      </span>
+      <div
+        class$="alreadySubmittedText [[_hideAgreements(item, _groups, _signedAgreements)]]"
+      >
+        Agreement already submitted.
+      </div>
+      <div class="agreementsUrl">
+        [[item.description]]
+      </div>
+    </template>
+    <div
+      id="claNewAgreement"
+      class$="[[_computeShowAgreementsClass(_showAgreements)]]"
+    >
+      <h3 class="heading-3">Review the agreement:</h3>
+      <div id="agreementsUrl" class="agreementsUrl">
+        <a href$="[[_agreementsUrl]]" target="blank" rel="noopener">
+          Please review the agreement.</a
+        >
+      </div>
+      <div
+        class$="agreementsTextBox [[_computeHideAgreementClass(_agreementName, _serverConfig.auth.contributor_agreements)]]"
+      >
+        <h3 class="heading-3">Complete the agreement:</h3>
+        <iron-input
+          bind-value="{{_agreementsText}}"
+          placeholder="Enter 'I agree' here"
+        >
+          <input
+            id="input-agreements"
+            is="iron-input"
+            bind-value="{{_agreementsText}}"
+            placeholder="Enter 'I agree' here"
+          />
+        </iron-input>
+        <gr-button
+          on-click="_handleSaveAgreements"
+          disabled="[[_disableAgreementsText(_agreementsText)]]"
+        >
+          Submit
+        </gr-button>
+      </div>
+    </div>
+  </main>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
deleted file mode 100644
index bc3c10c..0000000
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.html
+++ /dev/null
@@ -1,194 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-cla-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-cla-view></gr-cla-view>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-cla-view.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-cla-view tests', () => {
-  let element;
-  const signedAgreements = [{
-    name: 'CLA',
-    description: 'Contributor License Agreement',
-    url: 'static/cla.html',
-  }];
-  const auth = {
-    name: 'Individual',
-    description: 'test-description',
-    url: 'static/cla_individual.html',
-    auto_verify_group: {
-      url: '#/admin/groups/uuid-e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-      options: {
-        visible_to_all: true,
-      },
-      group_id: 20,
-      owner: 'CLA Accepted - Individual',
-      owner_id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-      created_on: '2017-07-31 15:11:04.000000000',
-      id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-      name: 'CLA Accepted - Individual',
-    },
-  };
-
-  const auth2 = {
-    name: 'Individual2',
-    description: 'test-description2',
-    url: 'static/cla_individual2.html',
-    auto_verify_group: {
-      url: '#/admin/groups/uuid-bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-      options: {},
-      group_id: 21,
-      owner: 'CLA Accepted - Individual2',
-      owner_id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-      created_on: '2017-07-31 15:25:42.000000000',
-      id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
-      name: 'CLA Accepted - Individual2',
-    },
-  };
-
-  const auth3 = {
-    name: 'CLA',
-    description: 'Contributor License Agreement',
-    url: 'static/cla_individual.html',
-  };
-
-  const config = {
-    auth: {
-      use_contributor_agreements: true,
-      contributor_agreements: [
-        {
-          name: 'Individual',
-          description: 'test-description',
-          url: 'static/cla_individual.html',
-        },
-        {
-          name: 'CLA',
-          description: 'Contributor License Agreement',
-          url: 'static/cla.html',
-        }],
-    },
-  };
-  const config2 = {
-    auth: {
-      use_contributor_agreements: true,
-      contributor_agreements: [
-        {
-          name: 'Individual2',
-          description: 'test-description2',
-          url: 'static/cla_individual2.html',
-        },
-      ],
-    },
-  };
-  const groups = [{
-    options: {visible_to_all: true},
-    id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
-    group_id: 3,
-    name: 'CLA Accepted - Individual',
-  },
-  ];
-
-  setup(done => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve(config); },
-      getAccountGroups() { return Promise.resolve(groups); },
-      getAccountAgreements() { return Promise.resolve(signedAgreements); },
-    });
-    element = fixture('basic');
-    element.loadData().then(() => { flush(done); });
-  });
-
-  test('renders as expected with signed agreement', () => {
-    const agreementSections = dom(element.root)
-        .querySelectorAll('.contributorAgreementButton');
-    const agreementSubmittedTexts = dom(element.root)
-        .querySelectorAll('.alreadySubmittedText');
-    assert.equal(agreementSections.length, 2);
-    assert.isFalse(agreementSections[0].querySelector('input').disabled);
-    assert.equal(getComputedStyle(agreementSubmittedTexts[0]).display,
-        'none');
-    assert.isTrue(agreementSections[1].querySelector('input').disabled);
-    assert.notEqual(getComputedStyle(agreementSubmittedTexts[1]).display,
-        'none');
-  });
-
-  test('_disableAgreements', () => {
-    // In the auto verify group and have not yet signed agreement
-    assert.isTrue(
-        element._disableAgreements(auth, groups, signedAgreements));
-    // Not in the auto verify group and have not yet signed agreement
-    assert.isFalse(
-        element._disableAgreements(auth2, groups, signedAgreements));
-    // Not in the auto verify group, have signed agreement
-    assert.isTrue(
-        element._disableAgreements(auth3, groups, signedAgreements));
-    // Make sure the undefined check works
-    assert.isFalse(
-        element._disableAgreements(auth, undefined, signedAgreements));
-  });
-
-  test('_hideAgreements', () => {
-    // Not in the auto verify group and have not yet signed agreement
-    assert.equal(
-        element._hideAgreements(auth, groups, signedAgreements), '');
-    // In the auto verify group
-    assert.equal(
-        element._hideAgreements(auth2, groups, signedAgreements), 'hide');
-    // Not in the auto verify group, have signed agreement
-    assert.equal(
-        element._hideAgreements(auth3, groups, signedAgreements), '');
-  });
-
-  test('_disableAgreementsText', () => {
-    assert.isFalse(element._disableAgreementsText('I AGREE'));
-    assert.isTrue(element._disableAgreementsText('I DO NOT AGREE'));
-  });
-
-  test('_computeHideAgreementClass', () => {
-    assert.equal(
-        element._computeHideAgreementClass(
-            auth.name, config.auth.contributor_agreements),
-        'hideAgreementsTextBox');
-    assert.isUndefined(
-        element._computeHideAgreementClass(
-            auth.name, config2.auth.contributor_agreements));
-  });
-
-  test('_getAgreementsUrl', () => {
-    assert.equal(element._getAgreementsUrl(
-        'http://test.org/test.html'), 'http://test.org/test.html');
-    assert.equal(element._getAgreementsUrl(
-        'test_cla.html'), '/test_cla.html');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
new file mode 100644
index 0000000..6f89c49
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
@@ -0,0 +1,180 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-cla-view.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-cla-view');
+
+suite('gr-cla-view tests', () => {
+  let element;
+  const signedAgreements = [{
+    name: 'CLA',
+    description: 'Contributor License Agreement',
+    url: 'static/cla.html',
+  }];
+  const auth = {
+    name: 'Individual',
+    description: 'test-description',
+    url: 'static/cla_individual.html',
+    auto_verify_group: {
+      url: '#/admin/groups/uuid-e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+      options: {
+        visible_to_all: true,
+      },
+      group_id: 20,
+      owner: 'CLA Accepted - Individual',
+      owner_id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+      created_on: '2017-07-31 15:11:04.000000000',
+      id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+      name: 'CLA Accepted - Individual',
+    },
+  };
+
+  const auth2 = {
+    name: 'Individual2',
+    description: 'test-description2',
+    url: 'static/cla_individual2.html',
+    auto_verify_group: {
+      url: '#/admin/groups/uuid-bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+      options: {},
+      group_id: 21,
+      owner: 'CLA Accepted - Individual2',
+      owner_id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+      created_on: '2017-07-31 15:25:42.000000000',
+      id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
+      name: 'CLA Accepted - Individual2',
+    },
+  };
+
+  const auth3 = {
+    name: 'CLA',
+    description: 'Contributor License Agreement',
+    url: 'static/cla_individual.html',
+  };
+
+  const config = {
+    auth: {
+      use_contributor_agreements: true,
+      contributor_agreements: [
+        {
+          name: 'Individual',
+          description: 'test-description',
+          url: 'static/cla_individual.html',
+        },
+        {
+          name: 'CLA',
+          description: 'Contributor License Agreement',
+          url: 'static/cla.html',
+        }],
+    },
+  };
+  const config2 = {
+    auth: {
+      use_contributor_agreements: true,
+      contributor_agreements: [
+        {
+          name: 'Individual2',
+          description: 'test-description2',
+          url: 'static/cla_individual2.html',
+        },
+      ],
+    },
+  };
+  const groups = [{
+    options: {visible_to_all: true},
+    id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
+    group_id: 3,
+    name: 'CLA Accepted - Individual',
+  },
+  ];
+
+  setup(done => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve(config); },
+      getAccountGroups() { return Promise.resolve(groups); },
+      getAccountAgreements() { return Promise.resolve(signedAgreements); },
+    });
+    element = basicFixture.instantiate();
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('renders as expected with signed agreement', () => {
+    const agreementSections = dom(element.root)
+        .querySelectorAll('.contributorAgreementButton');
+    const agreementSubmittedTexts = dom(element.root)
+        .querySelectorAll('.alreadySubmittedText');
+    assert.equal(agreementSections.length, 2);
+    assert.isFalse(agreementSections[0].querySelector('input').disabled);
+    assert.equal(getComputedStyle(agreementSubmittedTexts[0]).display,
+        'none');
+    assert.isTrue(agreementSections[1].querySelector('input').disabled);
+    assert.notEqual(getComputedStyle(agreementSubmittedTexts[1]).display,
+        'none');
+  });
+
+  test('_disableAgreements', () => {
+    // In the auto verify group and have not yet signed agreement
+    assert.isTrue(
+        element._disableAgreements(auth, groups, signedAgreements));
+    // Not in the auto verify group and have not yet signed agreement
+    assert.isFalse(
+        element._disableAgreements(auth2, groups, signedAgreements));
+    // Not in the auto verify group, have signed agreement
+    assert.isTrue(
+        element._disableAgreements(auth3, groups, signedAgreements));
+    // Make sure the undefined check works
+    assert.isFalse(
+        element._disableAgreements(auth, undefined, signedAgreements));
+  });
+
+  test('_hideAgreements', () => {
+    // Not in the auto verify group and have not yet signed agreement
+    assert.equal(
+        element._hideAgreements(auth, groups, signedAgreements), '');
+    // In the auto verify group
+    assert.equal(
+        element._hideAgreements(auth2, groups, signedAgreements), 'hide');
+    // Not in the auto verify group, have signed agreement
+    assert.equal(
+        element._hideAgreements(auth3, groups, signedAgreements), '');
+  });
+
+  test('_disableAgreementsText', () => {
+    assert.isFalse(element._disableAgreementsText('I AGREE'));
+    assert.isTrue(element._disableAgreementsText('I DO NOT AGREE'));
+  });
+
+  test('_computeHideAgreementClass', () => {
+    assert.equal(
+        element._computeHideAgreementClass(
+            auth.name, config.auth.contributor_agreements),
+        'hideAgreementsTextBox');
+    assert.isUndefined(
+        element._computeHideAgreementClass(
+            auth.name, config2.auth.contributor_agreements));
+  });
+
+  test('_getAgreementsUrl', () => {
+    assert.equal(element._getAgreementsUrl(
+        'http://test.org/test.html'), 'http://test.org/test.html');
+    assert.equal(element._getAgreementsUrl(
+        'test_cla.html'), '/test_cla.html');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
index 2a7ac06..6973292 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/shared-styles.js';
@@ -26,7 +24,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-edit-preferences_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrEditPreferences extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.js
deleted file mode 100644
index f2c476a..0000000
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.js
+++ /dev/null
@@ -1,164 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div id="editPreferences" class="gr-form-styles">
-    <section>
-      <span class="title">Tab width</span>
-      <span class="value">
-        <iron-input
-          type="number"
-          prevent-invalid-input=""
-          allowed-pattern="[0-9]"
-          bind-value="{{editPrefs.tab_size}}"
-          on-keypress="_handleEditPrefsChanged"
-          on-change="_handleEditPrefsChanged"
-        >
-          <input
-            is="iron-input"
-            type="number"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{editPrefs.tab_size}}"
-            on-keypress="_handleEditPrefsChanged"
-            on-change="_handleEditPrefsChanged"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <span class="title">Columns</span>
-      <span class="value">
-        <iron-input
-          type="number"
-          prevent-invalid-input=""
-          allowed-pattern="[0-9]"
-          bind-value="{{editPrefs.line_length}}"
-          on-keypress="_handleEditPrefsChanged"
-          on-change="_handleEditPrefsChanged"
-        >
-          <input
-            is="iron-input"
-            type="number"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{editPrefs.line_length}}"
-            on-keypress="_handleEditPrefsChanged"
-            on-change="_handleEditPrefsChanged"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <span class="title">Indent unit</span>
-      <span class="value">
-        <iron-input
-          type="number"
-          prevent-invalid-input=""
-          allowed-pattern="[0-9]"
-          bind-value="{{editPrefs.indent_unit}}"
-          on-keypress="_handleEditPrefsChanged"
-          on-change="_handleEditPrefsChanged"
-        >
-          <input
-            is="iron-input"
-            type="number"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{editPrefs.indent_unit}}"
-            on-keypress="_handleEditPrefsChanged"
-            on-change="_handleEditPrefsChanged"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <span class="title">Syntax highlighting</span>
-      <span class="value">
-        <input
-          id="editSyntaxHighlighting"
-          type="checkbox"
-          checked$="[[editPrefs.syntax_highlighting]]"
-          on-change="_handleEditSyntaxHighlightingChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <span class="title">Show tabs</span>
-      <span class="value">
-        <input
-          id="editShowTabs"
-          type="checkbox"
-          checked$="[[editPrefs.show_tabs]]"
-          on-change="_handleEditShowTabsChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <span class="title">Match brackets</span>
-      <span class="value">
-        <input
-          id="showMatchBrackets"
-          type="checkbox"
-          checked$="[[editPrefs.match_brackets]]"
-          on-change="_handleMatchBracketsChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <span class="title">Line wrapping</span>
-      <span class="value">
-        <input
-          id="editShowLineWrapping"
-          type="checkbox"
-          checked$="[[editPrefs.line_wrapping]]"
-          on-change="_handleEditLineWrappingChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <span class="title">Indent with tabs</span>
-      <span class="value">
-        <input
-          id="showIndentWithTabs"
-          type="checkbox"
-          checked$="[[editPrefs.indent_with_tabs]]"
-          on-change="_handleIndentWithTabsChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <span class="title">Auto close brackets</span>
-      <span class="value">
-        <input
-          id="showAutoCloseBrackets"
-          type="checkbox"
-          checked$="[[editPrefs.auto_close_brackets]]"
-          on-change="_handleAutoCloseBracketsChanged"
-        />
-      </span>
-    </section>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
new file mode 100644
index 0000000..47e4592
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
@@ -0,0 +1,164 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <div id="editPreferences" class="gr-form-styles">
+    <section>
+      <span class="title">Tab width</span>
+      <span class="value">
+        <iron-input
+          type="number"
+          prevent-invalid-input=""
+          allowed-pattern="[0-9]"
+          bind-value="{{editPrefs.tab_size}}"
+          on-keypress="_handleEditPrefsChanged"
+          on-change="_handleEditPrefsChanged"
+        >
+          <input
+            is="iron-input"
+            type="number"
+            prevent-invalid-input=""
+            allowed-pattern="[0-9]"
+            bind-value="{{editPrefs.tab_size}}"
+            on-keypress="_handleEditPrefsChanged"
+            on-change="_handleEditPrefsChanged"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Columns</span>
+      <span class="value">
+        <iron-input
+          type="number"
+          prevent-invalid-input=""
+          allowed-pattern="[0-9]"
+          bind-value="{{editPrefs.line_length}}"
+          on-keypress="_handleEditPrefsChanged"
+          on-change="_handleEditPrefsChanged"
+        >
+          <input
+            is="iron-input"
+            type="number"
+            prevent-invalid-input=""
+            allowed-pattern="[0-9]"
+            bind-value="{{editPrefs.line_length}}"
+            on-keypress="_handleEditPrefsChanged"
+            on-change="_handleEditPrefsChanged"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Indent unit</span>
+      <span class="value">
+        <iron-input
+          type="number"
+          prevent-invalid-input=""
+          allowed-pattern="[0-9]"
+          bind-value="{{editPrefs.indent_unit}}"
+          on-keypress="_handleEditPrefsChanged"
+          on-change="_handleEditPrefsChanged"
+        >
+          <input
+            is="iron-input"
+            type="number"
+            prevent-invalid-input=""
+            allowed-pattern="[0-9]"
+            bind-value="{{editPrefs.indent_unit}}"
+            on-keypress="_handleEditPrefsChanged"
+            on-change="_handleEditPrefsChanged"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Syntax highlighting</span>
+      <span class="value">
+        <input
+          id="editSyntaxHighlighting"
+          type="checkbox"
+          checked$="[[editPrefs.syntax_highlighting]]"
+          on-change="_handleEditSyntaxHighlightingChanged"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Show tabs</span>
+      <span class="value">
+        <input
+          id="editShowTabs"
+          type="checkbox"
+          checked$="[[editPrefs.show_tabs]]"
+          on-change="_handleEditShowTabsChanged"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Match brackets</span>
+      <span class="value">
+        <input
+          id="showMatchBrackets"
+          type="checkbox"
+          checked$="[[editPrefs.match_brackets]]"
+          on-change="_handleMatchBracketsChanged"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Line wrapping</span>
+      <span class="value">
+        <input
+          id="editShowLineWrapping"
+          type="checkbox"
+          checked$="[[editPrefs.line_wrapping]]"
+          on-change="_handleEditLineWrappingChanged"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Indent with tabs</span>
+      <span class="value">
+        <input
+          id="showIndentWithTabs"
+          type="checkbox"
+          checked$="[[editPrefs.indent_with_tabs]]"
+          on-change="_handleIndentWithTabsChanged"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Auto close brackets</span>
+      <span class="value">
+        <input
+          id="showAutoCloseBrackets"
+          type="checkbox"
+          checked$="[[editPrefs.auto_close_brackets]]"
+          on-change="_handleAutoCloseBracketsChanged"
+        />
+      </span>
+    </section>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
deleted file mode 100644
index 3cc7bfe..0000000
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
+++ /dev/null
@@ -1,126 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-edit-preferences</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-edit-preferences></gr-edit-preferences>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-edit-preferences.js';
-suite('gr-edit-preferences tests', () => {
-  let element;
-  let sandbox;
-  let editPreferences;
-
-  function valueOf(title, fieldsetid) {
-    const sections = element.$[fieldsetid].querySelectorAll('section');
-    let titleEl;
-    for (let i = 0; i < sections.length; i++) {
-      titleEl = sections[i].querySelector('.title');
-      if (titleEl.textContent.trim() === title) {
-        return sections[i].querySelector('.value');
-      }
-    }
-  }
-
-  setup(() => {
-    editPreferences = {
-      auto_close_brackets: false,
-      cursor_blink_rate: 0,
-      hide_line_numbers: false,
-      hide_top_menu: false,
-      indent_unit: 2,
-      indent_with_tabs: false,
-      key_map_type: 'DEFAULT',
-      line_length: 100,
-      line_wrapping: false,
-      match_brackets: true,
-      show_base: false,
-      show_tabs: true,
-      show_whitespace_errors: true,
-      syntax_highlighting: true,
-      tab_size: 8,
-      theme: 'DEFAULT',
-    };
-
-    stub('gr-rest-api-interface', {
-      getEditPreferences() {
-        return Promise.resolve(editPreferences);
-      },
-    });
-
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    return element.loadData();
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('renders', () => {
-    // Rendered with the expected preferences selected.
-    assert.equal(valueOf('Tab width', 'editPreferences')
-        .firstElementChild.bindValue, editPreferences.tab_size);
-    assert.equal(valueOf('Columns', 'editPreferences')
-        .firstElementChild.bindValue, editPreferences.line_length);
-    assert.equal(valueOf('Indent unit', 'editPreferences')
-        .firstElementChild.bindValue, editPreferences.indent_unit);
-    assert.equal(valueOf('Syntax highlighting', 'editPreferences')
-        .firstElementChild.checked, editPreferences.syntax_highlighting);
-    assert.equal(valueOf('Show tabs', 'editPreferences')
-        .firstElementChild.checked, editPreferences.show_tabs);
-    assert.equal(valueOf('Match brackets', 'editPreferences')
-        .firstElementChild.checked, editPreferences.match_brackets);
-    assert.equal(valueOf('Line wrapping', 'editPreferences')
-        .firstElementChild.checked, editPreferences.line_wrapping);
-    assert.equal(valueOf('Indent with tabs', 'editPreferences')
-        .firstElementChild.checked, editPreferences.indent_with_tabs);
-    assert.equal(valueOf('Auto close brackets', 'editPreferences')
-        .firstElementChild.checked, editPreferences.auto_close_brackets);
-
-    assert.isFalse(element.hasUnsavedChanges);
-  });
-
-  test('save changes', () => {
-    sandbox.stub(element.$.restAPI, 'saveEditPreferences')
-        .returns(Promise.resolve());
-    const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
-        .firstElementChild;
-    showTabsCheckbox.checked = false;
-    element._handleEditShowTabsChanged();
-
-    assert.isTrue(element.hasUnsavedChanges);
-
-    // Save the change.
-    return element.save().then(() => {
-      assert.isFalse(element.hasUnsavedChanges);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js
new file mode 100644
index 0000000..b1b8b61
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.js
@@ -0,0 +1,110 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-edit-preferences.js';
+
+const basicFixture = fixtureFromElement('gr-edit-preferences');
+
+suite('gr-edit-preferences tests', () => {
+  let element;
+
+  let editPreferences;
+
+  function valueOf(title, fieldsetid) {
+    const sections = element.$[fieldsetid].querySelectorAll('section');
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl.textContent.trim() === title) {
+        return sections[i].querySelector('.value');
+      }
+    }
+  }
+
+  setup(() => {
+    editPreferences = {
+      auto_close_brackets: false,
+      cursor_blink_rate: 0,
+      hide_line_numbers: false,
+      hide_top_menu: false,
+      indent_unit: 2,
+      indent_with_tabs: false,
+      key_map_type: 'DEFAULT',
+      line_length: 100,
+      line_wrapping: false,
+      match_brackets: true,
+      show_base: false,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      tab_size: 8,
+      theme: 'DEFAULT',
+    };
+
+    stub('gr-rest-api-interface', {
+      getEditPreferences() {
+        return Promise.resolve(editPreferences);
+      },
+    });
+
+    element = basicFixture.instantiate();
+
+    return element.loadData();
+  });
+
+  test('renders', () => {
+    // Rendered with the expected preferences selected.
+    assert.equal(valueOf('Tab width', 'editPreferences')
+        .firstElementChild.bindValue, editPreferences.tab_size);
+    assert.equal(valueOf('Columns', 'editPreferences')
+        .firstElementChild.bindValue, editPreferences.line_length);
+    assert.equal(valueOf('Indent unit', 'editPreferences')
+        .firstElementChild.bindValue, editPreferences.indent_unit);
+    assert.equal(valueOf('Syntax highlighting', 'editPreferences')
+        .firstElementChild.checked, editPreferences.syntax_highlighting);
+    assert.equal(valueOf('Show tabs', 'editPreferences')
+        .firstElementChild.checked, editPreferences.show_tabs);
+    assert.equal(valueOf('Match brackets', 'editPreferences')
+        .firstElementChild.checked, editPreferences.match_brackets);
+    assert.equal(valueOf('Line wrapping', 'editPreferences')
+        .firstElementChild.checked, editPreferences.line_wrapping);
+    assert.equal(valueOf('Indent with tabs', 'editPreferences')
+        .firstElementChild.checked, editPreferences.indent_with_tabs);
+    assert.equal(valueOf('Auto close brackets', 'editPreferences')
+        .firstElementChild.checked, editPreferences.auto_close_brackets);
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('save changes', () => {
+    sinon.stub(element.$.restAPI, 'saveEditPreferences')
+        .returns(Promise.resolve());
+    const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
+        .firstElementChild;
+    showTabsCheckbox.checked = false;
+    element._handleEditShowTabsChanged();
+
+    assert.isTrue(element.hasUnsavedChanges);
+
+    // Save the change.
+    return element.save().then(() => {
+      assert.isFalse(element.hasUnsavedChanges);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
index fc97079..0cc5c2c 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
@@ -26,7 +24,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-email-editor_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrEmailEditor extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.js
deleted file mode 100644
index 977e95d..0000000
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    th {
-      color: var(--deemphasized-text-color);
-      text-align: left;
-    }
-    #emailTable .emailColumn {
-      min-width: 32.5em;
-      width: auto;
-    }
-    #emailTable .preferredHeader {
-      text-align: center;
-      width: 6em;
-    }
-    #emailTable .preferredControl {
-      cursor: pointer;
-      height: auto;
-      text-align: center;
-    }
-    #emailTable .preferredControl .preferredRadio {
-      height: auto;
-    }
-    .preferredControl:hover {
-      outline: 1px solid var(--border-color);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="emailTable">
-      <thead>
-        <tr>
-          <th class="emailColumn">Email</th>
-          <th class="preferredHeader">Preferred</th>
-          <th></th>
-        </tr>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[_emails]]">
-          <tr>
-            <td class="emailColumn">[[item.email]]</td>
-            <td
-              class="preferredControl"
-              on-click="_handlePreferredControlClick"
-            >
-              <iron-input
-                class="preferredRadio"
-                type="radio"
-                on-change="_handlePreferredChange"
-                name="preferred"
-                bind-value="[[item.email]]"
-                checked$="[[item.preferred]]"
-              >
-                <input
-                  is="iron-input"
-                  class="preferredRadio"
-                  type="radio"
-                  on-change="_handlePreferredChange"
-                  name="preferred"
-                  value="[[item.email]]"
-                  checked$="[[item.preferred]]"
-                />
-              </iron-input>
-            </td>
-            <td>
-              <gr-button
-                data-index$="[[index]]"
-                on-click="_handleDeleteButton"
-                disabled="[[item.preferred]]"
-                class="remove-button"
-                >Delete</gr-button
-              >
-            </td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
new file mode 100644
index 0000000..525fca6
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    th {
+      color: var(--deemphasized-text-color);
+      text-align: left;
+    }
+    #emailTable .emailColumn {
+      min-width: 32.5em;
+      width: auto;
+    }
+    #emailTable .preferredHeader {
+      text-align: center;
+      width: 6em;
+    }
+    #emailTable .preferredControl {
+      cursor: pointer;
+      height: auto;
+      text-align: center;
+    }
+    #emailTable .preferredControl .preferredRadio {
+      height: auto;
+    }
+    .preferredControl:hover {
+      outline: 1px solid var(--border-color);
+    }
+  </style>
+  <div class="gr-form-styles">
+    <table id="emailTable">
+      <thead>
+        <tr>
+          <th class="emailColumn">Email</th>
+          <th class="preferredHeader">Preferred</th>
+          <th></th>
+        </tr>
+      </thead>
+      <tbody>
+        <template is="dom-repeat" items="[[_emails]]">
+          <tr>
+            <td class="emailColumn">[[item.email]]</td>
+            <td
+              class="preferredControl"
+              on-click="_handlePreferredControlClick"
+            >
+              <iron-input
+                class="preferredRadio"
+                type="radio"
+                on-change="_handlePreferredChange"
+                name="preferred"
+                bind-value="[[item.email]]"
+                checked$="[[item.preferred]]"
+              >
+                <input
+                  is="iron-input"
+                  class="preferredRadio"
+                  type="radio"
+                  on-change="_handlePreferredChange"
+                  name="preferred"
+                  value="[[item.email]]"
+                  checked$="[[item.preferred]]"
+                />
+              </iron-input>
+            </td>
+            <td>
+              <gr-button
+                data-index$="[[index]]"
+                on-click="_handleDeleteButton"
+                disabled="[[item.preferred]]"
+                class="remove-button"
+                >Delete</gr-button
+              >
+            </td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
deleted file mode 100644
index ad2553d..0000000
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
+++ /dev/null
@@ -1,152 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-email-editor</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-email-editor></gr-email-editor>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-email-editor.js';
-suite('gr-email-editor tests', () => {
-  let element;
-
-  setup(done => {
-    const emails = [
-      {email: 'email@one.com'},
-      {email: 'email@two.com', preferred: true},
-      {email: 'email@three.com'},
-    ];
-
-    stub('gr-rest-api-interface', {
-      getAccountEmails() { return Promise.resolve(emails); },
-    });
-
-    element = fixture('basic');
-
-    element.loadData().then(flush(done));
-  });
-
-  test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('table').querySelectorAll('tbody tr');
-
-    assert.equal(rows.length, 3);
-
-    assert.isFalse(rows[0].querySelector('input[type=radio]').checked);
-    assert.isNotOk(rows[0].querySelector('gr-button').disabled);
-
-    assert.isTrue(rows[1].querySelector('input[type=radio]').checked);
-    assert.isOk(rows[1].querySelector('gr-button').disabled);
-
-    assert.isFalse(rows[2].querySelector('input[type=radio]').checked);
-    assert.isNotOk(rows[2].querySelector('gr-button').disabled);
-
-    assert.isFalse(element.hasUnsavedChanges);
-  });
-
-  test('edit preferred', () => {
-    const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
-    const radios = element.shadowRoot
-        .querySelector('table').querySelectorAll('input[type=radio]');
-
-    assert.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
-    assert.isNotOk(radios[0].checked);
-    assert.isOk(radios[1].checked);
-    assert.isFalse(preferredChangedSpy.called);
-
-    radios[0].click();
-
-    assert.isTrue(element.hasUnsavedChanges);
-    assert.isOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
-    assert.isOk(radios[0].checked);
-    assert.isNotOk(radios[1].checked);
-    assert.isTrue(preferredChangedSpy.called);
-  });
-
-  test('delete email', () => {
-    const buttons = element.shadowRoot
-        .querySelector('table').querySelectorAll('gr-button');
-
-    assert.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
-
-    buttons[2].click();
-
-    assert.isTrue(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 1);
-    assert.equal(element._emails.length, 2);
-
-    assert.equal(element._emailsToRemove[0].email, 'email@three.com');
-  });
-
-  test('save changes', done => {
-    const deleteEmailStub =
-        sinon.stub(element.$.restAPI, 'deleteAccountEmail');
-    const setPreferredStub = sinon.stub(element.$.restAPI,
-        'setPreferredAccountEmail');
-    const rows = element.shadowRoot
-        .querySelector('table').querySelectorAll('tbody tr');
-
-    assert.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
-
-    // Delete the first email and set the last as preferred.
-    rows[0].querySelector('gr-button').click();
-    rows[2].querySelector('input[type=radio]').click();
-
-    assert.isTrue(element.hasUnsavedChanges);
-    assert.equal(element._newPreferred, 'email@three.com');
-    assert.equal(element._emailsToRemove.length, 1);
-    assert.equal(element._emailsToRemove[0].email, 'email@one.com');
-    assert.equal(element._emails.length, 2);
-
-    // Save the changes.
-    element.save().then(() => {
-      assert.equal(deleteEmailStub.callCount, 1);
-      assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
-
-      assert.isTrue(setPreferredStub.called);
-      assert.equal(setPreferredStub.getCall(0).args[0], 'email@three.com');
-
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.js
new file mode 100644
index 0000000..805b8c8
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.js
@@ -0,0 +1,138 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-email-editor.js';
+
+const basicFixture = fixtureFromElement('gr-email-editor');
+
+suite('gr-email-editor tests', () => {
+  let element;
+
+  setup(done => {
+    const emails = [
+      {email: 'email@one.com'},
+      {email: 'email@two.com', preferred: true},
+      {email: 'email@three.com'},
+    ];
+
+    stub('gr-rest-api-interface', {
+      getAccountEmails() { return Promise.resolve(emails); },
+    });
+
+    element = basicFixture.instantiate();
+
+    element.loadData().then(flush(done));
+  });
+
+  test('renders', () => {
+    const rows = element.shadowRoot
+        .querySelector('table').querySelectorAll('tbody tr');
+
+    assert.equal(rows.length, 3);
+
+    assert.isFalse(rows[0].querySelector('input[type=radio]').checked);
+    assert.isNotOk(rows[0].querySelector('gr-button').disabled);
+
+    assert.isTrue(rows[1].querySelector('input[type=radio]').checked);
+    assert.isOk(rows[1].querySelector('gr-button').disabled);
+
+    assert.isFalse(rows[2].querySelector('input[type=radio]').checked);
+    assert.isNotOk(rows[2].querySelector('gr-button').disabled);
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('edit preferred', () => {
+    const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
+    const radios = element.shadowRoot
+        .querySelector('table').querySelectorAll('input[type=radio]');
+
+    assert.isFalse(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
+    assert.isNotOk(radios[0].checked);
+    assert.isOk(radios[1].checked);
+    assert.isFalse(preferredChangedSpy.called);
+
+    radios[0].click();
+
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
+    assert.isOk(radios[0].checked);
+    assert.isNotOk(radios[1].checked);
+    assert.isTrue(preferredChangedSpy.called);
+  });
+
+  test('delete email', () => {
+    const buttons = element.shadowRoot
+        .querySelector('table').querySelectorAll('gr-button');
+
+    assert.isFalse(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
+
+    buttons[2].click();
+
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 1);
+    assert.equal(element._emails.length, 2);
+
+    assert.equal(element._emailsToRemove[0].email, 'email@three.com');
+  });
+
+  test('save changes', done => {
+    const deleteEmailStub =
+        sinon.stub(element.$.restAPI, 'deleteAccountEmail');
+    const setPreferredStub = sinon.stub(element.$.restAPI,
+        'setPreferredAccountEmail');
+    const rows = element.shadowRoot
+        .querySelector('table').querySelectorAll('tbody tr');
+
+    assert.isFalse(element.hasUnsavedChanges);
+    assert.isNotOk(element._newPreferred);
+    assert.equal(element._emailsToRemove.length, 0);
+    assert.equal(element._emails.length, 3);
+
+    // Delete the first email and set the last as preferred.
+    rows[0].querySelector('gr-button').click();
+    rows[2].querySelector('input[type=radio]').click();
+
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.equal(element._newPreferred, 'email@three.com');
+    assert.equal(element._emailsToRemove.length, 1);
+    assert.equal(element._emailsToRemove[0].email, 'email@one.com');
+    assert.equal(element._emails.length, 2);
+
+    // Save the changes.
+    element.save().then(() => {
+      assert.equal(deleteEmailStub.callCount, 1);
+      assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
+
+      assert.isTrue(setPreferredStub.called);
+      assert.equal(setPreferredStub.getCall(0).args[0], 'email@three.com');
+
+      done();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
index 90631c7..d1bc7eb 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 import '../../../styles/gr-form-styles.js';
 import '../../shared/gr-button/gr-button.js';
@@ -29,7 +27,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-gpg-editor_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrGpgEditor extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -74,9 +72,8 @@
   }
 
   save() {
-    const promises = this._keysToRemove.map(key => {
-      this.$.restAPI.deleteAccountGPGKey(key.id);
-    });
+    const promises = this._keysToRemove
+        .map(key => this.$.restAPI.deleteAccountGPGKey(key.id));
 
     return Promise.all(promises).then(() => {
       this._keysToRemove = [];
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.js
deleted file mode 100644
index 19b8d0c..0000000
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.js
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    .keyHeader {
-      width: 9em;
-    }
-    .userIdHeader {
-      width: 15em;
-    }
-    #viewKeyOverlay {
-      padding: var(--spacing-xxl);
-      width: 50em;
-    }
-    .publicKey {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      overflow-x: scroll;
-      overflow-wrap: break-word;
-      width: 30em;
-    }
-    .closeButton {
-      bottom: 2em;
-      position: absolute;
-      right: 2em;
-    }
-    #existing {
-      margin-bottom: var(--spacing-l);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset id="existing">
-      <table>
-        <thead>
-          <tr>
-            <th class="idColumn">ID</th>
-            <th class="fingerPrintColumn">Fingerprint</th>
-            <th class="userIdHeader">User IDs</th>
-            <th class="keyHeader">Public Key</th>
-            <th></th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template is="dom-repeat" items="[[_keys]]" as="key">
-            <tr>
-              <td class="idColumn">[[key.id]]</td>
-              <td class="fingerPrintColumn">[[key.fingerprint]]</td>
-              <td class="userIdHeader">
-                <template is="dom-repeat" items="[[key.user_ids]]">
-                  [[item]]
-                </template>
-              </td>
-              <td class="keyHeader">
-                <gr-button on-click="_showKey" data-index$="[[index]]" link=""
-                  >Click to View</gr-button
-                >
-              </td>
-              <td>
-                <gr-copy-clipboard
-                  has-tooltip=""
-                  button-title="Copy GPG public key to clipboard"
-                  hide-input=""
-                  text="[[key.key]]"
-                >
-                </gr-copy-clipboard>
-              </td>
-              <td>
-                <gr-button data-index$="[[index]]" on-click="_handleDeleteKey"
-                  >Delete</gr-button
-                >
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-      <gr-overlay id="viewKeyOverlay" with-backdrop="">
-        <fieldset>
-          <section>
-            <span class="title">Status</span>
-            <span class="value">[[_keyToView.status]]</span>
-          </section>
-          <section>
-            <span class="title">Key</span>
-            <span class="value">[[_keyToView.key]]</span>
-          </section>
-        </fieldset>
-        <gr-button class="closeButton" on-click="_closeOverlay"
-          >Close</gr-button
-        >
-      </gr-overlay>
-      <gr-button on-click="save" disabled$="[[!hasUnsavedChanges]]"
-        >Save changes</gr-button
-      >
-    </fieldset>
-    <fieldset>
-      <section>
-        <span class="title">New GPG key</span>
-        <span class="value">
-          <iron-autogrow-textarea
-            id="newKey"
-            autocomplete="on"
-            bind-value="{{_newKey}}"
-            placeholder="New GPG Key"
-          ></iron-autogrow-textarea>
-        </span>
-      </section>
-      <gr-button
-        id="addButton"
-        disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-        on-click="_handleAddKey"
-        >Add new GPG key</gr-button
-      >
-    </fieldset>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
new file mode 100644
index 0000000..432bc4f
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
@@ -0,0 +1,137 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    .keyHeader {
+      width: 9em;
+    }
+    .userIdHeader {
+      width: 15em;
+    }
+    #viewKeyOverlay {
+      padding: var(--spacing-xxl);
+      width: 50em;
+    }
+    .publicKey {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      overflow-x: scroll;
+      overflow-wrap: break-word;
+      width: 30em;
+    }
+    .closeButton {
+      bottom: 2em;
+      position: absolute;
+      right: 2em;
+    }
+    #existing {
+      margin-bottom: var(--spacing-l);
+    }
+  </style>
+  <div class="gr-form-styles">
+    <fieldset id="existing">
+      <table>
+        <thead>
+          <tr>
+            <th class="idColumn">ID</th>
+            <th class="fingerPrintColumn">Fingerprint</th>
+            <th class="userIdHeader">User IDs</th>
+            <th class="keyHeader">Public Key</th>
+            <th></th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          <template is="dom-repeat" items="[[_keys]]" as="key">
+            <tr>
+              <td class="idColumn">[[key.id]]</td>
+              <td class="fingerPrintColumn">[[key.fingerprint]]</td>
+              <td class="userIdHeader">
+                <template is="dom-repeat" items="[[key.user_ids]]">
+                  [[item]]
+                </template>
+              </td>
+              <td class="keyHeader">
+                <gr-button on-click="_showKey" data-index$="[[index]]" link=""
+                  >Click to View</gr-button
+                >
+              </td>
+              <td>
+                <gr-copy-clipboard
+                  has-tooltip=""
+                  button-title="Copy GPG public key to clipboard"
+                  hide-input=""
+                  text="[[key.key]]"
+                >
+                </gr-copy-clipboard>
+              </td>
+              <td>
+                <gr-button data-index$="[[index]]" on-click="_handleDeleteKey"
+                  >Delete</gr-button
+                >
+              </td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+      <gr-overlay id="viewKeyOverlay" with-backdrop="">
+        <fieldset>
+          <section>
+            <span class="title">Status</span>
+            <span class="value">[[_keyToView.status]]</span>
+          </section>
+          <section>
+            <span class="title">Key</span>
+            <span class="value">[[_keyToView.key]]</span>
+          </section>
+        </fieldset>
+        <gr-button class="closeButton" on-click="_closeOverlay"
+          >Close</gr-button
+        >
+      </gr-overlay>
+      <gr-button on-click="save" disabled$="[[!hasUnsavedChanges]]"
+        >Save changes</gr-button
+      >
+    </fieldset>
+    <fieldset>
+      <section>
+        <span class="title">New GPG key</span>
+        <span class="value">
+          <iron-autogrow-textarea
+            id="newKey"
+            autocomplete="on"
+            bind-value="{{_newKey}}"
+            placeholder="New GPG Key"
+          ></iron-autogrow-textarea>
+        </span>
+      </section>
+      <gr-button
+        id="addButton"
+        disabled$="[[_computeAddButtonDisabled(_newKey)]]"
+        on-click="_handleAddKey"
+        >Add new GPG key</gr-button
+      >
+    </fieldset>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
deleted file mode 100644
index 4a0af5b..0000000
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.html
+++ /dev/null
@@ -1,195 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-gpg-editor</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-gpg-editor></gr-gpg-editor>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-gpg-editor.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-gpg-editor tests', () => {
-  let element;
-  let keys;
-
-  setup(done => {
-    const fingerprint1 = '0192 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
-    const fingerprint2 = '0196 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
-    keys = {
-      AFC8A49B: {
-        fingerprint: fingerprint1,
-        user_ids: [
-          'John Doe john.doe@example.com',
-        ],
-        key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-             '\nVersion: BCPG v1.52\n\t<key 1>',
-        status: 'TRUSTED',
-        problems: [],
-      },
-      AED9B59C: {
-        fingerprint: fingerprint2,
-        user_ids: [
-          'Gerrit gerrit@example.com',
-        ],
-        key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-             '\nVersion: BCPG v1.52\n\t<key 2>',
-        status: 'TRUSTED',
-        problems: [],
-      },
-    };
-
-    stub('gr-rest-api-interface', {
-      getAccountGPGKeys() { return Promise.resolve(keys); },
-    });
-
-    element = fixture('basic');
-
-    element.loadData().then(() => { flush(done); });
-  });
-
-  test('renders', () => {
-    const rows = dom(element.root).querySelectorAll('tbody tr');
-
-    assert.equal(rows.length, 2);
-
-    let cells = rows[0].querySelectorAll('td');
-    assert.equal(cells[0].textContent, 'AFC8A49B');
-
-    cells = rows[1].querySelectorAll('td');
-    assert.equal(cells[0].textContent, 'AED9B59C');
-  });
-
-  test('remove key', done => {
-    const lastKey = keys[Object.keys(keys)[1]];
-
-    const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountGPGKey',
-        () => Promise.resolve());
-
-    assert.equal(element._keysToRemove.length, 0);
-    assert.isFalse(element.hasUnsavedChanges);
-
-    // Get the delete button for the last row.
-    const button = dom(element.root).querySelector(
-        'tbody tr:last-of-type td:nth-child(6) gr-button');
-
-    MockInteractions.tap(button);
-
-    assert.equal(element._keys.length, 1);
-    assert.equal(element._keysToRemove.length, 1);
-    assert.equal(element._keysToRemove[0], lastKey);
-    assert.isTrue(element.hasUnsavedChanges);
-    assert.isFalse(saveStub.called);
-
-    element.save().then(() => {
-      assert.isTrue(saveStub.called);
-      assert.equal(saveStub.lastCall.args[0], Object.keys(keys)[1]);
-      assert.equal(element._keysToRemove.length, 0);
-      assert.isFalse(element.hasUnsavedChanges);
-      done();
-    });
-  });
-
-  test('show key', () => {
-    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
-
-    // Get the show button for the last row.
-    const button = dom(element.root).querySelector(
-        'tbody tr:last-of-type td:nth-child(4) gr-button');
-
-    MockInteractions.tap(button);
-
-    assert.equal(element._keyToView, keys[Object.keys(keys)[1]]);
-    assert.isTrue(openSpy.called);
-  });
-
-  test('add key', done => {
-    const newKeyString =
-        '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-        '\nVersion: BCPG v1.52\n\t<key 3>';
-    const newKeyObject = {
-      ADE8A59B: {
-        fingerprint: '0194 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B',
-        user_ids: [
-          'John john@example.com',
-        ],
-        key: newKeyString,
-        status: 'TRUSTED',
-        problems: [],
-      },
-    };
-
-    const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
-        () => Promise.resolve(newKeyObject));
-
-    element._newKey = newKeyString;
-
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
-
-    element._handleAddKey().then(() => {
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
-      done();
-    });
-
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
-
-    assert.isTrue(addStub.called);
-    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
-  });
-
-  test('add invalid key', done => {
-    const newKeyString = 'not even close to valid';
-
-    const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey',
-        () => Promise.reject(new Error('error')));
-
-    element._newKey = newKeyString;
-
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
-
-    element._handleAddKey().then(() => {
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
-      done();
-    });
-
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
-
-    assert.isTrue(addStub.called);
-    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
new file mode 100644
index 0000000..5281e17
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
@@ -0,0 +1,181 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-gpg-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-gpg-editor');
+
+suite('gr-gpg-editor tests', () => {
+  let element;
+  let keys;
+
+  setup(done => {
+    const fingerprint1 = '0192 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
+    const fingerprint2 = '0196 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
+    keys = {
+      AFC8A49B: {
+        fingerprint: fingerprint1,
+        user_ids: [
+          'John Doe john.doe@example.com',
+        ],
+        key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+             '\nVersion: BCPG v1.52\n\t<key 1>',
+        status: 'TRUSTED',
+        problems: [],
+      },
+      AED9B59C: {
+        fingerprint: fingerprint2,
+        user_ids: [
+          'Gerrit gerrit@example.com',
+        ],
+        key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+             '\nVersion: BCPG v1.52\n\t<key 2>',
+        status: 'TRUSTED',
+        problems: [],
+      },
+    };
+
+    stub('gr-rest-api-interface', {
+      getAccountGPGKeys() { return Promise.resolve(keys); },
+    });
+
+    element = basicFixture.instantiate();
+
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('renders', () => {
+    const rows = dom(element.root).querySelectorAll('tbody tr');
+
+    assert.equal(rows.length, 2);
+
+    let cells = rows[0].querySelectorAll('td');
+    assert.equal(cells[0].textContent, 'AFC8A49B');
+
+    cells = rows[1].querySelectorAll('td');
+    assert.equal(cells[0].textContent, 'AED9B59C');
+  });
+
+  test('remove key', done => {
+    const lastKey = keys[Object.keys(keys)[1]];
+
+    const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountGPGKey')
+        .callsFake(() => Promise.resolve());
+
+    assert.equal(element._keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+
+    // Get the delete button for the last row.
+    const button = dom(element.root).querySelector(
+        'tbody tr:last-of-type td:nth-child(6) gr-button');
+
+    MockInteractions.tap(button);
+
+    assert.equal(element._keys.length, 1);
+    assert.equal(element._keysToRemove.length, 1);
+    assert.equal(element._keysToRemove[0], lastKey);
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isFalse(saveStub.called);
+
+    element.save().then(() => {
+      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.lastCall.args[0], Object.keys(keys)[1]);
+      assert.equal(element._keysToRemove.length, 0);
+      assert.isFalse(element.hasUnsavedChanges);
+      done();
+    });
+  });
+
+  test('show key', () => {
+    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+
+    // Get the show button for the last row.
+    const button = dom(element.root).querySelector(
+        'tbody tr:last-of-type td:nth-child(4) gr-button');
+
+    MockInteractions.tap(button);
+
+    assert.equal(element._keyToView, keys[Object.keys(keys)[1]]);
+    assert.isTrue(openSpy.called);
+  });
+
+  test('add key', done => {
+    const newKeyString =
+        '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+        '\nVersion: BCPG v1.52\n\t<key 3>';
+    const newKeyObject = {
+      ADE8A59B: {
+        fingerprint: '0194 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B',
+        user_ids: [
+          'John john@example.com',
+        ],
+        key: newKeyString,
+        status: 'TRUSTED',
+        problems: [],
+      },
+    };
+
+    const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey').callsFake(
+        () => Promise.resolve(newKeyObject));
+
+    element._newKey = newKeyString;
+
+    assert.isFalse(element.$.addButton.disabled);
+    assert.isFalse(element.$.newKey.disabled);
+
+    element._handleAddKey().then(() => {
+      assert.isTrue(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+      assert.equal(element._keys.length, 2);
+      done();
+    });
+
+    assert.isTrue(element.$.addButton.disabled);
+    assert.isTrue(element.$.newKey.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+  });
+
+  test('add invalid key', done => {
+    const newKeyString = 'not even close to valid';
+
+    const addStub = sinon.stub(element.$.restAPI, 'addAccountGPGKey').callsFake(
+        () => Promise.reject(new Error('error')));
+
+    element._newKey = newKeyString;
+
+    assert.isFalse(element.$.addButton.disabled);
+    assert.isFalse(element.$.newKey.disabled);
+
+    element._handleAddKey().then(() => {
+      assert.isFalse(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+      assert.equal(element._keys.length, 2);
+      done();
+    });
+
+    assert.isTrue(element.$.addButton.disabled);
+    assert.isTrue(element.$.newKey.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
index 1cc1369..429a7c7 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../../../styles/gr-form-styles.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
@@ -25,7 +23,7 @@
 import {htmlTemplate} from './gr-group-list_html.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrGroupList extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.js
deleted file mode 100644
index d5350aa..0000000
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    #groups .nameColumn {
-      min-width: 11em;
-      width: auto;
-    }
-    .descriptionHeader {
-      min-width: 21.5em;
-    }
-    .visibleCell {
-      text-align: center;
-      width: 6em;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="groups">
-      <thead>
-        <tr>
-          <th class="nameHeader">Name</th>
-          <th class="descriptionHeader">Description</th>
-          <th class="visibleCell">Visible to all</th>
-        </tr>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[_groups]]">
-          <tr>
-            <td class="nameColumn">
-              <a href$="[[_computeGroupPath(item)]]">
-                [[item.name]]
-              </a>
-            </td>
-            <td>[[item.description]]</td>
-            <td class="visibleCell">[[_computeVisibleToAll(item)]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts
new file mode 100644
index 0000000..e52583d
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    #groups .nameColumn {
+      min-width: 11em;
+      width: auto;
+    }
+    .descriptionHeader {
+      min-width: 21.5em;
+    }
+    .visibleCell {
+      text-align: center;
+      width: 6em;
+    }
+  </style>
+  <div class="gr-form-styles">
+    <table id="groups">
+      <thead>
+        <tr>
+          <th class="nameHeader">Name</th>
+          <th class="descriptionHeader">Description</th>
+          <th class="visibleCell">Visible to all</th>
+        </tr>
+      </thead>
+      <tbody>
+        <template is="dom-repeat" items="[[_groups]]">
+          <tr>
+            <td class="nameColumn">
+              <a href$="[[_computeGroupPath(item)]]">
+                [[item.name]]
+              </a>
+            </td>
+            <td>[[item.description]]</td>
+            <td class="visibleCell">[[_computeVisibleToAll(item)]]</td>
+          </tr>
+        </template>
+      </tbody>
+    </table>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
deleted file mode 100644
index 2fdc7b3..0000000
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
+++ /dev/null
@@ -1,124 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-settings-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-group-list></gr-group-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-group-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-group-list tests', () => {
-  let sandbox;
-  let element;
-  let groups;
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    groups = [{
-      url: 'some url',
-      options: {},
-      description: 'Group 1 description',
-      group_id: 1,
-      owner: 'Administrators',
-      owner_id: '123',
-      id: 'abc',
-      name: 'Group 1',
-    }, {
-      options: {visible_to_all: true},
-      id: '456',
-      name: 'Group 2',
-    }, {
-      options: {},
-      id: '789',
-      name: 'Group 3',
-    }];
-
-    stub('gr-rest-api-interface', {
-      getAccountGroups() { return Promise.resolve(groups); },
-    });
-
-    element = fixture('basic');
-
-    element.loadData().then(() => { flush(done); });
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('renders', () => {
-    const rows = Array.from(
-        dom(element.root).querySelectorAll('tbody tr'));
-
-    assert.equal(rows.length, 3);
-
-    const nameCells = rows.map(row =>
-      row.querySelectorAll('td a')[0].textContent.trim()
-    );
-
-    assert.equal(nameCells[0], 'Group 1');
-    assert.equal(nameCells[1], 'Group 2');
-    assert.equal(nameCells[2], 'Group 3');
-  });
-
-  test('_computeVisibleToAll', () => {
-    assert.equal(element._computeVisibleToAll(groups[0]), 'No');
-    assert.equal(element._computeVisibleToAll(groups[1]), 'Yes');
-  });
-
-  test('_computeGroupPath', () => {
-    let urlStub = sandbox.stub(GerritNav, 'getUrlForGroup',
-        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-    let group = {
-      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
-    };
-    assert.equal(element._computeGroupPath(group),
-        '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-    group = {
-      name: 'admin',
-    };
-    assert.isUndefined(element._computeGroupPath(group));
-
-    urlStub.restore();
-
-    urlStub = sandbox.stub(GerritNav, 'getUrlForGroup',
-        () => '/admin/groups/user/test');
-
-    group = {
-      id: 'user%2Ftest',
-    };
-    assert.equal(element._computeGroupPath(group),
-        '/admin/groups/user/test');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
new file mode 100644
index 0000000..bfd42ac
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
@@ -0,0 +1,105 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-group-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-group-list');
+
+suite('gr-group-list tests', () => {
+  let element;
+  let groups;
+
+  setup(done => {
+    groups = [{
+      url: 'some url',
+      options: {},
+      description: 'Group 1 description',
+      group_id: 1,
+      owner: 'Administrators',
+      owner_id: '123',
+      id: 'abc',
+      name: 'Group 1',
+    }, {
+      options: {visible_to_all: true},
+      id: '456',
+      name: 'Group 2',
+    }, {
+      options: {},
+      id: '789',
+      name: 'Group 3',
+    }];
+
+    stub('gr-rest-api-interface', {
+      getAccountGroups() { return Promise.resolve(groups); },
+    });
+
+    element = basicFixture.instantiate();
+
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('renders', () => {
+    const rows = Array.from(
+        dom(element.root).querySelectorAll('tbody tr'));
+
+    assert.equal(rows.length, 3);
+
+    const nameCells = rows.map(row =>
+      row.querySelectorAll('td a')[0].textContent.trim()
+    );
+
+    assert.equal(nameCells[0], 'Group 1');
+    assert.equal(nameCells[1], 'Group 2');
+    assert.equal(nameCells[2], 'Group 3');
+  });
+
+  test('_computeVisibleToAll', () => {
+    assert.equal(element._computeVisibleToAll(groups[0]), 'No');
+    assert.equal(element._computeVisibleToAll(groups[1]), 'Yes');
+  });
+
+  test('_computeGroupPath', () => {
+    let urlStub = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
+        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+    let group = {
+      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
+    };
+    assert.equal(element._computeGroupPath(group),
+        '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+    group = {
+      name: 'admin',
+    };
+    assert.isUndefined(element._computeGroupPath(group));
+
+    urlStub.restore();
+
+    urlStub = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
+        () => '/admin/groups/user/test');
+
+    group = {
+      id: 'user%2Ftest',
+    };
+    assert.equal(element._computeGroupPath(group),
+        '/admin/groups/user/test');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
index 02657f8..164bdee 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/gr-form-styles.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
@@ -27,7 +25,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-http-password_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrHttpPassword extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.js
deleted file mode 100644
index 0474b99..0000000
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.js
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .password {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-    }
-    #generatedPasswordOverlay {
-      padding: var(--spacing-xxl);
-      width: 50em;
-    }
-    #generatedPasswordDisplay {
-      margin: var(--spacing-l) 0;
-    }
-    #generatedPasswordDisplay .title {
-      width: unset;
-    }
-    #generatedPasswordDisplay .value {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-    }
-    #passwordWarning {
-      font-style: italic;
-      text-align: center;
-    }
-    .closeButton {
-      bottom: 2em;
-      position: absolute;
-      right: 2em;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="gr-form-styles">
-    <div hidden$="[[_passwordUrl]]">
-      <section>
-        <span class="title">Username</span>
-        <span class="value">[[_username]]</span>
-      </section>
-      <gr-button id="generateButton" on-click="_handleGenerateTap"
-        >Generate new password</gr-button
-      >
-    </div>
-    <span hidden$="[[!_passwordUrl]]">
-      <a href$="[[_passwordUrl]]" target="_blank" rel="noopener">
-        Obtain password</a
-      >
-      (opens in a new tab)
-    </span>
-  </div>
-  <gr-overlay
-    id="generatedPasswordOverlay"
-    on-iron-overlay-closed="_generatedPasswordOverlayClosed"
-    with-backdrop=""
-  >
-    <div class="gr-form-styles">
-      <section id="generatedPasswordDisplay">
-        <span class="title">New Password:</span>
-        <span class="value">[[_generatedPassword]]</span>
-        <gr-copy-clipboard
-          has-tooltip=""
-          button-title="Copy password to clipboard"
-          hide-input=""
-          text="[[_generatedPassword]]"
-        >
-        </gr-copy-clipboard>
-      </section>
-      <section id="passwordWarning">
-        This password will not be displayed again.<br />
-        If you lose it, you will need to generate a new one.
-      </section>
-      <gr-button link="" class="closeButton" on-click="_closeOverlay"
-        >Close</gr-button
-      >
-    </div>
-  </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
new file mode 100644
index 0000000..41084b5
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
@@ -0,0 +1,98 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .password {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+    }
+    #generatedPasswordOverlay {
+      padding: var(--spacing-xxl);
+      width: 50em;
+    }
+    #generatedPasswordDisplay {
+      margin: var(--spacing-l) 0;
+    }
+    #generatedPasswordDisplay .title {
+      width: unset;
+    }
+    #generatedPasswordDisplay .value {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+    }
+    #passwordWarning {
+      font-style: italic;
+      text-align: center;
+    }
+    .closeButton {
+      bottom: 2em;
+      position: absolute;
+      right: 2em;
+    }
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <div class="gr-form-styles">
+    <div hidden$="[[_passwordUrl]]">
+      <section>
+        <span class="title">Username</span>
+        <span class="value">[[_username]]</span>
+      </section>
+      <gr-button id="generateButton" on-click="_handleGenerateTap"
+        >Generate new password</gr-button
+      >
+    </div>
+    <span hidden$="[[!_passwordUrl]]">
+      <a href$="[[_passwordUrl]]" target="_blank" rel="noopener">
+        Obtain password</a
+      >
+      (opens in a new tab)
+    </span>
+  </div>
+  <gr-overlay
+    id="generatedPasswordOverlay"
+    on-iron-overlay-closed="_generatedPasswordOverlayClosed"
+    with-backdrop=""
+  >
+    <div class="gr-form-styles">
+      <section id="generatedPasswordDisplay">
+        <span class="title">New Password:</span>
+        <span class="value">[[_generatedPassword]]</span>
+        <gr-copy-clipboard
+          has-tooltip=""
+          button-title="Copy password to clipboard"
+          hide-input=""
+          text="[[_generatedPassword]]"
+        >
+        </gr-copy-clipboard>
+      </section>
+      <section id="passwordWarning">
+        This password will not be displayed again.<br />
+        If you lose it, you will need to generate a new one.
+      </section>
+      <gr-button link="" class="closeButton" on-click="_closeOverlay"
+        >Close</gr-button
+      >
+    </div>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
deleted file mode 100644
index 26fa84d..0000000
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
+++ /dev/null
@@ -1,91 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-settings-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-http-password></gr-http-password>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-http-password.js';
-suite('gr-http-password tests', () => {
-  let element;
-  let account;
-  let config;
-
-  setup(done => {
-    account = {username: 'user name'};
-    config = {auth: {}};
-
-    stub('gr-rest-api-interface', {
-      getAccount() { return Promise.resolve(account); },
-      getConfig() { return Promise.resolve(config); },
-    });
-
-    element = fixture('basic');
-    element.loadData().then(() => { flush(done); });
-  });
-
-  test('generate password', () => {
-    const button = element.$.generateButton;
-    const nextPassword = 'the new password';
-    let generateResolve;
-    const generateStub = sinon.stub(element.$.restAPI,
-        'generateAccountHttpPassword', () => new Promise(resolve => {
-          generateResolve = resolve;
-        }));
-
-    assert.isNotOk(element._generatedPassword);
-
-    MockInteractions.tap(button);
-
-    assert.isTrue(generateStub.called);
-    assert.equal(element._generatedPassword, 'Generating...');
-
-    generateResolve(nextPassword);
-
-    generateStub.lastCall.returnValue.then(() => {
-      assert.equal(element._generatedPassword, nextPassword);
-    });
-  });
-
-  test('without http_password_url', () => {
-    assert.isNull(element._passwordUrl);
-  });
-
-  test('with http_password_url', done => {
-    config.auth.http_password_url = 'http://example.com/';
-    element.loadData().then(() => {
-      assert.isNotNull(element._passwordUrl);
-      assert.equal(element._passwordUrl, config.auth.http_password_url);
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js
new file mode 100644
index 0000000..920ad48
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-http-password.js';
+
+const basicFixture = fixtureFromElement('gr-http-password');
+
+suite('gr-http-password tests', () => {
+  let element;
+  let account;
+  let config;
+
+  setup(done => {
+    account = {username: 'user name'};
+    config = {auth: {}};
+
+    stub('gr-rest-api-interface', {
+      getAccount() { return Promise.resolve(account); },
+      getConfig() { return Promise.resolve(config); },
+    });
+
+    element = basicFixture.instantiate();
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('generate password', () => {
+    const button = element.$.generateButton;
+    const nextPassword = 'the new password';
+    let generateResolve;
+    const generateStub = sinon.stub(element.$.restAPI,
+        'generateAccountHttpPassword')
+        .callsFake(() => new Promise(resolve => {
+          generateResolve = resolve;
+        }));
+
+    assert.isNotOk(element._generatedPassword);
+
+    MockInteractions.tap(button);
+
+    assert.isTrue(generateStub.called);
+    assert.equal(element._generatedPassword, 'Generating...');
+
+    generateResolve(nextPassword);
+
+    generateStub.lastCall.returnValue.then(() => {
+      assert.equal(element._generatedPassword, nextPassword);
+    });
+  });
+
+  test('without http_password_url', () => {
+    assert.isNull(element._passwordUrl);
+  });
+
+  test('with http_password_url', done => {
+    config.auth.http_password_url = 'http://example.com/';
+    element.loadData().then(() => {
+      assert.isNotNull(element._passwordUrl);
+      assert.equal(element._passwordUrl, config.auth.http_password_url);
+      done();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
index 74c5eed..eee06e3 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
@@ -14,20 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../../../styles/gr-form-styles.js';
 import '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-overlay/gr-overlay.js';
 import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-identities_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 
 const AUTH = [
   'OPENID',
@@ -35,13 +32,11 @@
 ];
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrIdentities extends mixinBehaviors( [
-  BaseUrlBehavior,
-], GestureEventListeners(
+class GrIdentities extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-identities'; }
@@ -108,7 +103,7 @@
   }
 
   _computeLinkAnotherIdentity() {
-    const baseUrl = this.getBaseUrl() || '';
+    const baseUrl = getBaseUrl() || '';
     let pathname = window.location.pathname;
     if (baseUrl) {
       pathname = '/' + pathname.substring(baseUrl.length);
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.js
deleted file mode 100644
index bf50124..0000000
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.js
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    tr th.emailAddressHeader,
-    tr th.identityHeader {
-      width: 15em;
-      padding: 0 10px;
-    }
-    tr td.statusColumn,
-    tr td.emailAddressColumn,
-    tr td.identityColumn {
-      word-break: break-word;
-    }
-    tr td.emailAddressColumn,
-    tr td.identityColumn {
-      padding: 4px 10px;
-      width: 15em;
-    }
-    .deleteButton {
-      float: right;
-    }
-    .deleteButton:not(.show) {
-      display: none;
-    }
-    .space {
-      margin-bottom: var(--spacing-l);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset class="space">
-      <table>
-        <thead>
-          <tr>
-            <th class="statusHeader">Status</th>
-            <th class="emailAddressHeader">Email Address</th>
-            <th class="identityHeader">Identity</th>
-            <th class="deleteHeader"></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template
-            is="dom-repeat"
-            items="[[_identities]]"
-            filter="filterIdentities"
-          >
-            <tr>
-              <td class="statusColumn">
-                [[_computeIsTrusted(item.trusted)]]
-              </td>
-              <td class="emailAddressColumn">[[item.email_address]]</td>
-              <td class="identityColumn">
-                [[_computeIdentity(item.identity)]]
-              </td>
-              <td class="deleteColumn">
-                <gr-button
-                  class$="deleteButton [[_computeHideDeleteClass(item.can_delete)]]"
-                  on-click="_handleDeleteItem"
-                >
-                  Delete
-                </gr-button>
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-    </fieldset>
-    <template is="dom-if" if="[[_showLinkAnotherIdentity]]">
-      <fieldset>
-        <a href$="[[_computeLinkAnotherIdentity()]]">
-          <gr-button id="linkAnotherIdentity" link=""
-            >Link Another Identity</gr-button
-          >
-        </a>
-      </fieldset>
-    </template>
-  </div>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-confirm-delete-item-dialog
-      class="confirmDialog"
-      on-confirm="_handleDeleteItemConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      item="[[_idName]]"
-      item-type="id"
-    ></gr-confirm-delete-item-dialog>
-  </gr-overlay>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
new file mode 100644
index 0000000..1472103
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    tr th.emailAddressHeader,
+    tr th.identityHeader {
+      width: 15em;
+      padding: 0 10px;
+    }
+    tr td.statusColumn,
+    tr td.emailAddressColumn,
+    tr td.identityColumn {
+      word-break: break-word;
+    }
+    tr td.emailAddressColumn,
+    tr td.identityColumn {
+      padding: 4px 10px;
+      width: 15em;
+    }
+    .deleteButton {
+      float: right;
+    }
+    .deleteButton:not(.show) {
+      display: none;
+    }
+    .space {
+      margin-bottom: var(--spacing-l);
+    }
+  </style>
+  <div class="gr-form-styles">
+    <fieldset class="space">
+      <table>
+        <thead>
+          <tr>
+            <th class="statusHeader">Status</th>
+            <th class="emailAddressHeader">Email Address</th>
+            <th class="identityHeader">Identity</th>
+            <th class="deleteHeader"></th>
+          </tr>
+        </thead>
+        <tbody>
+          <template
+            is="dom-repeat"
+            items="[[_identities]]"
+            filter="filterIdentities"
+          >
+            <tr>
+              <td class="statusColumn">
+                [[_computeIsTrusted(item.trusted)]]
+              </td>
+              <td class="emailAddressColumn">[[item.email_address]]</td>
+              <td class="identityColumn">
+                [[_computeIdentity(item.identity)]]
+              </td>
+              <td class="deleteColumn">
+                <gr-button
+                  class$="deleteButton [[_computeHideDeleteClass(item.can_delete)]]"
+                  on-click="_handleDeleteItem"
+                >
+                  Delete
+                </gr-button>
+              </td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </fieldset>
+    <template is="dom-if" if="[[_showLinkAnotherIdentity]]">
+      <fieldset>
+        <a href$="[[_computeLinkAnotherIdentity()]]">
+          <gr-button id="linkAnotherIdentity" link=""
+            >Link Another Identity</gr-button
+          >
+        </a>
+      </fieldset>
+    </template>
+  </div>
+  <gr-overlay id="overlay" with-backdrop="">
+    <gr-confirm-delete-item-dialog
+      class="confirmDialog"
+      on-confirm="_handleDeleteItemConfirm"
+      on-cancel="_handleConfirmDialogCancel"
+      item="[[_idName]]"
+      item-type="id"
+    ></gr-confirm-delete-item-dialog>
+  </gr-overlay>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
deleted file mode 100644
index 0965826..0000000
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.html
+++ /dev/null
@@ -1,190 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-identities</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-identities></gr-identities>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-identities.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-identities tests', () => {
-  let element;
-  let sandbox;
-  const ids = [
-    {
-      identity: 'username:john',
-      email_address: 'john.doe@example.com',
-      trusted: true,
-    }, {
-      identity: 'gerrit:gerrit',
-      email_address: 'gerrit@example.com',
-    }, {
-      identity: 'mailto:gerrit2@example.com',
-      email_address: 'gerrit2@example.com',
-      trusted: true,
-      can_delete: true,
-    },
-  ];
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-
-    stub('gr-rest-api-interface', {
-      getExternalIds() { return Promise.resolve(ids); },
-    });
-
-    element = fixture('basic');
-
-    element.loadData().then(() => { flush(done); });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('renders', () => {
-    const rows = Array.from(
-        dom(element.root).querySelectorAll('tbody tr'));
-
-    assert.equal(rows.length, 2);
-
-    const nameCells = rows.map(row =>
-      row.querySelectorAll('td')[2].textContent
-    );
-
-    assert.equal(nameCells[0].trim(), 'gerrit:gerrit');
-    assert.equal(nameCells[1].trim(), '');
-  });
-
-  test('renders email', () => {
-    const rows = Array.from(
-        dom(element.root).querySelectorAll('tbody tr'));
-
-    assert.equal(rows.length, 2);
-
-    const nameCells = rows.map(row =>
-      row.querySelectorAll('td')[1].textContent
-    );
-
-    assert.equal(nameCells[0], 'gerrit@example.com');
-    assert.equal(nameCells[1], 'gerrit2@example.com');
-  });
-
-  test('_computeIdentity', () => {
-    assert.equal(
-        element._computeIdentity(ids[0].identity), 'username:john');
-    assert.equal(element._computeIdentity(ids[2].identity), '');
-  });
-
-  test('filterIdentities', () => {
-    assert.isFalse(element.filterIdentities(ids[0]));
-
-    assert.isTrue(element.filterIdentities(ids[1]));
-  });
-
-  test('delete id', done => {
-    element._idName = 'mailto:gerrit2@example.com';
-    const loadDataStub = sandbox.stub(element, 'loadData');
-    element._handleDeleteItemConfirm().then(() => {
-      assert.isTrue(loadDataStub.called);
-      done();
-    });
-  });
-
-  test('_handleDeleteItem opens modal', () => {
-    const deleteBtn =
-        dom(element.root).querySelector('.deleteButton');
-    const deleteItem = sandbox.stub(element, '_handleDeleteItem');
-    MockInteractions.tap(deleteBtn);
-    assert.isTrue(deleteItem.called);
-  });
-
-  test('_computeShowLinkAnotherIdentity', () => {
-    let serverConfig;
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OAUTH',
-      },
-    };
-    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OpenID',
-      },
-    };
-    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'HTTP_LDAP',
-      },
-    };
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'LDAP',
-      },
-    };
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'HTTP',
-      },
-    };
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {};
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-  });
-
-  test('_showLinkAnotherIdentity', () => {
-    element.serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OAUTH',
-      },
-    };
-
-    assert.isTrue(element._showLinkAnotherIdentity);
-
-    element.serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'LDAP',
-      },
-    };
-
-    assert.isFalse(element._showLinkAnotherIdentity);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
new file mode 100644
index 0000000..e01c58b
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
@@ -0,0 +1,170 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-identities.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-identities');
+
+suite('gr-identities tests', () => {
+  let element;
+
+  const ids = [
+    {
+      identity: 'username:john',
+      email_address: 'john.doe@example.com',
+      trusted: true,
+    }, {
+      identity: 'gerrit:gerrit',
+      email_address: 'gerrit@example.com',
+    }, {
+      identity: 'mailto:gerrit2@example.com',
+      email_address: 'gerrit2@example.com',
+      trusted: true,
+      can_delete: true,
+    },
+  ];
+
+  setup(done => {
+    stub('gr-rest-api-interface', {
+      getExternalIds() { return Promise.resolve(ids); },
+    });
+
+    element = basicFixture.instantiate();
+
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('renders', () => {
+    const rows = Array.from(
+        dom(element.root).querySelectorAll('tbody tr'));
+
+    assert.equal(rows.length, 2);
+
+    const nameCells = rows.map(row =>
+      row.querySelectorAll('td')[2].textContent
+    );
+
+    assert.equal(nameCells[0].trim(), 'gerrit:gerrit');
+    assert.equal(nameCells[1].trim(), '');
+  });
+
+  test('renders email', () => {
+    const rows = Array.from(
+        dom(element.root).querySelectorAll('tbody tr'));
+
+    assert.equal(rows.length, 2);
+
+    const nameCells = rows.map(row =>
+      row.querySelectorAll('td')[1].textContent
+    );
+
+    assert.equal(nameCells[0], 'gerrit@example.com');
+    assert.equal(nameCells[1], 'gerrit2@example.com');
+  });
+
+  test('_computeIdentity', () => {
+    assert.equal(
+        element._computeIdentity(ids[0].identity), 'username:john');
+    assert.equal(element._computeIdentity(ids[2].identity), '');
+  });
+
+  test('filterIdentities', () => {
+    assert.isFalse(element.filterIdentities(ids[0]));
+
+    assert.isTrue(element.filterIdentities(ids[1]));
+  });
+
+  test('delete id', done => {
+    element._idName = 'mailto:gerrit2@example.com';
+    const loadDataStub = sinon.stub(element, 'loadData');
+    element._handleDeleteItemConfirm().then(() => {
+      assert.isTrue(loadDataStub.called);
+      done();
+    });
+  });
+
+  test('_handleDeleteItem opens modal', () => {
+    const deleteBtn =
+        dom(element.root).querySelector('.deleteButton');
+    const deleteItem = sinon.stub(element, '_handleDeleteItem');
+    MockInteractions.tap(deleteBtn);
+    assert.isTrue(deleteItem.called);
+  });
+
+  test('_computeShowLinkAnotherIdentity', () => {
+    let serverConfig;
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'OAUTH',
+      },
+    };
+    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'OpenID',
+      },
+    };
+    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'HTTP_LDAP',
+      },
+    };
+    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'LDAP',
+      },
+    };
+    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'HTTP',
+      },
+    };
+    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
+
+    serverConfig = {};
+    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
+  });
+
+  test('_showLinkAnotherIdentity', () => {
+    element.serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'OAUTH',
+      },
+    };
+
+    assert.isTrue(element._showLinkAnotherIdentity);
+
+    element.serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'LDAP',
+      },
+    };
+
+    assert.isFalse(element._showLinkAnotherIdentity);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
index 42982fd..b68915a 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '../../shared/gr-button/gr-button.js';
 import '../../shared/gr-date-formatter/gr-date-formatter.js';
@@ -28,7 +26,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-menu-editor_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrMenuEditor extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.js
deleted file mode 100644
index ceb8958..0000000
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.js
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .buttonColumn {
-      width: 2em;
-    }
-    .moveUpButton,
-    .moveDownButton {
-      width: 100%;
-    }
-    tbody tr:first-of-type td .moveUpButton,
-    tbody tr:last-of-type td .moveDownButton {
-      display: none;
-    }
-    td.urlCell {
-      word-break: break-word;
-    }
-    .newUrlInput {
-      min-width: 23em;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="gr-form-styles">
-    <table>
-      <thead>
-        <tr>
-          <th class="nameHeader">Name</th>
-          <th class="url-header">URL</th>
-        </tr>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[menuItems]]">
-          <tr>
-            <td>[[item.name]]</td>
-            <td class="urlCell">[[item.url]]</td>
-            <td class="buttonColumn">
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleMoveUpButton"
-                class="moveUpButton"
-                >↑</gr-button
-              >
-            </td>
-            <td class="buttonColumn">
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleMoveDownButton"
-                class="moveDownButton"
-                >↓</gr-button
-              >
-            </td>
-            <td>
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleDeleteButton"
-                class="remove-button"
-                >Delete</gr-button
-              >
-            </td>
-          </tr>
-        </template>
-      </tbody>
-      <tfoot>
-        <tr>
-          <th>
-            <iron-input
-              placeholder="New Title"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newName}}"
-            >
-              <input
-                is="iron-input"
-                placeholder="New Title"
-                on-keydown="_handleInputKeydown"
-                bind-value="{{_newName}}"
-              />
-            </iron-input>
-          </th>
-          <th>
-            <iron-input
-              class="newUrlInput"
-              placeholder="New URL"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newUrl}}"
-            >
-              <input
-                class="newUrlInput"
-                is="iron-input"
-                placeholder="New URL"
-                on-keydown="_handleInputKeydown"
-                bind-value="{{_newUrl}}"
-              />
-            </iron-input>
-          </th>
-          <th></th>
-          <th></th>
-          <th>
-            <gr-button
-              link=""
-              disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
-              on-click="_handleAddButton"
-              >Add</gr-button
-            >
-          </th>
-        </tr>
-      </tfoot>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts
new file mode 100644
index 0000000..e4d66e2
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts
@@ -0,0 +1,131 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .buttonColumn {
+      width: 2em;
+    }
+    .moveUpButton,
+    .moveDownButton {
+      width: 100%;
+    }
+    tbody tr:first-of-type td .moveUpButton,
+    tbody tr:last-of-type td .moveDownButton {
+      display: none;
+    }
+    td.urlCell {
+      word-break: break-word;
+    }
+    .newUrlInput {
+      min-width: 23em;
+    }
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <div class="gr-form-styles">
+    <table>
+      <thead>
+        <tr>
+          <th class="nameHeader">Name</th>
+          <th class="url-header">URL</th>
+        </tr>
+      </thead>
+      <tbody>
+        <template is="dom-repeat" items="[[menuItems]]">
+          <tr>
+            <td>[[item.name]]</td>
+            <td class="urlCell">[[item.url]]</td>
+            <td class="buttonColumn">
+              <gr-button
+                link=""
+                data-index$="[[index]]"
+                on-click="_handleMoveUpButton"
+                class="moveUpButton"
+                >↑</gr-button
+              >
+            </td>
+            <td class="buttonColumn">
+              <gr-button
+                link=""
+                data-index$="[[index]]"
+                on-click="_handleMoveDownButton"
+                class="moveDownButton"
+                >↓</gr-button
+              >
+            </td>
+            <td>
+              <gr-button
+                link=""
+                data-index$="[[index]]"
+                on-click="_handleDeleteButton"
+                class="remove-button"
+                >Delete</gr-button
+              >
+            </td>
+          </tr>
+        </template>
+      </tbody>
+      <tfoot>
+        <tr>
+          <th>
+            <iron-input
+              placeholder="New Title"
+              on-keydown="_handleInputKeydown"
+              bind-value="{{_newName}}"
+            >
+              <input
+                is="iron-input"
+                placeholder="New Title"
+                on-keydown="_handleInputKeydown"
+                bind-value="{{_newName}}"
+              />
+            </iron-input>
+          </th>
+          <th>
+            <iron-input
+              class="newUrlInput"
+              placeholder="New URL"
+              on-keydown="_handleInputKeydown"
+              bind-value="{{_newUrl}}"
+            >
+              <input
+                class="newUrlInput"
+                is="iron-input"
+                placeholder="New URL"
+                on-keydown="_handleInputKeydown"
+                bind-value="{{_newUrl}}"
+              />
+            </iron-input>
+          </th>
+          <th></th>
+          <th></th>
+          <th>
+            <gr-button
+              link=""
+              disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
+              on-click="_handleAddButton"
+              >Add</gr-button
+            >
+          </th>
+        </tr>
+      </tfoot>
+    </table>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
deleted file mode 100644
index 9c8db6d..0000000
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
+++ /dev/null
@@ -1,178 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-settings-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-menu-editor></gr-menu-editor>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-menu-editor.js';
-import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-menu-editor tests', () => {
-  let element;
-  let menu;
-
-  function assertMenuNamesEqual(element, expected) {
-    const names = element.menuItems.map(i => i.name);
-    assert.equal(names.length, expected.length);
-    for (let i = 0; i < names.length; i++) {
-      assert.equal(names[i], expected[i]);
-    }
-  }
-
-  // Click the up/down button (according to direction) for the index'th row.
-  // The index of the first row is 0, corresponding to the array.
-  function move(element, index, direction) {
-    const selector = 'tr:nth-child(' + (index + 1) + ') .move' +
-        direction + 'Button';
-    const button =
-        element.shadowRoot
-            .querySelector('tbody').querySelector(selector)
-            .shadowRoot
-            .querySelector('paper-button');
-    MockInteractions.tap(button);
-  }
-
-  setup(done => {
-    element = fixture('basic');
-    menu = [
-      {url: '/first/url', name: 'first name', target: '_blank'},
-      {url: '/second/url', name: 'second name', target: '_blank'},
-      {url: '/third/url', name: 'third name', target: '_blank'},
-    ];
-    element.set('menuItems', menu);
-    flush$0();
-    flush(done);
-  });
-
-  test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('tbody').querySelectorAll('tr');
-    let tds;
-
-    assert.equal(rows.length, menu.length);
-    for (let i = 0; i < menu.length; i++) {
-      tds = rows[i].querySelectorAll('td');
-      assert.equal(tds[0].textContent, menu[i].name);
-      assert.equal(tds[1].textContent, menu[i].url);
-    }
-
-    assert.isTrue(element._computeAddDisabled(element._newName,
-        element._newUrl));
-  });
-
-  test('_computeAddDisabled', () => {
-    assert.isTrue(element._computeAddDisabled('', ''));
-    assert.isTrue(element._computeAddDisabled('name', ''));
-    assert.isTrue(element._computeAddDisabled('', 'url'));
-    assert.isFalse(element._computeAddDisabled('name', 'url'));
-  });
-
-  test('add a new menu item', () => {
-    const newName = 'new name';
-    const newUrl = 'new url';
-
-    element._newName = newName;
-    element._newUrl = newUrl;
-    assert.isFalse(element._computeAddDisabled(element._newName,
-        element._newUrl));
-
-    const originalMenuLength = element.menuItems.length;
-
-    element._handleAddButton();
-
-    assert.equal(element.menuItems.length, originalMenuLength + 1);
-    assert.equal(element.menuItems[element.menuItems.length - 1].name,
-        newName);
-    assert.equal(element.menuItems[element.menuItems.length - 1].url, newUrl);
-  });
-
-  test('move items down', () => {
-    assertMenuNamesEqual(element,
-        ['first name', 'second name', 'third name']);
-
-    // Move the middle item down
-    move(element, 1, 'Down');
-    assertMenuNamesEqual(element,
-        ['first name', 'third name', 'second name']);
-
-    // Moving the bottom item down is a no-op.
-    move(element, 2, 'Down');
-    assertMenuNamesEqual(element,
-        ['first name', 'third name', 'second name']);
-  });
-
-  test('move items up', () => {
-    assertMenuNamesEqual(element,
-        ['first name', 'second name', 'third name']);
-
-    // Move the last item up twice to be the first.
-    move(element, 2, 'Up');
-    move(element, 1, 'Up');
-    assertMenuNamesEqual(element,
-        ['third name', 'first name', 'second name']);
-
-    // Moving the top item up is a no-op.
-    move(element, 0, 'Up');
-    assertMenuNamesEqual(element,
-        ['third name', 'first name', 'second name']);
-  });
-
-  test('remove item', () => {
-    assertMenuNamesEqual(element,
-        ['first name', 'second name', 'third name']);
-
-    // Tap the delete button for the middle item.
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('tbody')
-        .querySelector('tr:nth-child(2) .remove-button')
-        .shadowRoot
-        .querySelector('paper-button'));
-
-    assertMenuNamesEqual(element, ['first name', 'third name']);
-
-    // Delete remaining items.
-    for (let i = 0; i < 2; i++) {
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('tbody')
-          .querySelector('tr:first-child .remove-button')
-          .shadowRoot
-          .querySelector('paper-button'));
-    }
-    assertMenuNamesEqual(element, []);
-
-    // Add item to empty menu.
-    element._newName = 'new name';
-    element._newUrl = 'new url';
-    element._handleAddButton();
-    assertMenuNamesEqual(element, ['new name']);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.js
new file mode 100644
index 0000000..19852d9
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.js
@@ -0,0 +1,164 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-menu-editor.js';
+import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-menu-editor');
+
+suite('gr-menu-editor tests', () => {
+  let element;
+  let menu;
+
+  function assertMenuNamesEqual(element, expected) {
+    const names = element.menuItems.map(i => i.name);
+    assert.equal(names.length, expected.length);
+    for (let i = 0; i < names.length; i++) {
+      assert.equal(names[i], expected[i]);
+    }
+  }
+
+  // Click the up/down button (according to direction) for the index'th row.
+  // The index of the first row is 0, corresponding to the array.
+  function move(element, index, direction) {
+    const selector = 'tr:nth-child(' + (index + 1) + ') .move' +
+        direction + 'Button';
+    const button =
+        element.shadowRoot
+            .querySelector('tbody').querySelector(selector)
+            .shadowRoot
+            .querySelector('paper-button');
+    MockInteractions.tap(button);
+  }
+
+  setup(done => {
+    element = basicFixture.instantiate();
+    menu = [
+      {url: '/first/url', name: 'first name', target: '_blank'},
+      {url: '/second/url', name: 'second name', target: '_blank'},
+      {url: '/third/url', name: 'third name', target: '_blank'},
+    ];
+    element.set('menuItems', menu);
+    flush$0();
+    flush(done);
+  });
+
+  test('renders', () => {
+    const rows = element.shadowRoot
+        .querySelector('tbody').querySelectorAll('tr');
+    let tds;
+
+    assert.equal(rows.length, menu.length);
+    for (let i = 0; i < menu.length; i++) {
+      tds = rows[i].querySelectorAll('td');
+      assert.equal(tds[0].textContent, menu[i].name);
+      assert.equal(tds[1].textContent, menu[i].url);
+    }
+
+    assert.isTrue(element._computeAddDisabled(element._newName,
+        element._newUrl));
+  });
+
+  test('_computeAddDisabled', () => {
+    assert.isTrue(element._computeAddDisabled('', ''));
+    assert.isTrue(element._computeAddDisabled('name', ''));
+    assert.isTrue(element._computeAddDisabled('', 'url'));
+    assert.isFalse(element._computeAddDisabled('name', 'url'));
+  });
+
+  test('add a new menu item', () => {
+    const newName = 'new name';
+    const newUrl = 'new url';
+
+    element._newName = newName;
+    element._newUrl = newUrl;
+    assert.isFalse(element._computeAddDisabled(element._newName,
+        element._newUrl));
+
+    const originalMenuLength = element.menuItems.length;
+
+    element._handleAddButton();
+
+    assert.equal(element.menuItems.length, originalMenuLength + 1);
+    assert.equal(element.menuItems[element.menuItems.length - 1].name,
+        newName);
+    assert.equal(element.menuItems[element.menuItems.length - 1].url, newUrl);
+  });
+
+  test('move items down', () => {
+    assertMenuNamesEqual(element,
+        ['first name', 'second name', 'third name']);
+
+    // Move the middle item down
+    move(element, 1, 'Down');
+    assertMenuNamesEqual(element,
+        ['first name', 'third name', 'second name']);
+
+    // Moving the bottom item down is a no-op.
+    move(element, 2, 'Down');
+    assertMenuNamesEqual(element,
+        ['first name', 'third name', 'second name']);
+  });
+
+  test('move items up', () => {
+    assertMenuNamesEqual(element,
+        ['first name', 'second name', 'third name']);
+
+    // Move the last item up twice to be the first.
+    move(element, 2, 'Up');
+    move(element, 1, 'Up');
+    assertMenuNamesEqual(element,
+        ['third name', 'first name', 'second name']);
+
+    // Moving the top item up is a no-op.
+    move(element, 0, 'Up');
+    assertMenuNamesEqual(element,
+        ['third name', 'first name', 'second name']);
+  });
+
+  test('remove item', () => {
+    assertMenuNamesEqual(element,
+        ['first name', 'second name', 'third name']);
+
+    // Tap the delete button for the middle item.
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('tbody')
+        .querySelector('tr:nth-child(2) .remove-button')
+        .shadowRoot
+        .querySelector('paper-button'));
+
+    assertMenuNamesEqual(element, ['first name', 'third name']);
+
+    // Delete remaining items.
+    for (let i = 0; i < 2; i++) {
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('tbody')
+          .querySelector('tr:first-child .remove-button')
+          .shadowRoot
+          .querySelector('paper-button'));
+    }
+    assertMenuNamesEqual(element, []);
+
+    // Add item to empty menu.
+    element._newName = 'new name';
+    element._newUrl = 'new url';
+    element._handleAddButton();
+    assertMenuNamesEqual(element, ['new name']);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
index 6635de2..0f92428 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '../../../styles/gr-form-styles.js';
 import '../../shared/gr-button/gr-button.js';
@@ -27,7 +25,7 @@
 import {htmlTemplate} from './gr-registration-dialog_html.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrRegistrationDialog extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -88,7 +86,7 @@
       // Using Object.assign here allows preservation of the default values
       // supplied in the value generating function of this._account, unless
       // they are overridden by properties in the account from the response.
-      this._account = Object.assign({}, this._account, account);
+      this._account = {...this._account, ...account};
     });
 
     const loadConfig = this.$.restAPI.getConfig().then(config => {
@@ -145,7 +143,7 @@
     if ([
       config,
       username,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return undefined;
     }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.js
deleted file mode 100644
index 3559ba6..0000000
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.js
+++ /dev/null
@@ -1,133 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    main {
-      max-width: 46em;
-    }
-    :host(.loading) main {
-      display: none;
-    }
-    .loadingMessage {
-      display: none;
-      font-style: italic;
-    }
-    :host(.loading) .loadingMessage {
-      display: block;
-    }
-    hr {
-      margin-top: var(--spacing-l);
-      margin-bottom: var(--spacing-l);
-    }
-    header {
-      border-bottom: 1px solid var(--border-color);
-      font-weight: var(--font-weight-bold);
-      margin-bottom: var(--spacing-l);
-    }
-    .container {
-      padding: var(--spacing-m) var(--spacing-xl);
-    }
-    footer {
-      display: flex;
-      justify-content: flex-end;
-    }
-    footer gr-button {
-      margin-left: var(--spacing-l);
-    }
-    input {
-      width: 20em;
-    }
-    section.hide {
-      display: none;
-    }
-  </style>
-  <div class="container gr-form-styles">
-    <header>Please confirm your contact information</header>
-    <div class="loadingMessage">Loading...</div>
-    <main>
-      <p>
-        The following contact information was automatically obtained when you
-        signed in to the site. This information is used to display who you are
-        to others, and to send updates to code reviews you have either started
-        or subscribed to.
-      </p>
-      <hr />
-      <section>
-        <div class="title">Full Name</div>
-        <iron-input bind-value="{{_account.name}}">
-          <input
-            is="iron-input"
-            id="name"
-            bind-value="{{_account.name}}"
-            disabled="[[_saving]]"
-          />
-        </iron-input>
-      </section>
-      <section class$="[[_computeUsernameClass(_usernameMutable)]]">
-        <div class="title">Username</div>
-        <iron-input bind-value="{{_account.username}}">
-          <input
-            is="iron-input"
-            id="username"
-            bind-value="{{_account.username}}"
-            disabled="[[_saving]]"
-          />
-        </iron-input>
-      </section>
-      <section>
-        <div class="title">Preferred Email</div>
-        <select id="email" disabled="[[_saving]]">
-          <option value="[[_account.email]]">[[_account.email]]</option>
-          <template is="dom-repeat" items="[[_account.secondary_emails]]">
-            <option value="[[item]]">[[item]]</option>
-          </template>
-        </select>
-      </section>
-      <hr />
-      <p>
-        More configuration options for Gerrit may be found in the
-        <a on-click="close" href$="[[settingsUrl]]">settings</a>.
-      </p>
-    </main>
-    <footer>
-      <gr-button
-        id="closeButton"
-        link=""
-        disabled="[[_saving]]"
-        on-click="_handleClose"
-        >Close</gr-button
-      >
-      <gr-button
-        id="saveButton"
-        primary=""
-        link=""
-        disabled="[[_computeSaveDisabled(_account.name, _account.email, _saving)]]"
-        on-click="_handleSave"
-        >Save</gr-button
-      >
-    </footer>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
new file mode 100644
index 0000000..11fbbc9
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.ts
@@ -0,0 +1,133 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    main {
+      max-width: 46em;
+    }
+    :host(.loading) main {
+      display: none;
+    }
+    .loadingMessage {
+      display: none;
+      font-style: italic;
+    }
+    :host(.loading) .loadingMessage {
+      display: block;
+    }
+    hr {
+      margin-top: var(--spacing-l);
+      margin-bottom: var(--spacing-l);
+    }
+    header {
+      border-bottom: 1px solid var(--border-color);
+      font-weight: var(--font-weight-bold);
+      margin-bottom: var(--spacing-l);
+    }
+    .container {
+      padding: var(--spacing-m) var(--spacing-xl);
+    }
+    footer {
+      display: flex;
+      justify-content: flex-end;
+    }
+    footer gr-button {
+      margin-left: var(--spacing-l);
+    }
+    input {
+      width: 20em;
+    }
+    section.hide {
+      display: none;
+    }
+  </style>
+  <div class="container gr-form-styles">
+    <header>Please confirm your contact information</header>
+    <div class="loadingMessage">Loading...</div>
+    <main>
+      <p>
+        The following contact information was automatically obtained when you
+        signed in to the site. This information is used to display who you are
+        to others, and to send updates to code reviews you have either started
+        or subscribed to.
+      </p>
+      <hr />
+      <section>
+        <div class="title">Full Name</div>
+        <iron-input bind-value="{{_account.name}}">
+          <input
+            is="iron-input"
+            id="name"
+            bind-value="{{_account.name}}"
+            disabled="[[_saving]]"
+          />
+        </iron-input>
+      </section>
+      <section class$="[[_computeUsernameClass(_usernameMutable)]]">
+        <div class="title">Username</div>
+        <iron-input bind-value="{{_account.username}}">
+          <input
+            is="iron-input"
+            id="username"
+            bind-value="{{_account.username}}"
+            disabled="[[_saving]]"
+          />
+        </iron-input>
+      </section>
+      <section>
+        <div class="title">Preferred Email</div>
+        <select id="email" disabled="[[_saving]]">
+          <option value="[[_account.email]]">[[_account.email]]</option>
+          <template is="dom-repeat" items="[[_account.secondary_emails]]">
+            <option value="[[item]]">[[item]]</option>
+          </template>
+        </select>
+      </section>
+      <hr />
+      <p>
+        More configuration options for Gerrit may be found in the
+        <a on-click="close" href$="[[settingsUrl]]">settings</a>.
+      </p>
+    </main>
+    <footer>
+      <gr-button
+        id="closeButton"
+        link=""
+        disabled="[[_saving]]"
+        on-click="_handleClose"
+        >Close</gr-button
+      >
+      <gr-button
+        id="saveButton"
+        primary=""
+        link=""
+        disabled="[[_computeSaveDisabled(_account.name, _account.email, _saving)]]"
+        on-click="_handleSave"
+        >Save</gr-button
+      >
+    </footer>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
deleted file mode 100644
index a3f8548..0000000
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
+++ /dev/null
@@ -1,186 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-registration-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-registration-dialog></gr-registration-dialog>
-  </template>
-</test-fixture>
-
-<test-fixture id="blank">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-registration-dialog.js';
-suite('gr-registration-dialog tests', () => {
-  let element;
-  let account;
-  let sandbox;
-  let _listeners;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    _listeners = {};
-
-    account = {
-      name: 'name',
-      username: null,
-      email: 'email',
-      secondary_emails: [
-        'email2',
-        'email3',
-      ],
-    };
-
-    stub('gr-rest-api-interface', {
-      getAccount() {
-        return Promise.resolve(account);
-      },
-      setAccountName(name) {
-        account.name = name;
-        return Promise.resolve();
-      },
-      setAccountUsername(username) {
-        account.username = username;
-        return Promise.resolve();
-      },
-      setPreferredAccountEmail(email) {
-        account.email = email;
-        return Promise.resolve();
-      },
-      getConfig() {
-        return Promise.resolve(
-            {auth: {editable_account_fields: ['USER_NAME']}});
-      },
-    });
-
-    element = fixture('basic');
-
-    return element.loadData();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-    for (const eventType in _listeners) {
-      if (_listeners.hasOwnProperty(eventType)) {
-        element.removeEventListener(eventType, _listeners[eventType]);
-      }
-    }
-  });
-
-  function listen(eventType) {
-    return new Promise(resolve => {
-      _listeners[eventType] = function() { resolve(); };
-      element.addEventListener(eventType, _listeners[eventType]);
-    });
-  }
-
-  function save(opt_action) {
-    const promise = listen('account-detail-update');
-    if (opt_action) {
-      opt_action();
-    } else {
-      MockInteractions.tap(element.$.saveButton);
-    }
-    return promise;
-  }
-
-  function close(opt_action) {
-    const promise = listen('close');
-    if (opt_action) {
-      opt_action();
-    } else {
-      MockInteractions.tap(element.$.closeButton);
-    }
-    return promise;
-  }
-
-  test('fires the close event on close', done => {
-    close().then(done);
-  });
-
-  test('fires the close event on save', done => {
-    close(() => {
-      MockInteractions.tap(element.$.saveButton);
-    }).then(done);
-  });
-
-  test('saves account details', done => {
-    flush(() => {
-      element.$.name.value = 'new name';
-      element.$.username.value = 'new username';
-      element.$.email.value = 'email3';
-
-      // Nothing should be committed yet.
-      assert.equal(account.name, 'name');
-      assert.isNotOk(account.username);
-      assert.equal(account.email, 'email');
-
-      // Save and verify new values are committed.
-      save()
-          .then(() => {
-            assert.equal(account.name, 'new name');
-            assert.equal(account.username, 'new username');
-            assert.equal(account.email, 'email3');
-          })
-          .then(done);
-    });
-  });
-
-  test('email select properly populated', done => {
-    element._account = {email: 'foo', secondary_emails: ['bar', 'baz']};
-    flush(() => {
-      assert.equal(element.$.email.value, 'foo');
-      done();
-    });
-  });
-
-  test('save btn disabled', () => {
-    const compute = element._computeSaveDisabled;
-    assert.isTrue(compute('', '', false));
-    assert.isTrue(compute('', 'test', false));
-    assert.isTrue(compute('test', '', false));
-    assert.isTrue(compute('test', 'test', true));
-    assert.isFalse(compute('test', 'test', false));
-  });
-
-  test('_computeUsernameMutable', () => {
-    assert.isTrue(element._computeUsernameMutable(
-        {auth: {editable_account_fields: ['USER_NAME']}}, null));
-    assert.isFalse(element._computeUsernameMutable(
-        {auth: {editable_account_fields: ['USER_NAME']}}, 'abc'));
-    assert.isFalse(element._computeUsernameMutable(
-        {auth: {editable_account_fields: []}}, null));
-    assert.isFalse(element._computeUsernameMutable(
-        {auth: {editable_account_fields: []}}, 'abc'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
new file mode 100644
index 0000000..468ef57
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.js
@@ -0,0 +1,163 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../test/common-test-setup-karma.js';
+import './gr-registration-dialog.js';
+
+const basicFixture = fixtureFromElement('gr-registration-dialog');
+
+suite('gr-registration-dialog tests', () => {
+  let element;
+  let account;
+
+  let _listeners;
+
+  setup(() => {
+    _listeners = {};
+
+    account = {
+      name: 'name',
+      username: null,
+      email: 'email',
+      secondary_emails: [
+        'email2',
+        'email3',
+      ],
+    };
+
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve(account);
+      },
+      setAccountName(name) {
+        account.name = name;
+        return Promise.resolve();
+      },
+      setAccountUsername(username) {
+        account.username = username;
+        return Promise.resolve();
+      },
+      setPreferredAccountEmail(email) {
+        account.email = email;
+        return Promise.resolve();
+      },
+      getConfig() {
+        return Promise.resolve(
+            {auth: {editable_account_fields: ['USER_NAME']}});
+      },
+    });
+
+    element = basicFixture.instantiate();
+
+    return element.loadData();
+  });
+
+  teardown(() => {
+    for (const eventType in _listeners) {
+      if (_listeners.hasOwnProperty(eventType)) {
+        element.removeEventListener(eventType, _listeners[eventType]);
+      }
+    }
+  });
+
+  function listen(eventType) {
+    return new Promise(resolve => {
+      _listeners[eventType] = function() { resolve(); };
+      element.addEventListener(eventType, _listeners[eventType]);
+    });
+  }
+
+  function save(opt_action) {
+    const promise = listen('account-detail-update');
+    if (opt_action) {
+      opt_action();
+    } else {
+      MockInteractions.tap(element.$.saveButton);
+    }
+    return promise;
+  }
+
+  function close(opt_action) {
+    const promise = listen('close');
+    if (opt_action) {
+      opt_action();
+    } else {
+      MockInteractions.tap(element.$.closeButton);
+    }
+    return promise;
+  }
+
+  test('fires the close event on close', done => {
+    close().then(done);
+  });
+
+  test('fires the close event on save', done => {
+    close(() => {
+      MockInteractions.tap(element.$.saveButton);
+    }).then(done);
+  });
+
+  test('saves account details', done => {
+    flush(() => {
+      element.$.name.value = 'new name';
+      element.$.username.value = 'new username';
+      element.$.email.value = 'email3';
+
+      // Nothing should be committed yet.
+      assert.equal(account.name, 'name');
+      assert.isNotOk(account.username);
+      assert.equal(account.email, 'email');
+
+      // Save and verify new values are committed.
+      save()
+          .then(() => {
+            assert.equal(account.name, 'new name');
+            assert.equal(account.username, 'new username');
+            assert.equal(account.email, 'email3');
+          })
+          .then(done);
+    });
+  });
+
+  test('email select properly populated', done => {
+    element._account = {email: 'foo', secondary_emails: ['bar', 'baz']};
+    flush(() => {
+      assert.equal(element.$.email.value, 'foo');
+      done();
+    });
+  });
+
+  test('save btn disabled', () => {
+    const compute = element._computeSaveDisabled;
+    assert.isTrue(compute('', '', false));
+    assert.isTrue(compute('', 'test', false));
+    assert.isTrue(compute('test', '', false));
+    assert.isTrue(compute('test', 'test', true));
+    assert.isFalse(compute('test', 'test', false));
+  });
+
+  test('_computeUsernameMutable', () => {
+    assert.isTrue(element._computeUsernameMutable(
+        {auth: {editable_account_fields: ['USER_NAME']}}, null));
+    assert.isFalse(element._computeUsernameMutable(
+        {auth: {editable_account_fields: ['USER_NAME']}}, 'abc'));
+    assert.isFalse(element._computeUsernameMutable(
+        {auth: {editable_account_fields: []}}, null));
+    assert.isFalse(element._computeUsernameMutable(
+        {auth: {editable_account_fields: []}}, 'abc'));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
index 3884a15..2455cec 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
@@ -14,13 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-settings-item_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrSettingsItem extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js
deleted file mode 100644
index e26faab..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-xxl);
-    }
-  </style>
-  <h2 id="[[anchor]]">[[title]]</h2>
-  <slot></slot>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.ts
new file mode 100644
index 0000000..786abc0
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.ts
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style>
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-xxl);
+    }
+  </style>
+  <h2 id="[[anchor]]" class="heading-2">[[title]]</h2>
+  <slot></slot>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
index 5b11516..4d839f8 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
@@ -14,15 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/gr-page-nav-styles.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-settings-menu-item_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrSettingsMenuItem extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.js
deleted file mode 100644
index 95433ac..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-page-nav-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="navStyles">
-    <li><a href$="[[href]]">[[title]]</a></li>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.ts
new file mode 100644
index 0000000..fc3edcd
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.ts
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-page-nav-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <div class="navStyles">
+    <li><a href$="[[href]]">[[title]]</a></li>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 158c5eb..06d9183 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -14,14 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '@polymer/paper-toggle-button/paper-toggle-button.js';
 import '../../../styles/gr-form-styles.js';
 import '../../../styles/gr-menu-page-styles.js';
 import '../../../styles/gr-page-nav-styles.js';
 import '../../../styles/shared-styles.js';
+import {applyTheme as applyDarkTheme, removeTheme as removeDarkTheme} from '../../../styles/themes/dark-theme.js';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
 import '../gr-change-table-editor/gr-change-table-editor.js';
 import '../../shared/gr-button/gr-button.js';
@@ -41,13 +40,12 @@
 import '../gr-menu-editor/gr-menu-editor.js';
 import '../gr-ssh-editor/gr-ssh-editor.js';
 import '../gr-watched-projects-editor/gr-watched-projects-editor.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-settings-view_html.js';
-import {DocsUrlBehavior} from '../../../behaviors/docs-url-behavior/docs-url-behavior.js';
-import {ChangeTableBehavior} from '../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.js';
+import {getDocsBaseUrl} from '../../../utils/url-util.js';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
 
 const PREFS_SECTION_FIELDS = [
   'changes_per_page',
@@ -70,20 +68,15 @@
 const ABSOLUTE_URL_PATTERN = /^https?:/;
 const TRAILING_SLASH_PATTERN = /\/$/;
 
-const RELOAD_MESSAGE = 'Reloading...';
-
 const HTTP_AUTH = [
   'HTTP',
   'HTTP_LDAP',
 ];
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrSettingsView extends mixinBehaviors( [
-  DocsUrlBehavior,
-  ChangeTableBehavior,
-], GestureEventListeners(
+class GrSettingsView extends ChangeTableMixin(GestureEventListeners(
     LegacyElementMixin(
         PolymerElement))) {
   static get template() { return htmlTemplate; }
@@ -240,7 +233,7 @@
       }
 
       configPromises.push(
-          this.getDocsBaseUrl(config, this.$.restAPI)
+          getDocsBaseUrl(config, this.$.restAPI)
               .then(baseUrl => { this._docsBaseUrl = baseUrl; }));
 
       return Promise.all(configPromises);
@@ -477,17 +470,19 @@
   _handleToggleDark() {
     if (this._isDark) {
       window.localStorage.removeItem('dark-theme');
+      removeDarkTheme();
     } else {
       window.localStorage.setItem('dark-theme', 'true');
+      applyDarkTheme();
     }
+    this._isDark = !!window.localStorage.getItem('dark-theme');
     this.dispatchEvent(new CustomEvent('show-alert', {
-      detail: {message: RELOAD_MESSAGE},
+      detail: {
+        message: `Theme changed to ${this._isDark ? 'dark' : 'light'}.`,
+      },
       bubbles: true,
       composed: true,
     }));
-    this.async(() => {
-      window.location.reload();
-    }, 1);
   }
 
   _showHttpAuth(config) {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
deleted file mode 100644
index e92bc68..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.js
+++ /dev/null
@@ -1,537 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      color: var(--primary-text-color);
-    }
-    .newEmailInput {
-      width: 20em;
-    }
-    #email {
-      margin-bottom: var(--spacing-l);
-    }
-    main section.darkToggle {
-      display: block;
-    }
-    .filters p,
-    .darkToggle p {
-      margin-bottom: var(--spacing-l);
-    }
-    .queryExample em {
-      color: violet;
-    }
-    .toggle {
-      align-items: center;
-      display: flex;
-      margin-bottom: var(--spacing-l);
-      margin-right: var(--spacing-l);
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-page-nav-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-  <div hidden$="[[_loading]]" hidden="">
-    <gr-page-nav class="navStyles">
-      <ul>
-        <li><a href="#Profile">Profile</a></li>
-        <li><a href="#Preferences">Preferences</a></li>
-        <li><a href="#DiffPreferences">Diff Preferences</a></li>
-        <li><a href="#EditPreferences">Edit Preferences</a></li>
-        <li><a href="#Menu">Menu</a></li>
-        <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
-        <li><a href="#Notifications">Notifications</a></li>
-        <li><a href="#EmailAddresses">Email Addresses</a></li>
-        <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
-          <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
-        </template>
-        <li hidden$="[[!_serverConfig.sshd]]">
-          <a href="#SSHKeys">
-            SSH Keys
-          </a>
-        </li>
-        <li hidden$="[[!_serverConfig.receive.enable_signed_push]]">
-          <a href="#GPGKeys">
-            GPG Keys
-          </a>
-        </li>
-        <li><a href="#Groups">Groups</a></li>
-        <li><a href="#Identities">Identities</a></li>
-        <template
-          is="dom-if"
-          if="[[_serverConfig.auth.use_contributor_agreements]]"
-        >
-          <li>
-            <a href="#Agreements">Agreements</a>
-          </li>
-        </template>
-        <li><a href="#MailFilters">Mail Filters</a></li>
-        <gr-endpoint-decorator name="settings-menu-item">
-        </gr-endpoint-decorator>
-      </ul>
-    </gr-page-nav>
-    <main class="gr-form-styles">
-      <h1>User Settings</h1>
-      <section class="darkToggle">
-        <div class="toggle">
-          <paper-toggle-button
-            checked="[[_isDark]]"
-            on-change="_handleToggleDark"
-            on-tap="_onTapDarkToggle"
-          ></paper-toggle-button>
-          <div>Dark theme (alpha)</div>
-        </div>
-        <p>
-          Gerrit's dark theme is in early alpha, and almost definitely will not
-          play nicely with themes set by specific Gerrit hosts. Filing feedback
-          via the link in the app footer is strongly encouraged!
-        </p>
-      </section>
-      <h2 id="Profile" class$="[[_computeHeaderClass(_accountInfoChanged)]]">
-        Profile
-      </h2>
-      <fieldset id="profile">
-        <gr-account-info
-          id="accountInfo"
-          has-unsaved-changes="{{_accountInfoChanged}}"
-        ></gr-account-info>
-        <gr-button
-          on-click="_handleSaveAccountInfo"
-          disabled="[[!_accountInfoChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2 id="Preferences" class$="[[_computeHeaderClass(_prefsChanged)]]">
-        Preferences
-      </h2>
-      <fieldset id="preferences">
-        <section>
-          <span class="title">Changes per page</span>
-          <span class="value">
-            <gr-select bind-value="{{_localPrefs.changes_per_page}}">
-              <select>
-                <option value="10">10 rows per page</option>
-                <option value="25">25 rows per page</option>
-                <option value="50">50 rows per page</option>
-                <option value="100">100 rows per page</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <span class="title">Date/time format</span>
-          <span class="value">
-            <gr-select bind-value="{{_localPrefs.date_format}}">
-              <select>
-                <option value="STD">Jun 3 ; Jun 3, 2016</option>
-                <option value="US">06/03 ; 06/03/16</option>
-                <option value="ISO">06-03 ; 2016-06-03</option>
-                <option value="EURO">3. Jun ; 03.06.2016</option>
-                <option value="UK">03/06 ; 03/06/2016</option>
-              </select>
-            </gr-select>
-            <gr-select bind-value="{{_localPrefs.time_format}}">
-              <select>
-                <option value="HHMM_12">4:10 PM</option>
-                <option value="HHMM_24">16:10</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <span class="title">Email notifications</span>
-          <span class="value">
-            <gr-select bind-value="{{_localPrefs.email_strategy}}">
-              <select>
-                <option value="CC_ON_OWN_COMMENTS">Every comment</option>
-                <option value="ENABLED">Only comments left by others</option>
-                <option value="DISABLED">None</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section hidden$="[[!_localPrefs.email_format]]">
-          <span class="title">Email format</span>
-          <span class="value">
-            <gr-select bind-value="{{_localPrefs.email_format}}">
-              <select>
-                <option value="HTML_PLAINTEXT">HTML and plaintext</option>
-                <option value="PLAINTEXT">Plaintext only</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section hidden$="[[!_localPrefs.default_base_for_merges]]">
-          <span class="title">Default Base For Merges</span>
-          <span class="value">
-            <gr-select bind-value="{{_localPrefs.default_base_for_merges}}">
-              <select>
-                <option value="AUTO_MERGE">Auto Merge</option>
-                <option value="FIRST_PARENT">First Parent</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <span class="title">Show Relative Dates In Changes Table</span>
-          <span class="value">
-            <input
-              id="relativeDateInChangeTable"
-              type="checkbox"
-              checked$="[[_localPrefs.relative_date_in_change_table]]"
-              on-change="_handleRelativeDateInChangeTable"
-            />
-          </span>
-        </section>
-        <section>
-          <span class="title">Diff view</span>
-          <span class="value">
-            <gr-select bind-value="{{_localPrefs.diff_view}}">
-              <select>
-                <option value="SIDE_BY_SIDE">Side by side</option>
-                <option value="UNIFIED_DIFF">Unified diff</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <span class="title">Show size bars in file list</span>
-          <span class="value">
-            <input
-              id="showSizeBarsInFileList"
-              type="checkbox"
-              checked$="[[_localPrefs.size_bar_in_change_table]]"
-              on-change="_handleShowSizeBarsInFileListChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <span class="title">Publish comments on push</span>
-          <span class="value">
-            <input
-              id="publishCommentsOnPush"
-              type="checkbox"
-              checked$="[[_localPrefs.publish_comments_on_push]]"
-              on-change="_handlePublishCommentsOnPushChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <span class="title"
-            >Set new changes to "work in progress" by default</span
-          >
-          <span class="value">
-            <input
-              id="workInProgressByDefault"
-              type="checkbox"
-              checked$="[[_localPrefs.work_in_progress_by_default]]"
-              on-change="_handleWorkInProgressByDefault"
-            />
-          </span>
-        </section>
-        <section>
-          <span class="title">
-            Insert Signed-off-by Footer For Inline Edit Changes
-          </span>
-          <span class="value">
-            <input
-              id="insertSignedOff"
-              type="checkbox"
-              checked$="[[_localPrefs.signed_off_by]]"
-              on-change="_handleInsertSignedOff"
-            />
-          </span>
-        </section>
-        <gr-button
-          id="savePrefs"
-          on-click="_handleSavePreferences"
-          disabled="[[!_prefsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2
-        id="DiffPreferences"
-        class$="[[_computeHeaderClass(_diffPrefsChanged)]]"
-      >
-        Diff Preferences
-      </h2>
-      <fieldset id="diffPreferences">
-        <gr-diff-preferences
-          id="diffPrefs"
-          has-unsaved-changes="{{_diffPrefsChanged}}"
-        ></gr-diff-preferences>
-        <gr-button
-          id="saveDiffPrefs"
-          on-click="_handleSaveDiffPreferences"
-          disabled$="[[!_diffPrefsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2
-        id="EditPreferences"
-        class$="[[_computeHeaderClass(_editPrefsChanged)]]"
-      >
-        Edit Preferences
-      </h2>
-      <fieldset id="editPreferences">
-        <gr-edit-preferences
-          id="editPrefs"
-          has-unsaved-changes="{{_editPrefsChanged}}"
-        ></gr-edit-preferences>
-        <gr-button
-          id="saveEditPrefs"
-          on-click="_handleSaveEditPreferences"
-          disabled$="[[!_editPrefsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2 id="Menu" class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
-      <fieldset id="menu">
-        <gr-menu-editor menu-items="{{_localMenu}}"></gr-menu-editor>
-        <gr-button
-          id="saveMenu"
-          on-click="_handleSaveMenu"
-          disabled="[[!_menuChanged]]"
-          >Save changes</gr-button
-        >
-        <gr-button id="resetMenu" link="" on-click="_handleResetMenuButton"
-          >Reset</gr-button
-        >
-      </fieldset>
-      <h2
-        id="ChangeTableColumns"
-        class$="[[_computeHeaderClass(_changeTableChanged)]]"
-      >
-        Change Table Columns
-      </h2>
-      <fieldset id="changeTableColumns">
-        <gr-change-table-editor
-          show-number="{{_showNumber}}"
-          displayed-columns="{{_localChangeTableColumns}}"
-        >
-        </gr-change-table-editor>
-        <gr-button
-          id="saveChangeTable"
-          on-click="_handleSaveChangeTable"
-          disabled="[[!_changeTableChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2
-        id="Notifications"
-        class$="[[_computeHeaderClass(_watchedProjectsChanged)]]"
-      >
-        Notifications
-      </h2>
-      <fieldset id="watchedProjects">
-        <gr-watched-projects-editor
-          has-unsaved-changes="{{_watchedProjectsChanged}}"
-          id="watchedProjectsEditor"
-        ></gr-watched-projects-editor>
-        <gr-button
-          on-click="_handleSaveWatchedProjects"
-          disabled$="[[!_watchedProjectsChanged]]"
-          id="_handleSaveWatchedProjects"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2 id="EmailAddresses" class$="[[_computeHeaderClass(_emailsChanged)]]">
-        Email Addresses
-      </h2>
-      <fieldset id="email">
-        <gr-email-editor
-          id="emailEditor"
-          has-unsaved-changes="{{_emailsChanged}}"
-        ></gr-email-editor>
-        <gr-button on-click="_handleSaveEmails" disabled$="[[!_emailsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <fieldset id="newEmail">
-        <section>
-          <span class="title">New email address</span>
-          <span class="value">
-            <iron-input
-              class="newEmailInput"
-              bind-value="{{_newEmail}}"
-              type="text"
-              on-keydown="_handleNewEmailKeydown"
-              placeholder="email@example.com"
-            >
-              <input
-                class="newEmailInput"
-                bind-value="{{_newEmail}}"
-                is="iron-input"
-                type="text"
-                disabled="[[_addingEmail]]"
-                on-keydown="_handleNewEmailKeydown"
-                placeholder="email@example.com"
-              />
-            </iron-input>
-          </span>
-        </section>
-        <section
-          id="verificationSentMessage"
-          hidden$="[[!_lastSentVerificationEmail]]"
-        >
-          <p>
-            A verification email was sent to
-            <em>[[_lastSentVerificationEmail]]</em>. Please check your inbox.
-          </p>
-        </section>
-        <gr-button
-          disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
-          on-click="_handleAddEmailButton"
-          >Send verification</gr-button
-        >
-      </fieldset>
-      <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
-        <div>
-          <h2 id="HTTPCredentials">HTTP Credentials</h2>
-          <fieldset>
-            <gr-http-password id="httpPass"></gr-http-password>
-          </fieldset>
-        </div>
-      </template>
-      <div hidden$="[[!_serverConfig.sshd]]">
-        <h2 id="SSHKeys" class$="[[_computeHeaderClass(_keysChanged)]]">
-          SSH keys
-        </h2>
-        <gr-ssh-editor
-          id="sshEditor"
-          has-unsaved-changes="{{_keysChanged}}"
-        ></gr-ssh-editor>
-      </div>
-      <div hidden$="[[!_serverConfig.receive.enable_signed_push]]">
-        <h2 id="GPGKeys" class$="[[_computeHeaderClass(_gpgKeysChanged)]]">
-          GPG keys
-        </h2>
-        <gr-gpg-editor
-          id="gpgEditor"
-          has-unsaved-changes="{{_gpgKeysChanged}}"
-        ></gr-gpg-editor>
-      </div>
-      <h2 id="Groups">Groups</h2>
-      <fieldset>
-        <gr-group-list id="groupList"></gr-group-list>
-      </fieldset>
-      <h2 id="Identities">Identities</h2>
-      <fieldset>
-        <gr-identities
-          id="identities"
-          server-config="[[_serverConfig]]"
-        ></gr-identities>
-      </fieldset>
-      <template
-        is="dom-if"
-        if="[[_serverConfig.auth.use_contributor_agreements]]"
-      >
-        <h2 id="Agreements">Agreements</h2>
-        <fieldset>
-          <gr-agreements-list id="agreementsList"></gr-agreements-list>
-        </fieldset>
-      </template>
-      <h2 id="MailFilters">Mail Filters</h2>
-      <fieldset class="filters">
-        <p>
-          Gerrit emails include metadata about the change to support writing
-          mail filters.
-        </p>
-        <p>
-          Here are some example Gmail queries that can be used for filters or
-          for searching through archived messages. View the
-          <a
-            href$="[[_getFilterDocsLink(_docsBaseUrl)]]"
-            target="_blank"
-            rel="nofollow"
-            >Gerrit documentation</a
-          >
-          for the complete set of footers.
-        </p>
-        <table>
-          <tbody>
-            <tr>
-              <th>Name</th>
-              <th>Query</th>
-            </tr>
-            <tr>
-              <td>Changes requesting my review</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Reviewer: <em>Your Name</em>
-                  &lt;<em>your.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes from a specific owner</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Owner: <em>Owner name</em>
-                  &lt;<em>owner.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes targeting a specific branch</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Branch: <em>branch-name</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes in a specific project</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Project: <em>project-name</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Messages related to a specific Change ID</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Change-Id: <em>Change ID</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Messages related to a specific change number</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Change-Number: <em>change number</em>"
-                </code>
-              </td>
-            </tr>
-          </tbody>
-        </table>
-      </fieldset>
-      <gr-endpoint-decorator name="settings-screen"> </gr-endpoint-decorator>
-    </main>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
new file mode 100644
index 0000000..e80e646
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -0,0 +1,544 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      color: var(--primary-text-color);
+    }
+    h2 {
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h2);
+      font-weight: var(--font-weight-h2);
+      line-height: var(--line-height-h2);
+    }
+    .newEmailInput {
+      width: 20em;
+    }
+    #email {
+      margin-bottom: var(--spacing-l);
+    }
+    main section.darkToggle {
+      display: block;
+    }
+    .filters p,
+    .darkToggle p {
+      margin-bottom: var(--spacing-l);
+    }
+    .queryExample em {
+      color: violet;
+    }
+    .toggle {
+      align-items: center;
+      display: flex;
+      margin-bottom: var(--spacing-l);
+      margin-right: var(--spacing-l);
+    }
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-menu-page-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-page-nav-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+  <div hidden$="[[_loading]]" hidden="">
+    <gr-page-nav class="navStyles">
+      <ul>
+        <li><a href="#Profile">Profile</a></li>
+        <li><a href="#Preferences">Preferences</a></li>
+        <li><a href="#DiffPreferences">Diff Preferences</a></li>
+        <li><a href="#EditPreferences">Edit Preferences</a></li>
+        <li><a href="#Menu">Menu</a></li>
+        <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
+        <li><a href="#Notifications">Notifications</a></li>
+        <li><a href="#EmailAddresses">Email Addresses</a></li>
+        <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
+          <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
+        </template>
+        <li hidden$="[[!_serverConfig.sshd]]">
+          <a href="#SSHKeys">
+            SSH Keys
+          </a>
+        </li>
+        <li hidden$="[[!_serverConfig.receive.enable_signed_push]]">
+          <a href="#GPGKeys">
+            GPG Keys
+          </a>
+        </li>
+        <li><a href="#Groups">Groups</a></li>
+        <li><a href="#Identities">Identities</a></li>
+        <template
+          is="dom-if"
+          if="[[_serverConfig.auth.use_contributor_agreements]]"
+        >
+          <li>
+            <a href="#Agreements">Agreements</a>
+          </li>
+        </template>
+        <li><a href="#MailFilters">Mail Filters</a></li>
+        <gr-endpoint-decorator name="settings-menu-item">
+        </gr-endpoint-decorator>
+      </ul>
+    </gr-page-nav>
+    <main class="gr-form-styles">
+      <h1 class="heading-1">User Settings</h1>
+      <section class="darkToggle">
+        <div class="toggle">
+          <paper-toggle-button
+            aria-labelledby="darkThemeToggleLabel"
+            checked="[[_isDark]]"
+            on-change="_handleToggleDark"
+            on-tap="_onTapDarkToggle"
+          ></paper-toggle-button>
+          <div id="darkThemeToggleLabel">Dark theme (alpha)</div>
+        </div>
+        <p>
+          Gerrit's dark theme is in early alpha, and almost definitely will not
+          play nicely with themes set by specific Gerrit hosts. Filing feedback
+          via the link in the app footer is strongly encouraged!
+        </p>
+      </section>
+      <h2 id="Profile" class$="[[_computeHeaderClass(_accountInfoChanged)]]">
+        Profile
+      </h2>
+      <fieldset id="profile">
+        <gr-account-info
+          id="accountInfo"
+          has-unsaved-changes="{{_accountInfoChanged}}"
+        ></gr-account-info>
+        <gr-button
+          on-click="_handleSaveAccountInfo"
+          disabled="[[!_accountInfoChanged]]"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <h2 id="Preferences" class$="[[_computeHeaderClass(_prefsChanged)]]">
+        Preferences
+      </h2>
+      <fieldset id="preferences">
+        <section>
+          <span class="title">Changes per page</span>
+          <span class="value">
+            <gr-select bind-value="{{_localPrefs.changes_per_page}}">
+              <select>
+                <option value="10">10 rows per page</option>
+                <option value="25">25 rows per page</option>
+                <option value="50">50 rows per page</option>
+                <option value="100">100 rows per page</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section>
+          <span class="title">Date/time format</span>
+          <span class="value">
+            <gr-select bind-value="{{_localPrefs.date_format}}">
+              <select>
+                <option value="STD">Jun 3 ; Jun 3, 2016</option>
+                <option value="US">06/03 ; 06/03/16</option>
+                <option value="ISO">06-03 ; 2016-06-03</option>
+                <option value="EURO">3. Jun ; 03.06.2016</option>
+                <option value="UK">03/06 ; 03/06/2016</option>
+              </select>
+            </gr-select>
+            <gr-select bind-value="{{_localPrefs.time_format}}">
+              <select>
+                <option value="HHMM_12">4:10 PM</option>
+                <option value="HHMM_24">16:10</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section>
+          <span class="title">Email notifications</span>
+          <span class="value">
+            <gr-select bind-value="{{_localPrefs.email_strategy}}">
+              <select>
+                <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+                <option value="ENABLED">Only comments left by others</option>
+                <option value="DISABLED">None</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section hidden$="[[!_localPrefs.email_format]]">
+          <span class="title">Email format</span>
+          <span class="value">
+            <gr-select bind-value="{{_localPrefs.email_format}}">
+              <select>
+                <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+                <option value="PLAINTEXT">Plaintext only</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section hidden$="[[!_localPrefs.default_base_for_merges]]">
+          <span class="title">Default Base For Merges</span>
+          <span class="value">
+            <gr-select bind-value="{{_localPrefs.default_base_for_merges}}">
+              <select>
+                <option value="AUTO_MERGE">Auto Merge</option>
+                <option value="FIRST_PARENT">First Parent</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section>
+          <span class="title">Show Relative Dates In Changes Table</span>
+          <span class="value">
+            <input
+              id="relativeDateInChangeTable"
+              type="checkbox"
+              checked$="[[_localPrefs.relative_date_in_change_table]]"
+              on-change="_handleRelativeDateInChangeTable"
+            />
+          </span>
+        </section>
+        <section>
+          <span class="title">Diff view</span>
+          <span class="value">
+            <gr-select bind-value="{{_localPrefs.diff_view}}">
+              <select>
+                <option value="SIDE_BY_SIDE">Side by side</option>
+                <option value="UNIFIED_DIFF">Unified diff</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section>
+          <span class="title">Show size bars in file list</span>
+          <span class="value">
+            <input
+              id="showSizeBarsInFileList"
+              type="checkbox"
+              checked$="[[_localPrefs.size_bar_in_change_table]]"
+              on-change="_handleShowSizeBarsInFileListChanged"
+            />
+          </span>
+        </section>
+        <section>
+          <span class="title">Publish comments on push</span>
+          <span class="value">
+            <input
+              id="publishCommentsOnPush"
+              type="checkbox"
+              checked$="[[_localPrefs.publish_comments_on_push]]"
+              on-change="_handlePublishCommentsOnPushChanged"
+            />
+          </span>
+        </section>
+        <section>
+          <span class="title"
+            >Set new changes to "work in progress" by default</span
+          >
+          <span class="value">
+            <input
+              id="workInProgressByDefault"
+              type="checkbox"
+              checked$="[[_localPrefs.work_in_progress_by_default]]"
+              on-change="_handleWorkInProgressByDefault"
+            />
+          </span>
+        </section>
+        <section>
+          <span class="title">
+            Insert Signed-off-by Footer For Inline Edit Changes
+          </span>
+          <span class="value">
+            <input
+              id="insertSignedOff"
+              type="checkbox"
+              checked$="[[_localPrefs.signed_off_by]]"
+              on-change="_handleInsertSignedOff"
+            />
+          </span>
+        </section>
+        <gr-button
+          id="savePrefs"
+          on-click="_handleSavePreferences"
+          disabled="[[!_prefsChanged]]"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <h2
+        id="DiffPreferences"
+        class$="[[_computeHeaderClass(_diffPrefsChanged)]]"
+      >
+        Diff Preferences
+      </h2>
+      <fieldset id="diffPreferences">
+        <gr-diff-preferences
+          id="diffPrefs"
+          has-unsaved-changes="{{_diffPrefsChanged}}"
+        ></gr-diff-preferences>
+        <gr-button
+          id="saveDiffPrefs"
+          on-click="_handleSaveDiffPreferences"
+          disabled$="[[!_diffPrefsChanged]]"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <h2
+        id="EditPreferences"
+        class$="[[_computeHeaderClass(_editPrefsChanged)]]"
+      >
+        Edit Preferences
+      </h2>
+      <fieldset id="editPreferences">
+        <gr-edit-preferences
+          id="editPrefs"
+          has-unsaved-changes="{{_editPrefsChanged}}"
+        ></gr-edit-preferences>
+        <gr-button
+          id="saveEditPrefs"
+          on-click="_handleSaveEditPreferences"
+          disabled$="[[!_editPrefsChanged]]"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <h2 id="Menu" class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
+      <fieldset id="menu">
+        <gr-menu-editor menu-items="{{_localMenu}}"></gr-menu-editor>
+        <gr-button
+          id="saveMenu"
+          on-click="_handleSaveMenu"
+          disabled="[[!_menuChanged]]"
+          >Save changes</gr-button
+        >
+        <gr-button id="resetMenu" link="" on-click="_handleResetMenuButton"
+          >Reset</gr-button
+        >
+      </fieldset>
+      <h2
+        id="ChangeTableColumns"
+        class$="[[_computeHeaderClass(_changeTableChanged)]]"
+      >
+        Change Table Columns
+      </h2>
+      <fieldset id="changeTableColumns">
+        <gr-change-table-editor
+          show-number="{{_showNumber}}"
+          displayed-columns="{{_localChangeTableColumns}}"
+        >
+        </gr-change-table-editor>
+        <gr-button
+          id="saveChangeTable"
+          on-click="_handleSaveChangeTable"
+          disabled="[[!_changeTableChanged]]"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <h2
+        id="Notifications"
+        class$="[[_computeHeaderClass(_watchedProjectsChanged)]]"
+      >
+        Notifications
+      </h2>
+      <fieldset id="watchedProjects">
+        <gr-watched-projects-editor
+          has-unsaved-changes="{{_watchedProjectsChanged}}"
+          id="watchedProjectsEditor"
+        ></gr-watched-projects-editor>
+        <gr-button
+          on-click="_handleSaveWatchedProjects"
+          disabled$="[[!_watchedProjectsChanged]]"
+          id="_handleSaveWatchedProjects"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <h2 id="EmailAddresses" class$="[[_computeHeaderClass(_emailsChanged)]]">
+        Email Addresses
+      </h2>
+      <fieldset id="email">
+        <gr-email-editor
+          id="emailEditor"
+          has-unsaved-changes="{{_emailsChanged}}"
+        ></gr-email-editor>
+        <gr-button on-click="_handleSaveEmails" disabled$="[[!_emailsChanged]]"
+          >Save changes</gr-button
+        >
+      </fieldset>
+      <fieldset id="newEmail">
+        <section>
+          <span class="title">New email address</span>
+          <span class="value">
+            <iron-input
+              class="newEmailInput"
+              bind-value="{{_newEmail}}"
+              type="text"
+              on-keydown="_handleNewEmailKeydown"
+              placeholder="email@example.com"
+            >
+              <input
+                class="newEmailInput"
+                bind-value="{{_newEmail}}"
+                is="iron-input"
+                type="text"
+                disabled="[[_addingEmail]]"
+                on-keydown="_handleNewEmailKeydown"
+                placeholder="email@example.com"
+              />
+            </iron-input>
+          </span>
+        </section>
+        <section
+          id="verificationSentMessage"
+          hidden$="[[!_lastSentVerificationEmail]]"
+        >
+          <p>
+            A verification email was sent to
+            <em>[[_lastSentVerificationEmail]]</em>. Please check your inbox.
+          </p>
+        </section>
+        <gr-button
+          disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
+          on-click="_handleAddEmailButton"
+          >Send verification</gr-button
+        >
+      </fieldset>
+      <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
+        <div>
+          <h2 id="HTTPCredentials">HTTP Credentials</h2>
+          <fieldset>
+            <gr-http-password id="httpPass"></gr-http-password>
+          </fieldset>
+        </div>
+      </template>
+      <div hidden$="[[!_serverConfig.sshd]]">
+        <h2 id="SSHKeys" class$="[[_computeHeaderClass(_keysChanged)]]">
+          SSH keys
+        </h2>
+        <gr-ssh-editor
+          id="sshEditor"
+          has-unsaved-changes="{{_keysChanged}}"
+        ></gr-ssh-editor>
+      </div>
+      <div hidden$="[[!_serverConfig.receive.enable_signed_push]]">
+        <h2 id="GPGKeys" class$="[[_computeHeaderClass(_gpgKeysChanged)]]">
+          GPG keys
+        </h2>
+        <gr-gpg-editor
+          id="gpgEditor"
+          has-unsaved-changes="{{_gpgKeysChanged}}"
+        ></gr-gpg-editor>
+      </div>
+      <h2 id="Groups">Groups</h2>
+      <fieldset>
+        <gr-group-list id="groupList"></gr-group-list>
+      </fieldset>
+      <h2 id="Identities">Identities</h2>
+      <fieldset>
+        <gr-identities
+          id="identities"
+          server-config="[[_serverConfig]]"
+        ></gr-identities>
+      </fieldset>
+      <template
+        is="dom-if"
+        if="[[_serverConfig.auth.use_contributor_agreements]]"
+      >
+        <h2 id="Agreements">Agreements</h2>
+        <fieldset>
+          <gr-agreements-list id="agreementsList"></gr-agreements-list>
+        </fieldset>
+      </template>
+      <h2 id="MailFilters">Mail Filters</h2>
+      <fieldset class="filters">
+        <p>
+          Gerrit emails include metadata about the change to support writing
+          mail filters.
+        </p>
+        <p>
+          Here are some example Gmail queries that can be used for filters or
+          for searching through archived messages. View the
+          <a
+            href$="[[_getFilterDocsLink(_docsBaseUrl)]]"
+            target="_blank"
+            rel="nofollow"
+            >Gerrit documentation</a
+          >
+          for the complete set of footers.
+        </p>
+        <table>
+          <tbody>
+            <tr>
+              <th>Name</th>
+              <th>Query</th>
+            </tr>
+            <tr>
+              <td>Changes requesting my review</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Reviewer: <em>Your Name</em>
+                  &lt;<em>your.email@example.com</em>&gt;"
+                </code>
+              </td>
+            </tr>
+            <tr>
+              <td>Changes from a specific owner</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Owner: <em>Owner name</em>
+                  &lt;<em>owner.email@example.com</em>&gt;"
+                </code>
+              </td>
+            </tr>
+            <tr>
+              <td>Changes targeting a specific branch</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Branch: <em>branch-name</em>"
+                </code>
+              </td>
+            </tr>
+            <tr>
+              <td>Changes in a specific project</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Project: <em>project-name</em>"
+                </code>
+              </td>
+            </tr>
+            <tr>
+              <td>Messages related to a specific Change ID</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Change-Id: <em>Change ID</em>"
+                </code>
+              </td>
+            </tr>
+            <tr>
+              <td>Messages related to a specific change number</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Change-Number: <em>change number</em>"
+                </code>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </fieldset>
+      <gr-endpoint-decorator name="settings-screen"> </gr-endpoint-decorator>
+    </main>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
deleted file mode 100644
index e430ecb..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ /dev/null
@@ -1,532 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-settings-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-settings-view></gr-settings-view>
-  </template>
-</test-fixture>
-
-<test-fixture id="blank">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-settings-view.js';
-import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-settings-view tests', () => {
-  let element;
-  let account;
-  let preferences;
-  let config;
-  let sandbox;
-
-  function valueOf(title, fieldsetid) {
-    const sections = element.$[fieldsetid].querySelectorAll('section');
-    let titleEl;
-    for (let i = 0; i < sections.length; i++) {
-      titleEl = sections[i].querySelector('.title');
-      if (titleEl.textContent.trim() === title) {
-        return sections[i].querySelector('.value');
-      }
-    }
-  }
-
-  // Because deepEqual isn't behaving in Safari.
-  function assertMenusEqual(actual, expected) {
-    assert.equal(actual.length, expected.length);
-    for (let i = 0; i < actual.length; i++) {
-      assert.equal(actual[i].name, expected[i].name);
-      assert.equal(actual[i].url, expected[i].url);
-    }
-  }
-
-  function stubAddAccountEmail(statusCode) {
-    return sandbox.stub(element.$.restAPI, 'addAccountEmail',
-        () => Promise.resolve({status: statusCode}));
-  }
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    account = {
-      _account_id: 123,
-      name: 'user name',
-      email: 'user@email',
-      username: 'user username',
-      registered: '2000-01-01 00:00:00.000000000',
-    };
-    preferences = {
-      changes_per_page: 25,
-      date_format: 'UK',
-      time_format: 'HHMM_12',
-      diff_view: 'UNIFIED_DIFF',
-      email_strategy: 'ENABLED',
-      email_format: 'HTML_PLAINTEXT',
-      default_base_for_merges: 'FIRST_PARENT',
-      relative_date_in_change_table: false,
-      size_bar_in_change_table: true,
-
-      my: [
-        {url: '/first/url', name: 'first name', target: '_blank'},
-        {url: '/second/url', name: 'second name', target: '_blank'},
-      ],
-      change_table: [],
-    };
-    config = {auth: {editable_account_fields: []}};
-
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-      getAccount() { return Promise.resolve(account); },
-      getPreferences() { return Promise.resolve(preferences); },
-      getWatchedProjects() {
-        return Promise.resolve([]);
-      },
-      getAccountEmails() { return Promise.resolve(); },
-      getConfig() { return Promise.resolve(config); },
-      getAccountGroups() { return Promise.resolve([]); },
-    });
-    element = fixture('basic');
-
-    // Allow the element to render.
-    element._loadingPromise.then(done);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('calls the title-change event', () => {
-    const titleChangedStub = sandbox.stub();
-
-    // Create a new view.
-    const newElement = document.createElement('gr-settings-view');
-    newElement.addEventListener('title-change', titleChangedStub);
-
-    // Attach it to the fixture.
-    const blank = fixture('blank');
-    blank.appendChild(newElement);
-
-    flush();
-
-    assert.isTrue(titleChangedStub.called);
-    assert.equal(titleChangedStub.getCall(0).args[0].detail.title,
-        'Settings');
-  });
-
-  test('user preferences', done => {
-    // Rendered with the expected preferences selected.
-    assert.equal(valueOf('Changes per page', 'preferences')
-        .firstElementChild.bindValue, preferences.changes_per_page);
-    assert.equal(valueOf('Date/time format', 'preferences')
-        .firstElementChild.bindValue, preferences.date_format);
-    assert.equal(valueOf('Date/time format', 'preferences')
-        .lastElementChild.bindValue, preferences.time_format);
-    assert.equal(valueOf('Email notifications', 'preferences')
-        .firstElementChild.bindValue, preferences.email_strategy);
-    assert.equal(valueOf('Email format', 'preferences')
-        .firstElementChild.bindValue, preferences.email_format);
-    assert.equal(valueOf('Default Base For Merges', 'preferences')
-        .firstElementChild.bindValue, preferences.default_base_for_merges);
-    assert.equal(
-        valueOf('Show Relative Dates In Changes Table', 'preferences')
-            .firstElementChild.checked, false);
-    assert.equal(valueOf('Diff view', 'preferences')
-        .firstElementChild.bindValue, preferences.diff_view);
-    assert.equal(valueOf('Show size bars in file list', 'preferences')
-        .firstElementChild.checked, true);
-    assert.equal(valueOf('Publish comments on push', 'preferences')
-        .firstElementChild.checked, false);
-    assert.equal(valueOf(
-        'Set new changes to "work in progress" by default', 'preferences')
-        .firstElementChild.checked, false);
-    assert.equal(valueOf(
-        'Insert Signed-off-by Footer For Inline Edit Changes', 'preferences')
-        .firstElementChild.checked, false);
-
-    assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
-
-    // Change the diff view element.
-    const diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
-    diffSelect.bindValue = 'SIDE_BY_SIDE';
-
-    const publishOnPush =
-        valueOf('Publish comments on push', 'preferences').firstElementChild;
-    diffSelect.dispatchEvent(
-        new CustomEvent('change', {
-          composed: true, bubbles: true,
-        }));
-
-    MockInteractions.tap(publishOnPush);
-
-    assert.isTrue(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
-
-    stub('gr-rest-api-interface', {
-      savePreferences(prefs) {
-        assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
-        assertMenusEqual(prefs.my, preferences.my);
-        assert.equal(prefs.publish_comments_on_push, true);
-        return Promise.resolve();
-      },
-    });
-
-    // Save the change.
-    element._handleSavePreferences().then(() => {
-      assert.isFalse(element._prefsChanged);
-      assert.isFalse(element._menuChanged);
-      done();
-    });
-  });
-
-  test('publish comments on push', done => {
-    const publishCommentsOnPush =
-      valueOf('Publish comments on push', 'preferences').firstElementChild;
-    MockInteractions.tap(publishCommentsOnPush);
-
-    assert.isFalse(element._menuChanged);
-    assert.isTrue(element._prefsChanged);
-
-    stub('gr-rest-api-interface', {
-      savePreferences(prefs) {
-        assert.equal(prefs.publish_comments_on_push, true);
-        return Promise.resolve();
-      },
-    });
-
-    // Save the change.
-    element._handleSavePreferences().then(() => {
-      assert.isFalse(element._prefsChanged);
-      assert.isFalse(element._menuChanged);
-      done();
-    });
-  });
-
-  test('set new changes work-in-progress', done => {
-    const newChangesWorkInProgress =
-      valueOf('Set new changes to "work in progress" by default',
-          'preferences').firstElementChild;
-    MockInteractions.tap(newChangesWorkInProgress);
-
-    assert.isFalse(element._menuChanged);
-    assert.isTrue(element._prefsChanged);
-
-    stub('gr-rest-api-interface', {
-      savePreferences(prefs) {
-        assert.equal(prefs.work_in_progress_by_default, true);
-        return Promise.resolve();
-      },
-    });
-
-    // Save the change.
-    element._handleSavePreferences().then(() => {
-      assert.isFalse(element._prefsChanged);
-      assert.isFalse(element._menuChanged);
-      done();
-    });
-  });
-
-  test('menu', done => {
-    assert.isFalse(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-
-    assertMenusEqual(element._localMenu, preferences.my);
-
-    const menu = element.$.menu.firstElementChild;
-    let tableRows = dom(menu.root).querySelectorAll('tbody tr');
-    assert.equal(tableRows.length, preferences.my.length);
-
-    // Add a menu item:
-    element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
-    flush();
-
-    tableRows = dom(menu.root).querySelectorAll('tbody tr');
-    assert.equal(tableRows.length, preferences.my.length + 1);
-
-    assert.isTrue(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-
-    stub('gr-rest-api-interface', {
-      savePreferences(prefs) {
-        assertMenusEqual(prefs.my, element._localMenu);
-        return Promise.resolve();
-      },
-    });
-
-    element._handleSaveMenu().then(() => {
-      assert.isFalse(element._menuChanged);
-      assert.isFalse(element._prefsChanged);
-      assertMenusEqual(element.prefs.my, element._localMenu);
-      done();
-    });
-  });
-
-  test('add email validation', () => {
-    assert.isFalse(element._isNewEmailValid('invalid email'));
-    assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
-
-    assert.isFalse(
-        element._computeAddEmailButtonEnabled('invalid email'), true);
-    assert.isFalse(
-        element._computeAddEmailButtonEnabled('vaguely@valid.email', true));
-    assert.isTrue(
-        element._computeAddEmailButtonEnabled('vaguely@valid.email', false));
-  });
-
-  test('add email does not save invalid', () => {
-    const addEmailStub = stubAddAccountEmail(201);
-
-    assert.isFalse(element._addingEmail);
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'invalid email';
-
-    element._handleAddEmailButton();
-
-    assert.isFalse(element._addingEmail);
-    assert.isFalse(addEmailStub.called);
-    assert.isNotOk(element._lastSentVerificationEmail);
-
-    assert.isFalse(addEmailStub.called);
-  });
-
-  test('add email does save valid', done => {
-    const addEmailStub = stubAddAccountEmail(201);
-
-    assert.isFalse(element._addingEmail);
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'valid@email.com';
-
-    element._handleAddEmailButton();
-
-    assert.isTrue(element._addingEmail);
-    assert.isTrue(addEmailStub.called);
-
-    assert.isTrue(addEmailStub.called);
-    addEmailStub.lastCall.returnValue.then(() => {
-      assert.isOk(element._lastSentVerificationEmail);
-      done();
-    });
-  });
-
-  test('add email does not set last-email if error', done => {
-    const addEmailStub = stubAddAccountEmail(500);
-
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'valid@email.com';
-
-    element._handleAddEmailButton();
-
-    assert.isTrue(addEmailStub.called);
-    addEmailStub.lastCall.returnValue.then(() => {
-      assert.isNotOk(element._lastSentVerificationEmail);
-      done();
-    });
-  });
-
-  test('emails are loaded without emailToken', () => {
-    sandbox.stub(element.$.emailEditor, 'loadData');
-    element.params = {};
-    element.attached();
-    assert.isTrue(element.$.emailEditor.loadData.calledOnce);
-  });
-
-  test('_handleSaveChangeTable', () => {
-    let newColumns = ['Owner', 'Project', 'Branch'];
-    element._localChangeTableColumns = newColumns.slice(0);
-    element._showNumber = false;
-    const cloneStub = sandbox.stub(element, '_cloneChangeTableColumns');
-    element._handleSaveChangeTable();
-    assert.isTrue(cloneStub.calledOnce);
-    assert.deepEqual(element.prefs.change_table, newColumns);
-    assert.isNotOk(element.prefs.legacycid_in_change_table);
-
-    newColumns = ['Size'];
-    element._localChangeTableColumns = newColumns;
-    element._showNumber = true;
-    element._handleSaveChangeTable();
-    assert.isTrue(cloneStub.calledTwice);
-    assert.deepEqual(element.prefs.change_table, newColumns);
-    assert.isTrue(element.prefs.legacycid_in_change_table);
-  });
-
-  test('reset menu item back to default', done => {
-    const originalMenu = {
-      my: [
-        {url: '/first/url', name: 'first name', target: '_blank'},
-        {url: '/second/url', name: 'second name', target: '_blank'},
-        {url: '/third/url', name: 'third name', target: '_blank'},
-      ],
-    };
-
-    stub('gr-rest-api-interface', {
-      getDefaultPreferences() { return Promise.resolve(originalMenu); },
-    });
-
-    const updatedMenu = [
-      {url: '/first/url', name: 'first name', target: '_blank'},
-      {url: '/second/url', name: 'second name', target: '_blank'},
-      {url: '/third/url', name: 'third name', target: '_blank'},
-      {url: '/fourth/url', name: 'fourth name', target: '_blank'},
-    ];
-
-    element.set('_localMenu', updatedMenu);
-
-    element._handleResetMenuButton().then(() => {
-      assertMenusEqual(element._localMenu, originalMenu.my);
-      done();
-    });
-  });
-
-  test('test that reset button is called', () => {
-    const overlayOpen = sandbox.stub(element, '_handleResetMenuButton');
-
-    MockInteractions.tap(element.$.resetMenu);
-
-    assert.isTrue(overlayOpen.called);
-  });
-
-  test('_showHttpAuth', () => {
-    let serverConfig;
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'HTTP',
-      },
-    };
-
-    assert.isTrue(element._showHttpAuth(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'HTTP_LDAP',
-      },
-    };
-
-    assert.isTrue(element._showHttpAuth(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'LDAP',
-      },
-    };
-
-    assert.isFalse(element._showHttpAuth(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OAUTH',
-      },
-    };
-
-    assert.isFalse(element._showHttpAuth(serverConfig));
-
-    serverConfig = {};
-
-    assert.isFalse(element._showHttpAuth(serverConfig));
-  });
-
-  suite('_getFilterDocsLink', () => {
-    test('with http: docs base URL', () => {
-      const base = 'http://example.com/';
-      const result = element._getFilterDocsLink(base);
-      assert.equal(result, 'http://example.com/user-notify.html');
-    });
-
-    test('with http: docs base URL without slash', () => {
-      const base = 'http://example.com';
-      const result = element._getFilterDocsLink(base);
-      assert.equal(result, 'http://example.com/user-notify.html');
-    });
-
-    test('with https: docs base URL', () => {
-      const base = 'https://example.com/';
-      const result = element._getFilterDocsLink(base);
-      assert.equal(result, 'https://example.com/user-notify.html');
-    });
-
-    test('without docs base URL', () => {
-      const result = element._getFilterDocsLink(null);
-      assert.equal(result, 'https://gerrit-review.googlesource.com/' +
-          'Documentation/user-notify.html');
-    });
-
-    test('ignores non HTTP links', () => {
-      const base = 'javascript://alert("evil");';
-      const result = element._getFilterDocsLink(base);
-      assert.equal(result, 'https://gerrit-review.googlesource.com/' +
-          'Documentation/user-notify.html');
-    });
-  });
-
-  suite('when email verification token is provided', () => {
-    let resolveConfirm;
-
-    setup(() => {
-      sandbox.stub(element.$.emailEditor, 'loadData');
-      sandbox.stub(
-          element.$.restAPI,
-          'confirmEmail',
-          () => new Promise(resolve => { resolveConfirm = resolve; }));
-      element.params = {emailToken: 'foo'};
-      element.attached();
-    });
-
-    test('it is used to confirm email via rest API', () => {
-      assert.isTrue(element.$.restAPI.confirmEmail.calledOnce);
-      assert.isTrue(element.$.restAPI.confirmEmail.calledWith('foo'));
-    });
-
-    test('emails are not loaded initially', () => {
-      assert.isFalse(element.$.emailEditor.loadData.called);
-    });
-
-    test('user emails are loaded after email confirmed', done => {
-      element._loadingPromise.then(() => {
-        assert.isTrue(element.$.emailEditor.loadData.calledOnce);
-        done();
-      });
-      resolveConfirm();
-    });
-
-    test('show-alert is fired when email is confirmed', done => {
-      sandbox.spy(element, 'dispatchEvent');
-      element._loadingPromise.then(() => {
-        assert.equal(
-            element.dispatchEvent.lastCall.args[0].type, 'show-alert');
-        assert.deepEqual(
-            element.dispatchEvent.lastCall.args[0].detail, {message: 'bar'}
-        );
-        done();
-      });
-      resolveConfirm('bar');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
new file mode 100644
index 0000000..c0ec344
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
@@ -0,0 +1,522 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {getComputedStyleValue} from '../../../utils/dom-util.js';
+import './gr-settings-view.js';
+import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-settings-view');
+const blankFixture = fixtureFromElement('div');
+
+suite('gr-settings-view tests', () => {
+  let element;
+  let account;
+  let preferences;
+  let config;
+
+  function valueOf(title, fieldsetid) {
+    const sections = element.$[fieldsetid].querySelectorAll('section');
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl.textContent.trim() === title) {
+        return sections[i].querySelector('.value');
+      }
+    }
+  }
+
+  // Because deepEqual isn't behaving in Safari.
+  function assertMenusEqual(actual, expected) {
+    assert.equal(actual.length, expected.length);
+    for (let i = 0; i < actual.length; i++) {
+      assert.equal(actual[i].name, expected[i].name);
+      assert.equal(actual[i].url, expected[i].url);
+    }
+  }
+
+  function stubAddAccountEmail(statusCode) {
+    return sinon.stub(element.$.restAPI, 'addAccountEmail').callsFake(
+        () => Promise.resolve({status: statusCode}));
+  }
+
+  setup(done => {
+    account = {
+      _account_id: 123,
+      name: 'user name',
+      email: 'user@email',
+      username: 'user username',
+      registered: '2000-01-01 00:00:00.000000000',
+    };
+    preferences = {
+      changes_per_page: 25,
+      date_format: 'UK',
+      time_format: 'HHMM_12',
+      diff_view: 'UNIFIED_DIFF',
+      email_strategy: 'ENABLED',
+      email_format: 'HTML_PLAINTEXT',
+      default_base_for_merges: 'FIRST_PARENT',
+      relative_date_in_change_table: false,
+      size_bar_in_change_table: true,
+
+      my: [
+        {url: '/first/url', name: 'first name', target: '_blank'},
+        {url: '/second/url', name: 'second name', target: '_blank'},
+      ],
+      change_table: [],
+    };
+    config = {auth: {editable_account_fields: []}};
+
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+      getAccount() { return Promise.resolve(account); },
+      getPreferences() { return Promise.resolve(preferences); },
+      getWatchedProjects() {
+        return Promise.resolve([]);
+      },
+      getAccountEmails() { return Promise.resolve(); },
+      getConfig() { return Promise.resolve(config); },
+      getAccountGroups() { return Promise.resolve([]); },
+    });
+    element = basicFixture.instantiate();
+
+    // Allow the element to render.
+    element._loadingPromise.then(done);
+  });
+
+  test('theme changing', () => {
+    window.localStorage.removeItem('dark-theme');
+    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
+    const themeToggle = element.shadowRoot
+        .querySelector('.darkToggle paper-toggle-button');
+    MockInteractions.tap(themeToggle);
+    assert.isTrue(window.localStorage.getItem('dark-theme') === 'true');
+    assert.equal(
+        getComputedStyleValue('--primary-text-color', document.body), '#e8eaed'
+    );
+    MockInteractions.tap(themeToggle);
+    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
+  });
+
+  test('calls the title-change event', () => {
+    const titleChangedStub = sinon.stub();
+
+    // Create a new view.
+    const newElement = document.createElement('gr-settings-view');
+    newElement.addEventListener('title-change', titleChangedStub);
+
+    const blank = blankFixture.instantiate();
+    blank.appendChild(newElement);
+
+    flush();
+
+    assert.isTrue(titleChangedStub.called);
+    assert.equal(titleChangedStub.getCall(0).args[0].detail.title,
+        'Settings');
+  });
+
+  test('user preferences', done => {
+    // Rendered with the expected preferences selected.
+    assert.equal(valueOf('Changes per page', 'preferences')
+        .firstElementChild.bindValue, preferences.changes_per_page);
+    assert.equal(valueOf('Date/time format', 'preferences')
+        .firstElementChild.bindValue, preferences.date_format);
+    assert.equal(valueOf('Date/time format', 'preferences')
+        .lastElementChild.bindValue, preferences.time_format);
+    assert.equal(valueOf('Email notifications', 'preferences')
+        .firstElementChild.bindValue, preferences.email_strategy);
+    assert.equal(valueOf('Email format', 'preferences')
+        .firstElementChild.bindValue, preferences.email_format);
+    assert.equal(valueOf('Default Base For Merges', 'preferences')
+        .firstElementChild.bindValue, preferences.default_base_for_merges);
+    assert.equal(
+        valueOf('Show Relative Dates In Changes Table', 'preferences')
+            .firstElementChild.checked, false);
+    assert.equal(valueOf('Diff view', 'preferences')
+        .firstElementChild.bindValue, preferences.diff_view);
+    assert.equal(valueOf('Show size bars in file list', 'preferences')
+        .firstElementChild.checked, true);
+    assert.equal(valueOf('Publish comments on push', 'preferences')
+        .firstElementChild.checked, false);
+    assert.equal(valueOf(
+        'Set new changes to "work in progress" by default', 'preferences')
+        .firstElementChild.checked, false);
+    assert.equal(valueOf(
+        'Insert Signed-off-by Footer For Inline Edit Changes', 'preferences')
+        .firstElementChild.checked, false);
+
+    assert.isFalse(element._prefsChanged);
+    assert.isFalse(element._menuChanged);
+
+    // Change the diff view element.
+    const diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
+    diffSelect.bindValue = 'SIDE_BY_SIDE';
+
+    const publishOnPush =
+        valueOf('Publish comments on push', 'preferences').firstElementChild;
+    diffSelect.dispatchEvent(
+        new CustomEvent('change', {
+          composed: true, bubbles: true,
+        }));
+
+    MockInteractions.tap(publishOnPush);
+
+    assert.isTrue(element._prefsChanged);
+    assert.isFalse(element._menuChanged);
+
+    stub('gr-rest-api-interface', {
+      savePreferences(prefs) {
+        assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
+        assertMenusEqual(prefs.my, preferences.my);
+        assert.equal(prefs.publish_comments_on_push, true);
+        return Promise.resolve();
+      },
+    });
+
+    // Save the change.
+    element._handleSavePreferences().then(() => {
+      assert.isFalse(element._prefsChanged);
+      assert.isFalse(element._menuChanged);
+      done();
+    });
+  });
+
+  test('publish comments on push', done => {
+    const publishCommentsOnPush =
+      valueOf('Publish comments on push', 'preferences').firstElementChild;
+    MockInteractions.tap(publishCommentsOnPush);
+
+    assert.isFalse(element._menuChanged);
+    assert.isTrue(element._prefsChanged);
+
+    stub('gr-rest-api-interface', {
+      savePreferences(prefs) {
+        assert.equal(prefs.publish_comments_on_push, true);
+        return Promise.resolve();
+      },
+    });
+
+    // Save the change.
+    element._handleSavePreferences().then(() => {
+      assert.isFalse(element._prefsChanged);
+      assert.isFalse(element._menuChanged);
+      done();
+    });
+  });
+
+  test('set new changes work-in-progress', done => {
+    const newChangesWorkInProgress =
+      valueOf('Set new changes to "work in progress" by default',
+          'preferences').firstElementChild;
+    MockInteractions.tap(newChangesWorkInProgress);
+
+    assert.isFalse(element._menuChanged);
+    assert.isTrue(element._prefsChanged);
+
+    stub('gr-rest-api-interface', {
+      savePreferences(prefs) {
+        assert.equal(prefs.work_in_progress_by_default, true);
+        return Promise.resolve();
+      },
+    });
+
+    // Save the change.
+    element._handleSavePreferences().then(() => {
+      assert.isFalse(element._prefsChanged);
+      assert.isFalse(element._menuChanged);
+      done();
+    });
+  });
+
+  test('menu', done => {
+    assert.isFalse(element._menuChanged);
+    assert.isFalse(element._prefsChanged);
+
+    assertMenusEqual(element._localMenu, preferences.my);
+
+    const menu = element.$.menu.firstElementChild;
+    let tableRows = dom(menu.root).querySelectorAll('tbody tr');
+    assert.equal(tableRows.length, preferences.my.length);
+
+    // Add a menu item:
+    element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
+    flush();
+
+    tableRows = dom(menu.root).querySelectorAll('tbody tr');
+    assert.equal(tableRows.length, preferences.my.length + 1);
+
+    assert.isTrue(element._menuChanged);
+    assert.isFalse(element._prefsChanged);
+
+    stub('gr-rest-api-interface', {
+      savePreferences(prefs) {
+        assertMenusEqual(prefs.my, element._localMenu);
+        return Promise.resolve();
+      },
+    });
+
+    element._handleSaveMenu().then(() => {
+      assert.isFalse(element._menuChanged);
+      assert.isFalse(element._prefsChanged);
+      assertMenusEqual(element.prefs.my, element._localMenu);
+      done();
+    });
+  });
+
+  test('add email validation', () => {
+    assert.isFalse(element._isNewEmailValid('invalid email'));
+    assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
+
+    assert.isFalse(
+        element._computeAddEmailButtonEnabled('invalid email'), true);
+    assert.isFalse(
+        element._computeAddEmailButtonEnabled('vaguely@valid.email', true));
+    assert.isTrue(
+        element._computeAddEmailButtonEnabled('vaguely@valid.email', false));
+  });
+
+  test('add email does not save invalid', () => {
+    const addEmailStub = stubAddAccountEmail(201);
+
+    assert.isFalse(element._addingEmail);
+    assert.isNotOk(element._lastSentVerificationEmail);
+    element._newEmail = 'invalid email';
+
+    element._handleAddEmailButton();
+
+    assert.isFalse(element._addingEmail);
+    assert.isFalse(addEmailStub.called);
+    assert.isNotOk(element._lastSentVerificationEmail);
+
+    assert.isFalse(addEmailStub.called);
+  });
+
+  test('add email does save valid', done => {
+    const addEmailStub = stubAddAccountEmail(201);
+
+    assert.isFalse(element._addingEmail);
+    assert.isNotOk(element._lastSentVerificationEmail);
+    element._newEmail = 'valid@email.com';
+
+    element._handleAddEmailButton();
+
+    assert.isTrue(element._addingEmail);
+    assert.isTrue(addEmailStub.called);
+
+    assert.isTrue(addEmailStub.called);
+    addEmailStub.lastCall.returnValue.then(() => {
+      assert.isOk(element._lastSentVerificationEmail);
+      done();
+    });
+  });
+
+  test('add email does not set last-email if error', done => {
+    const addEmailStub = stubAddAccountEmail(500);
+
+    assert.isNotOk(element._lastSentVerificationEmail);
+    element._newEmail = 'valid@email.com';
+
+    element._handleAddEmailButton();
+
+    assert.isTrue(addEmailStub.called);
+    addEmailStub.lastCall.returnValue.then(() => {
+      assert.isNotOk(element._lastSentVerificationEmail);
+      done();
+    });
+  });
+
+  test('emails are loaded without emailToken', () => {
+    sinon.stub(element.$.emailEditor, 'loadData');
+    element.params = {};
+    element.attached();
+    assert.isTrue(element.$.emailEditor.loadData.calledOnce);
+  });
+
+  test('_handleSaveChangeTable', () => {
+    let newColumns = ['Owner', 'Project', 'Branch'];
+    element._localChangeTableColumns = newColumns.slice(0);
+    element._showNumber = false;
+    const cloneStub = sinon.stub(element, '_cloneChangeTableColumns');
+    element._handleSaveChangeTable();
+    assert.isTrue(cloneStub.calledOnce);
+    assert.deepEqual(element.prefs.change_table, newColumns);
+    assert.isNotOk(element.prefs.legacycid_in_change_table);
+
+    newColumns = ['Size'];
+    element._localChangeTableColumns = newColumns;
+    element._showNumber = true;
+    element._handleSaveChangeTable();
+    assert.isTrue(cloneStub.calledTwice);
+    assert.deepEqual(element.prefs.change_table, newColumns);
+    assert.isTrue(element.prefs.legacycid_in_change_table);
+  });
+
+  test('reset menu item back to default', done => {
+    const originalMenu = {
+      my: [
+        {url: '/first/url', name: 'first name', target: '_blank'},
+        {url: '/second/url', name: 'second name', target: '_blank'},
+        {url: '/third/url', name: 'third name', target: '_blank'},
+      ],
+    };
+
+    stub('gr-rest-api-interface', {
+      getDefaultPreferences() { return Promise.resolve(originalMenu); },
+    });
+
+    const updatedMenu = [
+      {url: '/first/url', name: 'first name', target: '_blank'},
+      {url: '/second/url', name: 'second name', target: '_blank'},
+      {url: '/third/url', name: 'third name', target: '_blank'},
+      {url: '/fourth/url', name: 'fourth name', target: '_blank'},
+    ];
+
+    element.set('_localMenu', updatedMenu);
+
+    element._handleResetMenuButton().then(() => {
+      assertMenusEqual(element._localMenu, originalMenu.my);
+      done();
+    });
+  });
+
+  test('test that reset button is called', () => {
+    const overlayOpen = sinon.stub(element, '_handleResetMenuButton');
+
+    MockInteractions.tap(element.$.resetMenu);
+
+    assert.isTrue(overlayOpen.called);
+  });
+
+  test('_showHttpAuth', () => {
+    let serverConfig;
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'HTTP',
+      },
+    };
+
+    assert.isTrue(element._showHttpAuth(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'HTTP_LDAP',
+      },
+    };
+
+    assert.isTrue(element._showHttpAuth(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'LDAP',
+      },
+    };
+
+    assert.isFalse(element._showHttpAuth(serverConfig));
+
+    serverConfig = {
+      auth: {
+        git_basic_auth_policy: 'OAUTH',
+      },
+    };
+
+    assert.isFalse(element._showHttpAuth(serverConfig));
+
+    serverConfig = {};
+
+    assert.isFalse(element._showHttpAuth(serverConfig));
+  });
+
+  suite('_getFilterDocsLink', () => {
+    test('with http: docs base URL', () => {
+      const base = 'http://example.com/';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'http://example.com/user-notify.html');
+    });
+
+    test('with http: docs base URL without slash', () => {
+      const base = 'http://example.com';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'http://example.com/user-notify.html');
+    });
+
+    test('with https: docs base URL', () => {
+      const base = 'https://example.com/';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'https://example.com/user-notify.html');
+    });
+
+    test('without docs base URL', () => {
+      const result = element._getFilterDocsLink(null);
+      assert.equal(result, 'https://gerrit-review.googlesource.com/' +
+          'Documentation/user-notify.html');
+    });
+
+    test('ignores non HTTP links', () => {
+      const base = 'javascript://alert("evil");';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'https://gerrit-review.googlesource.com/' +
+          'Documentation/user-notify.html');
+    });
+  });
+
+  suite('when email verification token is provided', () => {
+    let resolveConfirm;
+
+    setup(() => {
+      sinon.stub(element.$.emailEditor, 'loadData');
+      sinon.stub(
+          element.$.restAPI,
+          'confirmEmail')
+          .callsFake(
+              () => new Promise(
+                  resolve => { resolveConfirm = resolve; }));
+      element.params = {emailToken: 'foo'};
+      element.attached();
+    });
+
+    test('it is used to confirm email via rest API', () => {
+      assert.isTrue(element.$.restAPI.confirmEmail.calledOnce);
+      assert.isTrue(element.$.restAPI.confirmEmail.calledWith('foo'));
+    });
+
+    test('emails are not loaded initially', () => {
+      assert.isFalse(element.$.emailEditor.loadData.called);
+    });
+
+    test('user emails are loaded after email confirmed', done => {
+      element._loadingPromise.then(() => {
+        assert.isTrue(element.$.emailEditor.loadData.calledOnce);
+        done();
+      });
+      resolveConfirm();
+    });
+
+    test('show-alert is fired when email is confirmed', done => {
+      sinon.spy(element, 'dispatchEvent');
+      element._loadingPromise.then(() => {
+        assert.equal(
+            element.dispatchEvent.lastCall.args[0].type, 'show-alert');
+        assert.deepEqual(
+            element.dispatchEvent.lastCall.args[0].detail, {message: 'bar'}
+        );
+        done();
+      });
+      resolveConfirm('bar');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
index 814eb7a..9c819e9 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 import '../../../styles/gr-form-styles.js';
 import '../../shared/gr-button/gr-button.js';
@@ -29,7 +27,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-ssh-editor_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrSshEditor extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -65,9 +63,8 @@
   }
 
   save() {
-    const promises = this._keysToRemove.map(key => {
-      this.$.restAPI.deleteAccountSSHKey(key.seq);
-    });
+    const promises = this._keysToRemove
+        .map(key => this.$.restAPI.deleteAccountSSHKey(key.seq));
 
     return Promise.all(promises).then(() => {
       this._keysToRemove = [];
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.js
deleted file mode 100644
index 1f3c793..0000000
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.js
+++ /dev/null
@@ -1,143 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    .statusHeader {
-      width: 4em;
-    }
-    .keyHeader {
-      width: 7.5em;
-    }
-    #viewKeyOverlay {
-      padding: var(--spacing-xxl);
-      width: 50em;
-    }
-    .publicKey {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      overflow-x: scroll;
-      overflow-wrap: break-word;
-      width: 30em;
-    }
-    .closeButton {
-      bottom: 2em;
-      position: absolute;
-      right: 2em;
-    }
-    #existing {
-      margin-bottom: var(--spacing-l);
-    }
-    #existing .commentColumn {
-      min-width: 27em;
-      width: auto;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset id="existing">
-      <table>
-        <thead>
-          <tr>
-            <th class="commentColumn">Comment</th>
-            <th class="statusHeader">Status</th>
-            <th class="keyHeader">Public key</th>
-            <th></th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template is="dom-repeat" items="[[_keys]]" as="key">
-            <tr>
-              <td class="commentColumn">[[key.comment]]</td>
-              <td>[[_getStatusLabel(key.valid)]]</td>
-              <td>
-                <gr-button link="" on-click="_showKey" data-index$="[[index]]"
-                  >Click to View</gr-button
-                >
-              </td>
-              <td>
-                <gr-copy-clipboard
-                  has-tooltip=""
-                  button-title="Copy SSH public key to clipboard"
-                  hide-input=""
-                  text="[[key.ssh_public_key]]"
-                >
-                </gr-copy-clipboard>
-              </td>
-              <td>
-                <gr-button
-                  link=""
-                  data-index$="[[index]]"
-                  on-click="_handleDeleteKey"
-                  >Delete</gr-button
-                >
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-      <gr-overlay id="viewKeyOverlay" with-backdrop="">
-        <fieldset>
-          <section>
-            <span class="title">Algorithm</span>
-            <span class="value">[[_keyToView.algorithm]]</span>
-          </section>
-          <section>
-            <span class="title">Public key</span>
-            <span class="value publicKey">[[_keyToView.encoded_key]]</span>
-          </section>
-          <section>
-            <span class="title">Comment</span>
-            <span class="value">[[_keyToView.comment]]</span>
-          </section>
-        </fieldset>
-        <gr-button class="closeButton" on-click="_closeOverlay"
-          >Close</gr-button
-        >
-      </gr-overlay>
-      <gr-button on-click="save" disabled$="[[!hasUnsavedChanges]]"
-        >Save changes</gr-button
-      >
-    </fieldset>
-    <fieldset>
-      <section>
-        <span class="title">New SSH key</span>
-        <span class="value">
-          <iron-autogrow-textarea
-            id="newKey"
-            autocomplete="on"
-            bind-value="{{_newKey}}"
-            placeholder="New SSH Key"
-          ></iron-autogrow-textarea>
-        </span>
-      </section>
-      <gr-button
-        id="addButton"
-        link=""
-        disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-        on-click="_handleAddKey"
-        >Add new SSH key</gr-button
-      >
-    </fieldset>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
new file mode 100644
index 0000000..96f770a
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
@@ -0,0 +1,143 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    .statusHeader {
+      width: 4em;
+    }
+    .keyHeader {
+      width: 7.5em;
+    }
+    #viewKeyOverlay {
+      padding: var(--spacing-xxl);
+      width: 50em;
+    }
+    .publicKey {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      overflow-x: scroll;
+      overflow-wrap: break-word;
+      width: 30em;
+    }
+    .closeButton {
+      bottom: 2em;
+      position: absolute;
+      right: 2em;
+    }
+    #existing {
+      margin-bottom: var(--spacing-l);
+    }
+    #existing .commentColumn {
+      min-width: 27em;
+      width: auto;
+    }
+  </style>
+  <div class="gr-form-styles">
+    <fieldset id="existing">
+      <table>
+        <thead>
+          <tr>
+            <th class="commentColumn">Comment</th>
+            <th class="statusHeader">Status</th>
+            <th class="keyHeader">Public key</th>
+            <th></th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          <template is="dom-repeat" items="[[_keys]]" as="key">
+            <tr>
+              <td class="commentColumn">[[key.comment]]</td>
+              <td>[[_getStatusLabel(key.valid)]]</td>
+              <td>
+                <gr-button link="" on-click="_showKey" data-index$="[[index]]"
+                  >Click to View</gr-button
+                >
+              </td>
+              <td>
+                <gr-copy-clipboard
+                  has-tooltip=""
+                  button-title="Copy SSH public key to clipboard"
+                  hide-input=""
+                  text="[[key.ssh_public_key]]"
+                >
+                </gr-copy-clipboard>
+              </td>
+              <td>
+                <gr-button
+                  link=""
+                  data-index$="[[index]]"
+                  on-click="_handleDeleteKey"
+                  >Delete</gr-button
+                >
+              </td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+      <gr-overlay id="viewKeyOverlay" with-backdrop="">
+        <fieldset>
+          <section>
+            <span class="title">Algorithm</span>
+            <span class="value">[[_keyToView.algorithm]]</span>
+          </section>
+          <section>
+            <span class="title">Public key</span>
+            <span class="value publicKey">[[_keyToView.encoded_key]]</span>
+          </section>
+          <section>
+            <span class="title">Comment</span>
+            <span class="value">[[_keyToView.comment]]</span>
+          </section>
+        </fieldset>
+        <gr-button class="closeButton" on-click="_closeOverlay"
+          >Close</gr-button
+        >
+      </gr-overlay>
+      <gr-button on-click="save" disabled$="[[!hasUnsavedChanges]]"
+        >Save changes</gr-button
+      >
+    </fieldset>
+    <fieldset>
+      <section>
+        <span class="title">New SSH key</span>
+        <span class="value">
+          <iron-autogrow-textarea
+            id="newKey"
+            autocomplete="on"
+            bind-value="{{_newKey}}"
+            placeholder="New SSH Key"
+          ></iron-autogrow-textarea>
+        </span>
+      </section>
+      <gr-button
+        id="addButton"
+        link=""
+        disabled$="[[_computeAddButtonDisabled(_newKey)]]"
+        on-click="_handleAddKey"
+        >Add new SSH key</gr-button
+      >
+    </fieldset>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
deleted file mode 100644
index 56625ae..0000000
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
+++ /dev/null
@@ -1,181 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-ssh-editor</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-ssh-editor></gr-ssh-editor>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-ssh-editor.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-ssh-editor tests', () => {
-  let element;
-  let keys;
-
-  setup(done => {
-    keys = [{
-      seq: 1,
-      ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
-      encoded_key: '<key 1>',
-      algorithm: 'ssh-rsa',
-      comment: 'comment-one@machine-one',
-      valid: true,
-    }, {
-      seq: 2,
-      ssh_public_key: 'ssh-rsa <key 2> comment-two@machine-two',
-      encoded_key: '<key 2>',
-      algorithm: 'ssh-rsa',
-      comment: 'comment-two@machine-two',
-      valid: true,
-    }];
-
-    stub('gr-rest-api-interface', {
-      getAccountSSHKeys() { return Promise.resolve(keys); },
-    });
-
-    element = fixture('basic');
-
-    element.loadData().then(() => { flush(done); });
-  });
-
-  test('renders', () => {
-    const rows = dom(element.root).querySelectorAll('tbody tr');
-
-    assert.equal(rows.length, 2);
-
-    let cells = rows[0].querySelectorAll('td');
-    assert.equal(cells[0].textContent, keys[0].comment);
-
-    cells = rows[1].querySelectorAll('td');
-    assert.equal(cells[0].textContent, keys[1].comment);
-  });
-
-  test('remove key', done => {
-    const lastKey = keys[1];
-
-    const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey',
-        () => Promise.resolve());
-
-    assert.equal(element._keysToRemove.length, 0);
-    assert.isFalse(element.hasUnsavedChanges);
-
-    // Get the delete button for the last row.
-    const button = dom(element.root).querySelector(
-        'tbody tr:last-of-type td:nth-child(5) gr-button');
-
-    MockInteractions.tap(button);
-
-    assert.equal(element._keys.length, 1);
-    assert.equal(element._keysToRemove.length, 1);
-    assert.equal(element._keysToRemove[0], lastKey);
-    assert.isTrue(element.hasUnsavedChanges);
-    assert.isFalse(saveStub.called);
-
-    element.save().then(() => {
-      assert.isTrue(saveStub.called);
-      assert.equal(saveStub.lastCall.args[0], lastKey.seq);
-      assert.equal(element._keysToRemove.length, 0);
-      assert.isFalse(element.hasUnsavedChanges);
-      done();
-    });
-  });
-
-  test('show key', () => {
-    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
-
-    // Get the show button for the last row.
-    const button = dom(element.root).querySelector(
-        'tbody tr:last-of-type td:nth-child(3) gr-button');
-
-    MockInteractions.tap(button);
-
-    assert.equal(element._keyToView, keys[1]);
-    assert.isTrue(openSpy.called);
-  });
-
-  test('add key', done => {
-    const newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
-    const newKeyObject = {
-      seq: 3,
-      ssh_public_key: newKeyString,
-      encoded_key: '<key 3>',
-      algorithm: 'ssh-rsa',
-      comment: 'comment-three@machine-three',
-      valid: true,
-    };
-
-    const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
-        () => Promise.resolve(newKeyObject));
-
-    element._newKey = newKeyString;
-
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
-
-    element._handleAddKey().then(() => {
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 3);
-      done();
-    });
-
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
-
-    assert.isTrue(addStub.called);
-    assert.equal(addStub.lastCall.args[0], newKeyString);
-  });
-
-  test('add invalid key', done => {
-    const newKeyString = 'not even close to valid';
-
-    const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
-        () => Promise.reject(new Error('error')));
-
-    element._newKey = newKeyString;
-
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
-
-    element._handleAddKey().then(() => {
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
-      done();
-    });
-
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
-
-    assert.isTrue(addStub.called);
-    assert.equal(addStub.lastCall.args[0], newKeyString);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
new file mode 100644
index 0000000..d4a0372
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
@@ -0,0 +1,167 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-ssh-editor.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-ssh-editor');
+
+suite('gr-ssh-editor tests', () => {
+  let element;
+  let keys;
+
+  setup(done => {
+    keys = [{
+      seq: 1,
+      ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
+      encoded_key: '<key 1>',
+      algorithm: 'ssh-rsa',
+      comment: 'comment-one@machine-one',
+      valid: true,
+    }, {
+      seq: 2,
+      ssh_public_key: 'ssh-rsa <key 2> comment-two@machine-two',
+      encoded_key: '<key 2>',
+      algorithm: 'ssh-rsa',
+      comment: 'comment-two@machine-two',
+      valid: true,
+    }];
+
+    stub('gr-rest-api-interface', {
+      getAccountSSHKeys() { return Promise.resolve(keys); },
+    });
+
+    element = basicFixture.instantiate();
+
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('renders', () => {
+    const rows = dom(element.root).querySelectorAll('tbody tr');
+
+    assert.equal(rows.length, 2);
+
+    let cells = rows[0].querySelectorAll('td');
+    assert.equal(cells[0].textContent, keys[0].comment);
+
+    cells = rows[1].querySelectorAll('td');
+    assert.equal(cells[0].textContent, keys[1].comment);
+  });
+
+  test('remove key', done => {
+    const lastKey = keys[1];
+
+    const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey')
+        .callsFake(() => Promise.resolve());
+
+    assert.equal(element._keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+
+    // Get the delete button for the last row.
+    const button = dom(element.root).querySelector(
+        'tbody tr:last-of-type td:nth-child(5) gr-button');
+
+    MockInteractions.tap(button);
+
+    assert.equal(element._keys.length, 1);
+    assert.equal(element._keysToRemove.length, 1);
+    assert.equal(element._keysToRemove[0], lastKey);
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isFalse(saveStub.called);
+
+    element.save().then(() => {
+      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.lastCall.args[0], lastKey.seq);
+      assert.equal(element._keysToRemove.length, 0);
+      assert.isFalse(element.hasUnsavedChanges);
+      done();
+    });
+  });
+
+  test('show key', () => {
+    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+
+    // Get the show button for the last row.
+    const button = dom(element.root).querySelector(
+        'tbody tr:last-of-type td:nth-child(3) gr-button');
+
+    MockInteractions.tap(button);
+
+    assert.equal(element._keyToView, keys[1]);
+    assert.isTrue(openSpy.called);
+  });
+
+  test('add key', done => {
+    const newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
+    const newKeyObject = {
+      seq: 3,
+      ssh_public_key: newKeyString,
+      encoded_key: '<key 3>',
+      algorithm: 'ssh-rsa',
+      comment: 'comment-three@machine-three',
+      valid: true,
+    };
+
+    const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey').callsFake(
+        () => Promise.resolve(newKeyObject));
+
+    element._newKey = newKeyString;
+
+    assert.isFalse(element.$.addButton.disabled);
+    assert.isFalse(element.$.newKey.disabled);
+
+    element._handleAddKey().then(() => {
+      assert.isTrue(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+      assert.equal(element._keys.length, 3);
+      done();
+    });
+
+    assert.isTrue(element.$.addButton.disabled);
+    assert.isTrue(element.$.newKey.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.equal(addStub.lastCall.args[0], newKeyString);
+  });
+
+  test('add invalid key', done => {
+    const newKeyString = 'not even close to valid';
+
+    const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey').callsFake(
+        () => Promise.reject(new Error('error')));
+
+    element._newKey = newKeyString;
+
+    assert.isFalse(element.$.addButton.disabled);
+    assert.isFalse(element.$.newKey.disabled);
+
+    element._handleAddKey().then(() => {
+      assert.isFalse(element.$.addButton.disabled);
+      assert.isFalse(element.$.newKey.disabled);
+      assert.equal(element._keys.length, 2);
+      done();
+    });
+
+    assert.isTrue(element.$.addButton.disabled);
+    assert.isTrue(element.$.newKey.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.equal(addStub.lastCall.args[0], newKeyString);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
index b8960e8..2af1bc7 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '../../shared/gr-autocomplete/gr-autocomplete.js';
 import '../../shared/gr-button/gr-button.js';
@@ -36,7 +34,7 @@
   {name: 'Abandons', key: 'notify_abandoned_changes'},
 ];
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrWatchedProjectsEditor extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.js
deleted file mode 100644
index b1ca653..0000000
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.js
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    #watchedProjects .notifType {
-      text-align: center;
-      padding: 0 var(--spacing-s);
-    }
-    .notifControl {
-      cursor: pointer;
-      text-align: center;
-    }
-    .notifControl:hover {
-      outline: 1px solid var(--border-color);
-    }
-    .projectFilter {
-      color: var(--deemphasized-text-color);
-      font-style: italic;
-      margin-left: var(--spacing-l);
-    }
-    .newFilterInput {
-      width: 100%;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="watchedProjects">
-      <thead>
-        <tr>
-          <th>Repo</th>
-          <template is="dom-repeat" items="[[_getTypes()]]">
-            <th class="notifType">[[item.name]]</th>
-          </template>
-          <th></th>
-        </tr>
-      </thead>
-      <tbody>
-        <template
-          is="dom-repeat"
-          items="[[_projects]]"
-          as="project"
-          index-as="projectIndex"
-        >
-          <tr>
-            <td>
-              [[project.project]]
-              <template is="dom-if" if="[[project.filter]]">
-                <div class="projectFilter">[[project.filter]]</div>
-              </template>
-            </td>
-            <template is="dom-repeat" items="[[_getTypes()]]" as="type">
-              <td class="notifControl" on-click="_handleNotifCellClick">
-                <input
-                  type="checkbox"
-                  data-index$="[[projectIndex]]"
-                  data-key$="[[type.key]]"
-                  on-change="_handleCheckboxChange"
-                  checked$="[[_computeCheckboxChecked(project, type.key)]]"
-                />
-              </td>
-            </template>
-            <td>
-              <gr-button
-                link=""
-                data-index$="[[projectIndex]]"
-                on-click="_handleRemoveProject"
-                >Delete</gr-button
-              >
-            </td>
-          </tr>
-        </template>
-      </tbody>
-      <tfoot>
-        <tr>
-          <th>
-            <gr-autocomplete
-              id="newProject"
-              query="[[_query]]"
-              threshold="1"
-              allow-non-suggested-values=""
-              tab-complete=""
-              placeholder="Repo"
-            ></gr-autocomplete>
-          </th>
-          <th colspan$="[[_getTypeCount()]]">
-            <iron-input
-              class="newFilterInput"
-              placeholder="branch:name, or other search expression"
-            >
-              <input
-                id="newFilter"
-                class="newFilterInput"
-                is="iron-input"
-                placeholder="branch:name, or other search expression"
-              />
-            </iron-input>
-          </th>
-          <th>
-            <gr-button link="" on-click="_handleAddProject">Add</gr-button>
-          </th>
-        </tr>
-      </tfoot>
-    </table>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
new file mode 100644
index 0000000..e3a90a2
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    #watchedProjects .notifType {
+      text-align: center;
+      padding: 0 var(--spacing-s);
+    }
+    .notifControl {
+      cursor: pointer;
+      text-align: center;
+    }
+    .notifControl:hover {
+      outline: 1px solid var(--border-color);
+    }
+    .projectFilter {
+      color: var(--deemphasized-text-color);
+      font-style: italic;
+      margin-left: var(--spacing-l);
+    }
+    .newFilterInput {
+      width: 100%;
+    }
+  </style>
+  <div class="gr-form-styles">
+    <table id="watchedProjects">
+      <thead>
+        <tr>
+          <th>Repo</th>
+          <template is="dom-repeat" items="[[_getTypes()]]">
+            <th class="notifType">[[item.name]]</th>
+          </template>
+          <th></th>
+        </tr>
+      </thead>
+      <tbody>
+        <template
+          is="dom-repeat"
+          items="[[_projects]]"
+          as="project"
+          index-as="projectIndex"
+        >
+          <tr>
+            <td>
+              [[project.project]]
+              <template is="dom-if" if="[[project.filter]]">
+                <div class="projectFilter">[[project.filter]]</div>
+              </template>
+            </td>
+            <template is="dom-repeat" items="[[_getTypes()]]" as="type">
+              <td class="notifControl" on-click="_handleNotifCellClick">
+                <input
+                  type="checkbox"
+                  data-index$="[[projectIndex]]"
+                  data-key$="[[type.key]]"
+                  on-change="_handleCheckboxChange"
+                  checked$="[[_computeCheckboxChecked(project, type.key)]]"
+                />
+              </td>
+            </template>
+            <td>
+              <gr-button
+                link=""
+                data-index$="[[projectIndex]]"
+                on-click="_handleRemoveProject"
+                >Delete</gr-button
+              >
+            </td>
+          </tr>
+        </template>
+      </tbody>
+      <tfoot>
+        <tr>
+          <th>
+            <gr-autocomplete
+              id="newProject"
+              query="[[_query]]"
+              threshold="1"
+              allow-non-suggested-values=""
+              tab-complete=""
+              placeholder="Repo"
+            ></gr-autocomplete>
+          </th>
+          <th colspan$="[[_getTypeCount()]]">
+            <iron-input
+              class="newFilterInput"
+              placeholder="branch:name, or other search expression"
+            >
+              <input
+                id="newFilter"
+                class="newFilterInput"
+                is="iron-input"
+                placeholder="branch:name, or other search expression"
+              />
+            </iron-input>
+          </th>
+          <th>
+            <gr-button link="" on-click="_handleAddProject">Add</gr-button>
+          </th>
+        </tr>
+      </tfoot>
+    </table>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
deleted file mode 100644
index 2a08e4f..0000000
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
+++ /dev/null
@@ -1,215 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-settings-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-watched-projects-editor></gr-watched-projects-editor>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-watched-projects-editor.js';
-suite('gr-watched-projects-editor tests', () => {
-  let element;
-
-  setup(done => {
-    const projects = [
-      {
-        project: 'project a',
-        notify_submitted_changes: true,
-        notify_abandoned_changes: true,
-      }, {
-        project: 'project b',
-        filter: 'filter 1',
-        notify_new_changes: true,
-      }, {
-        project: 'project b',
-        filter: 'filter 2',
-      }, {
-        project: 'project c',
-        notify_new_changes: true,
-        notify_new_patch_sets: true,
-        notify_all_comments: true,
-      },
-    ];
-
-    stub('gr-rest-api-interface', {
-      getSuggestedProjects(input) {
-        if (input.startsWith('th')) {
-          return Promise.resolve({'the project': {
-            id: 'the project',
-            state: 'ACTIVE',
-            web_links: [],
-          }});
-        } else {
-          return Promise.resolve({});
-        }
-      },
-      getWatchedProjects() {
-        return Promise.resolve(projects);
-      },
-    });
-
-    element = fixture('basic');
-
-    element.loadData().then(() => { flush(done); });
-  });
-
-  test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('table').querySelectorAll('tbody tr');
-    assert.equal(rows.length, 4);
-
-    function getKeysOfRow(row) {
-      const boxes = rows[row].querySelectorAll('input[checked]');
-      return Array.prototype.map.call(boxes,
-          e => e.getAttribute('data-key'));
-    }
-
-    let checkedKeys = getKeysOfRow(0);
-    assert.equal(checkedKeys.length, 2);
-    assert.equal(checkedKeys[0], 'notify_submitted_changes');
-    assert.equal(checkedKeys[1], 'notify_abandoned_changes');
-
-    checkedKeys = getKeysOfRow(1);
-    assert.equal(checkedKeys.length, 1);
-    assert.equal(checkedKeys[0], 'notify_new_changes');
-
-    checkedKeys = getKeysOfRow(2);
-    assert.equal(checkedKeys.length, 0);
-
-    checkedKeys = getKeysOfRow(3);
-    assert.equal(checkedKeys.length, 3);
-    assert.equal(checkedKeys[0], 'notify_new_changes');
-    assert.equal(checkedKeys[1], 'notify_new_patch_sets');
-    assert.equal(checkedKeys[2], 'notify_all_comments');
-  });
-
-  test('_getProjectSuggestions empty', done => {
-    element._getProjectSuggestions('nonexistent').then(projects => {
-      assert.equal(projects.length, 0);
-      done();
-    });
-  });
-
-  test('_getProjectSuggestions non-empty', done => {
-    element._getProjectSuggestions('the project').then(projects => {
-      assert.equal(projects.length, 1);
-      assert.equal(projects[0].name, 'the project');
-      done();
-    });
-  });
-
-  test('_getProjectSuggestions non-empty with two letter project', done => {
-    element._getProjectSuggestions('th').then(projects => {
-      assert.equal(projects.length, 1);
-      assert.equal(projects[0].name, 'the project');
-      done();
-    });
-  });
-
-  test('_canAddProject', () => {
-    assert.isFalse(element._canAddProject(null, null, null));
-    assert.isFalse(element._canAddProject({}, null, null));
-
-    // Can add a project that is not in the list.
-    assert.isTrue(element._canAddProject({id: 'project d'}, null, null));
-    assert.isTrue(element._canAddProject({id: 'project d'}, null, 'filter 3'));
-
-    // Cannot add a project that is in the list with no filter.
-    assert.isFalse(element._canAddProject({id: 'project a'}, null, null));
-
-    // Can add a project that is in the list if the filter differs.
-    assert.isTrue(element._canAddProject({id: 'project a'}, null, 'filter 4'));
-
-    // Cannot add a project that is in the list with the same filter.
-    assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 1'));
-    assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 2'));
-
-    // Can add a project that is in the list using a new filter.
-    assert.isTrue(element._canAddProject({id: 'project b'}, null, 'filter 3'));
-
-    // Can add a project that is not added by the auto complete
-    assert.isTrue(element._canAddProject(null, 'test', null));
-  });
-
-  test('_getNewProjectIndex', () => {
-    // Projects are sorted in ASCII order.
-    assert.equal(element._getNewProjectIndex('project A', 'filter'), 0);
-    assert.equal(element._getNewProjectIndex('project a', 'filter'), 1);
-
-    // Projects are sorted by filter when the names are equal
-    assert.equal(element._getNewProjectIndex('project b', 'filter 0'), 1);
-    assert.equal(element._getNewProjectIndex('project b', 'filter 1.5'), 2);
-    assert.equal(element._getNewProjectIndex('project b', 'filter 3'), 3);
-
-    // Projects with filters follow those without
-    assert.equal(element._getNewProjectIndex('project c', 'filter'), 4);
-  });
-
-  test('_handleAddProject', () => {
-    element.$.newProject.value = {id: 'project d'};
-    element.$.newProject.setText('project d');
-    element.$.newFilter.bindValue = '';
-
-    element._handleAddProject();
-
-    assert.equal(element._projects.length, 5);
-    assert.equal(element._projects[4].project, 'project d');
-    assert.isNotOk(element._projects[4].filter);
-    assert.isTrue(element._projects[4]._is_local);
-  });
-
-  test('_handleAddProject with invalid inputs', () => {
-    element.$.newProject.value = {id: 'project b'};
-    element.$.newProject.setText('project b');
-    element.$.newFilter.bindValue = 'filter 1';
-    element.$.newFilter.value = 'filter 1';
-
-    element._handleAddProject();
-
-    assert.equal(element._projects.length, 4);
-  });
-
-  test('_handleRemoveProject', () => {
-    assert.equal(element._projectsToRemove, 0);
-    const button = element.shadowRoot
-        .querySelector('table tbody tr:nth-child(2) gr-button');
-    MockInteractions.tap(button);
-
-    flushAsynchronousOperations();
-
-    const rows = element.shadowRoot
-        .querySelector('table tbody').querySelectorAll('tr');
-    assert.equal(rows.length, 3);
-
-    assert.equal(element._projectsToRemove.length, 1);
-    assert.equal(element._projectsToRemove[0].project, 'project b');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
new file mode 100644
index 0000000..d42f579
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
@@ -0,0 +1,201 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-watched-projects-editor.js';
+
+const basicFixture = fixtureFromElement('gr-watched-projects-editor');
+
+suite('gr-watched-projects-editor tests', () => {
+  let element;
+
+  setup(done => {
+    const projects = [
+      {
+        project: 'project a',
+        notify_submitted_changes: true,
+        notify_abandoned_changes: true,
+      }, {
+        project: 'project b',
+        filter: 'filter 1',
+        notify_new_changes: true,
+      }, {
+        project: 'project b',
+        filter: 'filter 2',
+      }, {
+        project: 'project c',
+        notify_new_changes: true,
+        notify_new_patch_sets: true,
+        notify_all_comments: true,
+      },
+    ];
+
+    stub('gr-rest-api-interface', {
+      getSuggestedProjects(input) {
+        if (input.startsWith('th')) {
+          return Promise.resolve({'the project': {
+            id: 'the project',
+            state: 'ACTIVE',
+            web_links: [],
+          }});
+        } else {
+          return Promise.resolve({});
+        }
+      },
+      getWatchedProjects() {
+        return Promise.resolve(projects);
+      },
+    });
+
+    element = basicFixture.instantiate();
+
+    element.loadData().then(() => { flush(done); });
+  });
+
+  test('renders', () => {
+    const rows = element.shadowRoot
+        .querySelector('table').querySelectorAll('tbody tr');
+    assert.equal(rows.length, 4);
+
+    function getKeysOfRow(row) {
+      const boxes = rows[row].querySelectorAll('input[checked]');
+      return Array.prototype.map.call(boxes,
+          e => e.getAttribute('data-key'));
+    }
+
+    let checkedKeys = getKeysOfRow(0);
+    assert.equal(checkedKeys.length, 2);
+    assert.equal(checkedKeys[0], 'notify_submitted_changes');
+    assert.equal(checkedKeys[1], 'notify_abandoned_changes');
+
+    checkedKeys = getKeysOfRow(1);
+    assert.equal(checkedKeys.length, 1);
+    assert.equal(checkedKeys[0], 'notify_new_changes');
+
+    checkedKeys = getKeysOfRow(2);
+    assert.equal(checkedKeys.length, 0);
+
+    checkedKeys = getKeysOfRow(3);
+    assert.equal(checkedKeys.length, 3);
+    assert.equal(checkedKeys[0], 'notify_new_changes');
+    assert.equal(checkedKeys[1], 'notify_new_patch_sets');
+    assert.equal(checkedKeys[2], 'notify_all_comments');
+  });
+
+  test('_getProjectSuggestions empty', done => {
+    element._getProjectSuggestions('nonexistent').then(projects => {
+      assert.equal(projects.length, 0);
+      done();
+    });
+  });
+
+  test('_getProjectSuggestions non-empty', done => {
+    element._getProjectSuggestions('the project').then(projects => {
+      assert.equal(projects.length, 1);
+      assert.equal(projects[0].name, 'the project');
+      done();
+    });
+  });
+
+  test('_getProjectSuggestions non-empty with two letter project', done => {
+    element._getProjectSuggestions('th').then(projects => {
+      assert.equal(projects.length, 1);
+      assert.equal(projects[0].name, 'the project');
+      done();
+    });
+  });
+
+  test('_canAddProject', () => {
+    assert.isFalse(element._canAddProject(null, null, null));
+    assert.isFalse(element._canAddProject({}, null, null));
+
+    // Can add a project that is not in the list.
+    assert.isTrue(element._canAddProject({id: 'project d'}, null, null));
+    assert.isTrue(element._canAddProject({id: 'project d'}, null, 'filter 3'));
+
+    // Cannot add a project that is in the list with no filter.
+    assert.isFalse(element._canAddProject({id: 'project a'}, null, null));
+
+    // Can add a project that is in the list if the filter differs.
+    assert.isTrue(element._canAddProject({id: 'project a'}, null, 'filter 4'));
+
+    // Cannot add a project that is in the list with the same filter.
+    assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 1'));
+    assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 2'));
+
+    // Can add a project that is in the list using a new filter.
+    assert.isTrue(element._canAddProject({id: 'project b'}, null, 'filter 3'));
+
+    // Can add a project that is not added by the auto complete
+    assert.isTrue(element._canAddProject(null, 'test', null));
+  });
+
+  test('_getNewProjectIndex', () => {
+    // Projects are sorted in ASCII order.
+    assert.equal(element._getNewProjectIndex('project A', 'filter'), 0);
+    assert.equal(element._getNewProjectIndex('project a', 'filter'), 1);
+
+    // Projects are sorted by filter when the names are equal
+    assert.equal(element._getNewProjectIndex('project b', 'filter 0'), 1);
+    assert.equal(element._getNewProjectIndex('project b', 'filter 1.5'), 2);
+    assert.equal(element._getNewProjectIndex('project b', 'filter 3'), 3);
+
+    // Projects with filters follow those without
+    assert.equal(element._getNewProjectIndex('project c', 'filter'), 4);
+  });
+
+  test('_handleAddProject', () => {
+    element.$.newProject.value = {id: 'project d'};
+    element.$.newProject.setText('project d');
+    element.$.newFilter.bindValue = '';
+
+    element._handleAddProject();
+
+    assert.equal(element._projects.length, 5);
+    assert.equal(element._projects[4].project, 'project d');
+    assert.isNotOk(element._projects[4].filter);
+    assert.isTrue(element._projects[4]._is_local);
+  });
+
+  test('_handleAddProject with invalid inputs', () => {
+    element.$.newProject.value = {id: 'project b'};
+    element.$.newProject.setText('project b');
+    element.$.newFilter.bindValue = 'filter 1';
+    element.$.newFilter.value = 'filter 1';
+
+    element._handleAddProject();
+
+    assert.equal(element._projects.length, 4);
+  });
+
+  test('_handleRemoveProject', () => {
+    assert.equal(element._projectsToRemove, 0);
+    const button = element.shadowRoot
+        .querySelector('table tbody tr:nth-child(2) gr-button');
+    MockInteractions.tap(button);
+
+    flushAsynchronousOperations();
+
+    const rows = element.shadowRoot
+        .querySelector('table tbody').querySelectorAll('tr');
+    assert.equal(rows.length, 3);
+
+    assert.equal(element._projectsToRemove.length, 1);
+    assert.equal(element._projectsToRemove[0].project, 'project b');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index 6ceee26..0b7943d 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../gr-account-link/gr-account-link.js';
 import '../gr-button/gr-button.js';
 import '../gr-icons/gr-icons.js';
@@ -27,7 +25,7 @@
 import {htmlTemplate} from './gr-account-chip_html.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrAccountChip extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -50,6 +48,12 @@
   static get properties() {
     return {
       account: Object,
+      /**
+       * Optional ChangeInfo object, typically comes from the change page or
+       * from a row in a list of search results. This is needed for some change
+       * related features like adding the user as a reviewer.
+       */
+      change: Object,
       voteableText: String,
       disabled: {
         type: Boolean,
@@ -59,8 +63,14 @@
       removable: {
         type: Boolean,
         value: false,
+        reflectToAttribute: true,
       },
-      showAttention: {
+      /**
+       * Should attention set related features be shown in the component? Note
+       * that the information whether the user is in the attention set or not is
+       * part of the ChangeInfo object in the change property.
+       */
+      highlightAttention: {
         type: Boolean,
         value: false,
       },
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
deleted file mode 100644
index 96e0160..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.js
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      overflow: hidden;
-    }
-    .container {
-      align-items: center;
-      background: var(--chip-background-color);
-      border-radius: 0.75em;
-      display: inline-flex;
-      padding: 0 var(--spacing-m);
-    }
-    :host([show-avatar]) .container {
-      padding-left: 0;
-    }
-    gr-button.remove {
-      --gr-remove-button-style: {
-        border: 0;
-        color: var(--deemphasized-text-color);
-        font-weight: var(--font-weight-normal);
-        height: 0.6em;
-        line-height: 10px;
-        margin-left: var(--spacing-xs);
-        padding: 0;
-        text-decoration: none;
-      }
-    }
-
-    gr-button.remove:hover,
-    gr-button.remove:focus {
-      --gr-button: {
-        @apply --gr-remove-button-style;
-        color: #333;
-      }
-    }
-    gr-button.remove {
-      --gr-button: {
-        @apply --gr-remove-button-style;
-      }
-    }
-    :host:focus {
-      border-color: transparent;
-      box-shadow: none;
-      outline: none;
-    }
-    :host:focus .container,
-    :host:focus gr-button {
-      background: #ccc;
-    }
-    .transparentBackground,
-    gr-button.transparentBackground {
-      background-color: transparent;
-      padding: 0;
-    }
-    :host([disabled]) {
-      opacity: 0.6;
-      pointer-events: none;
-    }
-    iron-icon {
-      height: 1.2rem;
-      width: 1.2rem;
-    }
-  </style>
-  <div class$="container [[_getBackgroundClass(transparentBackground)]]">
-    <gr-account-link
-      account="[[account]]"
-      show-attention="[[showAttention]]"
-      voteable-text="[[voteableText]]"
-    >
-    </gr-account-link>
-    <gr-button
-      id="remove"
-      link=""
-      hidden$="[[!removable]]"
-      hidden=""
-      tabindex="-1"
-      aria-label="Remove"
-      class$="remove [[_getBackgroundClass(transparentBackground)]]"
-      on-click="_handleRemoveTap"
-    >
-      <iron-icon icon="gr-icons:close"></iron-icon>
-    </gr-button>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
new file mode 100644
index 0000000..957b79c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
@@ -0,0 +1,112 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      overflow: hidden;
+    }
+    .container {
+      align-items: center;
+      background-color: var(--background-color-primary);
+      /** round */
+      border-radius: var(--account-chip-border-radius, 20px);
+      border: 1px solid var(--border-color);
+      display: inline-flex;
+      padding: 0 var(--spacing-l);
+
+      --gr-account-label-text-style: {
+        color: var(--deemphasized-text-color);
+      }
+    }
+    :host([show-avatar]) .container {
+      padding-left: var(--spacing-xs);
+    }
+    :host([removable]) .container {
+      padding-right: calc(1.5 * var(--spacing-s));
+    }
+    gr-button.remove {
+      --gr-remove-button-style: {
+        border: 0;
+        color: var(--deemphasized-text-color);
+        font-weight: var(--font-weight-normal);
+        height: 0.6em;
+        line-height: 10px;
+        margin-left: var(--spacing-xs);
+        padding: 0;
+        text-decoration: none;
+      }
+    }
+
+    gr-button.remove:hover,
+    gr-button.remove:focus {
+      --gr-button: {
+        @apply --gr-remove-button-style;
+        color: #333;
+      }
+    }
+    gr-button.remove {
+      --gr-button: {
+        @apply --gr-remove-button-style;
+      }
+    }
+    :host:focus {
+      border-color: transparent;
+      box-shadow: none;
+      outline: none;
+    }
+    :host:focus .container,
+    :host:focus gr-button {
+      background: #ccc;
+    }
+    .transparentBackground,
+    gr-button.transparentBackground {
+      background-color: transparent;
+    }
+    :host([disabled]) {
+      opacity: 0.6;
+      pointer-events: none;
+    }
+    iron-icon {
+      height: 1.2rem;
+      width: 1.2rem;
+    }
+  </style>
+  <div class$="container [[_getBackgroundClass(transparentBackground)]]">
+    <gr-account-link
+      account="[[account]]"
+      change="[[change]]"
+      highlight-attention="[[highlightAttention]]"
+      voteable-text="[[voteableText]]"
+    >
+    </gr-account-link>
+    <gr-button
+      id="remove"
+      link=""
+      hidden$="[[!removable]]"
+      hidden=""
+      aria-label="Remove"
+      class$="remove [[_getBackgroundClass(transparentBackground)]]"
+      on-click="_handleRemoveTap"
+    >
+      <iron-icon icon="gr-icons:close"></iron-icon>
+    </gr-button>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
index c991a37..807aa81 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../gr-autocomplete/gr-autocomplete.js';
 import '../gr-rest-api-interface/gr-rest-api-interface.js';
@@ -28,7 +26,7 @@
  * gr-account-entry is an element for entering account
  * and/or group with autocomplete support.
  *
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrAccountEntry extends GestureEventListeners(
     LegacyElementMixin(
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.js
deleted file mode 100644
index afd427a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-autocomplete {
-      display: inline-block;
-      flex: 1;
-      overflow: hidden;
-    }
-  </style>
-  <gr-autocomplete
-    id="input"
-    borderless="[[borderless]]"
-    placeholder="[[placeholder]]"
-    threshold="[[suggestFrom]]"
-    query="[[querySuggestions]]"
-    allow-non-suggested-values="[[allowAnyInput]]"
-    on-commit="_handleInputCommit"
-    clear-on-commit=""
-    warn-uncommitted=""
-    text="{{_inputText}}"
-    vertical-offset="24"
-  >
-  </gr-autocomplete>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
new file mode 100644
index 0000000..c6c2b7f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    gr-autocomplete {
+      display: inline-block;
+      flex: 1;
+      overflow: hidden;
+    }
+  </style>
+  <gr-autocomplete
+    id="input"
+    borderless="[[borderless]]"
+    placeholder="[[placeholder]]"
+    threshold="[[suggestFrom]]"
+    query="[[querySuggestions]]"
+    allow-non-suggested-values="[[allowAnyInput]]"
+    on-commit="_handleInputCommit"
+    clear-on-commit=""
+    warn-uncommitted=""
+    text="{{_inputText}}"
+    vertical-offset="24"
+  >
+  </gr-autocomplete>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html
deleted file mode 100644
index 5899ad4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.html
+++ /dev/null
@@ -1,109 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-account-entry</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-account-entry></gr-account-entry>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-account-entry.js';
-suite('gr-account-entry tests', () => {
-  let sandbox;
-  let element;
-
-  const suggestion1 = {
-    email: 'email1@example.com',
-    _account_id: 1,
-    some_property: 'value',
-  };
-  const suggestion2 = {
-    email: 'email2@example.com',
-    _account_id: 2,
-  };
-  const suggestion3 = {
-    email: 'email25@example.com',
-    _account_id: 25,
-    some_other_property: 'other value',
-  };
-
-  setup(done => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    return flush(done);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('stubbed values for querySuggestions', () => {
-    setup(() => {
-      element.querySuggestions = input => Promise.resolve([
-        suggestion1,
-        suggestion2,
-        suggestion3,
-      ]);
-    });
-  });
-
-  test('account-text-changed fired when input text changed and allowAnyInput',
-      () => {
-        // Spy on query, as that is called when _updateSuggestions proceeds.
-        const changeStub = sandbox.stub();
-        element.allowAnyInput = true;
-        element.querySuggestions = input => Promise.resolve([]);
-        element.addEventListener('account-text-changed', changeStub);
-        element.$.input.text = 'a';
-        assert.isTrue(changeStub.calledOnce);
-        element.$.input.text = 'ab';
-        assert.isTrue(changeStub.calledTwice);
-      });
-
-  test('account-text-changed not fired when input text changed without ' +
-      'allowAnyInput', () => {
-    // Spy on query, as that is called when _updateSuggestions proceeds.
-    const changeStub = sandbox.stub();
-    element.querySuggestions = input => Promise.resolve([]);
-    element.addEventListener('account-text-changed', changeStub);
-    element.$.input.text = 'a';
-    assert.isFalse(changeStub.called);
-  });
-
-  test('setText', () => {
-    // Spy on query, as that is called when _updateSuggestions proceeds.
-    const suggestSpy = sandbox.spy(element.$.input, 'query');
-    element.setText('test text');
-    flushAsynchronousOperations();
-
-    assert.equal(element.$.input.$.input.value, 'test text');
-    assert.isFalse(suggestSpy.called);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js
new file mode 100644
index 0000000..396145b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-account-entry.js';
+
+const basicFixture = fixtureFromElement('gr-account-entry');
+
+suite('gr-account-entry tests', () => {
+  let element;
+
+  const suggestion1 = {
+    email: 'email1@example.com',
+    _account_id: 1,
+    some_property: 'value',
+  };
+  const suggestion2 = {
+    email: 'email2@example.com',
+    _account_id: 2,
+  };
+  const suggestion3 = {
+    email: 'email25@example.com',
+    _account_id: 25,
+    some_other_property: 'other value',
+  };
+
+  setup(done => {
+    element = basicFixture.instantiate();
+
+    return flush(done);
+  });
+
+  suite('stubbed values for querySuggestions', () => {
+    setup(() => {
+      element.querySuggestions = input => Promise.resolve([
+        suggestion1,
+        suggestion2,
+        suggestion3,
+      ]);
+    });
+  });
+
+  test('account-text-changed fired when input text changed and allowAnyInput',
+      () => {
+        // Spy on query, as that is called when _updateSuggestions proceeds.
+        const changeStub = sinon.stub();
+        element.allowAnyInput = true;
+        element.querySuggestions = input => Promise.resolve([]);
+        element.addEventListener('account-text-changed', changeStub);
+        element.$.input.text = 'a';
+        assert.isTrue(changeStub.calledOnce);
+        element.$.input.text = 'ab';
+        assert.isTrue(changeStub.calledTwice);
+      });
+
+  test('account-text-changed not fired when input text changed without ' +
+      'allowAnyInput', () => {
+    // Spy on query, as that is called when _updateSuggestions proceeds.
+    const changeStub = sinon.stub();
+    element.querySuggestions = input => Promise.resolve([]);
+    element.addEventListener('account-text-changed', changeStub);
+    element.$.input.text = 'a';
+    assert.isFalse(changeStub.called);
+  });
+
+  test('setText', () => {
+    // Spy on query, as that is called when _updateSuggestions proceeds.
+    const suggestSpy = sinon.spy(element.$.input, 'query');
+    element.setText('test text');
+    flushAsynchronousOperations();
+
+    assert.equal(element.$.input.$.input.value, 'test text');
+    assert.isFalse(suggestSpy.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
index 110d884..bfdd269 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -14,28 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-icon/iron-icon.js';
 import '../../../styles/shared-styles.js';
 import '../gr-avatar/gr-avatar.js';
 import '../gr-hovercard-account/gr-hovercard-account.js';
 import '../gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-account-label_html.js';
-import {DisplayNameBehavior} from '../../../behaviors/gr-display-name-behavior/gr-display-name-behavior.js';
+import {getDisplayName} from '../../../utils/display-name-util.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrAccountLabel extends mixinBehaviors( [
-  DisplayNameBehavior,
-], GestureEventListeners(
+class GrAccountLabel extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-account-label'; }
@@ -46,8 +41,32 @@
        * @type {{ name: string, status: string }}
        */
       account: Object,
+      /**
+       * Optional ChangeInfo object, typically comes from the change page or
+       * from a row in a list of search results. This is needed for some change
+       * related features like adding the user as a reviewer.
+       */
+      change: Object,
       voteableText: String,
-      showAttention: {
+      /**
+       * Should this user be considered to be in the attention set, regardless
+       * of the current state of the change object? This can be used in a widget
+       * that allows the user to make adjustments to the attention set.
+       */
+      forceAttention: {
+        type: Boolean,
+        value: false,
+      },
+      /**
+       * Should attention set related features be shown in the component? Note
+       * that the information whether the user is in the attention set or not is
+       * part of the ChangeInfo object in the change property.
+       */
+      highlightAttention: {
+        type: Boolean,
+        value: false,
+      },
+      hideHovercard: {
         type: Boolean,
         value: false,
       },
@@ -59,7 +78,10 @@
         type: Boolean,
         value: false,
       },
-      _serverConfig: {
+      /**
+       * This is a ServerInfo response object.
+       */
+      _config: {
         type: Object,
         value: null,
       },
@@ -69,12 +91,24 @@
   /** @override */
   ready() {
     super.ready();
-    this.$.restAPI.getConfig()
-        .then(config => { this._serverConfig = config; });
+    this.$.restAPI.getConfig().then(config => { this._config = config; });
+  }
+
+  _isAttentionSetEnabled(config, highlight, account, change) {
+    return !!config && !!config.change
+        && !!config.change.enable_attention_set
+        && !!highlight && !!change && !!account;
+  }
+
+  _hasAttention(config, highlight, account, change, force) {
+    if (force) return true;
+    return this._isAttentionSetEnabled(config, highlight, account, change)
+        && change.attention_set
+        && change.attention_set.hasOwnProperty(account._account_id);
   }
 
   _computeName(account, config) {
-    return this.getDisplayName(config, account);
+    return getDisplayName(config, account);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js
deleted file mode 100644
index ba2d9cb..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.js
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: inline;
-      position: relative;
-    }
-    :host::after {
-      content: var(--account-label-suffix);
-    }
-    :host(:not([blurred])) .overlay {
-      display: none;
-    }
-    .overlay {
-      position: absolute;
-      pointer-events: none;
-      height: var(--line-height-normal);
-      right: 0;
-      left: 0;
-      background-color: var(--background-color-primary);
-      opacity: 0.5;
-    }
-    gr-avatar {
-      height: var(--line-height-normal);
-      width: var(--line-height-normal);
-      vertical-align: top;
-    }
-    .text {
-      @apply --gr-account-label-text-style;
-    }
-    .text:hover {
-      @apply --gr-account-label-text-hover-style;
-    }
-    iron-icon.attention {
-      vertical-align: top;
-    }
-    iron-icon.status {
-      width: 14px;
-      height: 14px;
-      vertical-align: top;
-      position: relative;
-      top: 2px;
-    }
-  </style>
-  <div class="overlay"></div>
-  <span>
-    <gr-hovercard-account
-      attention="[[showAttention]]"
-      account="[[account]]"
-      voteable-text="[[voteableText]]"
-    >
-    </gr-hovercard-account>
-    <template is="dom-if" if="[[showAttention]]">
-      <iron-icon class="attention" icon="gr-icons:attention"></iron-icon
-      ><!--
-   --></template
-    ><!--
-   --><template is="dom-if" if="[[!hideAvatar]]"
-      ><!--
-     --><gr-avatar account="[[account]]" image-size="32"></gr-avatar>
-    </template>
-    <span class="text">
-      <span class="name"> [[_computeName(account, _serverConfig)]]</span>
-      <template is="dom-if" if="[[!hideStatus]]">
-        <template is="dom-if" if="[[account.status]]">
-          <iron-icon class="status" icon="gr-icons:calendar"></iron-icon>
-        </template>
-      </template>
-    </span>
-  </span>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
new file mode 100644
index 0000000..e76185a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
@@ -0,0 +1,108 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: inline-block;
+      vertical-align: top;
+      position: relative;
+      border-radius: var(--label-border-radius);
+      /* Setting this really high, so all the following rules don't change
+           anything, only if --account-max-length is actually set to something
+           smaller like 20ch. */
+      max-width: var(--account-max-length, 500px);
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+    :host::after {
+      content: var(--account-label-suffix);
+    }
+    :host([deselected]) {
+      background-color: var(--background-color-primary);
+      border: 1px solid var(--comment-separator-color);
+      border-radius: 8px;
+      color: var(--deemphasized-text-color);
+    }
+    :host([selected]) {
+      background-color: var(--chip-selected-background-color);
+      border: 1px solid var(--chip-selected-background-color);
+      border-radius: 8px;
+      color: var(--chip-selected-text-color);
+    }
+    :host([selected]) iron-icon.attention {
+      color: var(--chip-selected-text-color);
+    }
+    gr-avatar {
+      height: calc(var(--line-height-normal) - 2px);
+      width: calc(var(--line-height-normal) - 2px);
+      vertical-align: sub;
+    }
+    .text {
+      @apply --gr-account-label-text-style;
+    }
+    .text:hover {
+      @apply --gr-account-label-text-hover-style;
+    }
+    iron-icon.attention {
+      width: 12px;
+      height: 12px;
+      vertical-align: top;
+      position: relative;
+      top: 4px;
+      padding-left: calc(1.5 * var(--spacing-s));
+    }
+    iron-icon.status {
+      width: 14px;
+      height: 14px;
+      vertical-align: top;
+      position: relative;
+      top: 2px;
+    }
+  </style>
+  <span>
+    <template is="dom-if" if="[[!hideHovercard]]">
+      <gr-hovercard-account
+        account="[[account]]"
+        change="[[change]]"
+        highlight-attention="[[highlightAttention]]"
+        voteable-text="[[voteableText]]"
+      >
+      </gr-hovercard-account>
+    </template>
+    <template
+      is="dom-if"
+      if="[[_hasAttention(_config, highlightAttention, account, change, forceAttention)]]"
+    >
+      <iron-icon class="attention" icon="gr-icons:attention"></iron-icon>
+    </template>
+    <template is="dom-if" if="[[!hideAvatar]]">
+      <gr-avatar account="[[account]]" image-size="32"></gr-avatar>
+    </template>
+    <span class="text">
+      <span class="name">[[_computeName(account, _config)]]</span>
+      <template is="dom-if" if="[[!hideStatus]]">
+        <template is="dom-if" if="[[account.status]]">
+          <iron-icon class="status" icon="gr-icons:calendar"></iron-icon>
+        </template>
+      </template>
+    </span>
+  </span>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
deleted file mode 100644
index 4cc66c4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ /dev/null
@@ -1,94 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-account-label</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-account-label></gr-account-label>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-account-label.js';
-suite('gr-account-label tests', () => {
-  let element;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getLoggedIn() { return Promise.resolve(false); },
-    });
-    element = fixture('basic');
-    element._config = {
-      user: {
-        anonymous_coward_name: 'Anonymous Coward',
-      },
-    };
-  });
-
-  test('null guard', () => {
-    assert.doesNotThrow(() => {
-      element.account = null;
-    });
-  });
-
-  suite('_computeName', () => {
-    test('not showing anonymous', () => {
-      const account = {name: 'Wyatt'};
-      assert.deepEqual(element._computeName(account, null), 'Wyatt');
-    });
-
-    test('showing anonymous but no config', () => {
-      const account = {};
-      assert.deepEqual(element._computeName(account, null),
-          'Anonymous');
-    });
-
-    test('test for Anonymous Coward user and replace with Anonymous', () => {
-      const config = {
-        user: {
-          anonymous_coward_name: 'Anonymous Coward',
-        },
-      };
-      const account = {};
-      assert.deepEqual(element._computeName(account, config),
-          'Anonymous');
-    });
-
-    test('test for anonymous_coward_name', () => {
-      const config = {
-        user: {
-          anonymous_coward_name: 'TestAnon',
-        },
-      };
-      const account = {};
-      assert.deepEqual(element._computeName(account, config),
-          'TestAnon');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
new file mode 100644
index 0000000..94274a7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
@@ -0,0 +1,80 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-account-label.js';
+
+const basicFixture = fixtureFromElement('gr-account-label');
+
+suite('gr-account-label tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getLoggedIn() { return Promise.resolve(false); },
+    });
+    element = basicFixture.instantiate();
+    element._config = {
+      user: {
+        anonymous_coward_name: 'Anonymous Coward',
+      },
+    };
+  });
+
+  test('null guard', () => {
+    assert.doesNotThrow(() => {
+      element.account = null;
+    });
+  });
+
+  suite('_computeName', () => {
+    test('not showing anonymous', () => {
+      const account = {name: 'Wyatt'};
+      assert.deepEqual(element._computeName(account, null), 'Wyatt');
+    });
+
+    test('showing anonymous but no config', () => {
+      const account = {};
+      assert.deepEqual(element._computeName(account, null),
+          'Anonymous');
+    });
+
+    test('test for Anonymous Coward user and replace with Anonymous', () => {
+      const config = {
+        user: {
+          anonymous_coward_name: 'Anonymous Coward',
+        },
+      };
+      const account = {};
+      assert.deepEqual(element._computeName(account, config),
+          'Anonymous');
+    });
+
+    test('test for anonymous_coward_name', () => {
+      const config = {
+        user: {
+          anonymous_coward_name: 'TestAnon',
+        },
+      };
+      const account = {};
+      assert.deepEqual(element._computeName(account, config),
+          'TestAnon');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
index 27de4b3..eff1953 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
@@ -15,25 +15,20 @@
  * limitations under the License.
  */
 
-import '../../../scripts/bundled-polymer.js';
 import '../gr-account-label/gr-account-label.js';
 import '../../../styles/shared-styles.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-account-link_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrAccountLink extends mixinBehaviors( [
-  BaseUrlBehavior,
-], GestureEventListeners(
+class GrAccountLink extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-account-link'; }
@@ -42,7 +37,18 @@
     return {
       voteableText: String,
       account: Object,
-      showAttention: {
+      /**
+       * Optional ChangeInfo object, typically comes from the change page or
+       * from a row in a list of search results. This is needed for some change
+       * related features like adding the user as a reviewer.
+       */
+      change: Object,
+      /**
+       * Should attention set related features be shown in the component? Note
+       * that the information whether the user is in the attention set or not is
+       * part of the ChangeInfo object in the change property.
+       */
+      highlightAttention: {
         type: Boolean,
         value: false,
       },
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
deleted file mode 100644
index 17e7f49..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: inline-block;
-      /* Setting this really high, so all the following rules don't change
-           anything, only if --account-max-length is actually set to something
-           smaller like 20ch. */
-      max-width: var(--account-max-length, 500px);
-      overflow: hidden;
-      text-overflow: ellipsis;
-      vertical-align: top;
-      white-space: nowrap;
-    }
-    a {
-      color: var(--primary-text-color);
-      text-decoration: none;
-    }
-    gr-account-label {
-      --gr-account-label-text-hover-style: {
-        text-decoration: underline;
-      }
-    }
-  </style>
-  <span>
-    <a href$="[[_computeOwnerLink(account)]]" tabindex="-1">
-      <gr-account-label
-        show-attention="[[showAttention]]"
-        hide-avatar="[[hideAvatar]]"
-        hide-status="[[hideStatus]]"
-        account="[[account]]"
-        voteable-text="[[voteableText]]"
-      >
-      </gr-account-label>
-    </a>
-  </span>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts
new file mode 100644
index 0000000..b9d0237
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts
@@ -0,0 +1,48 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: inline-block;
+      vertical-align: top;
+    }
+    a {
+      color: var(--primary-text-color);
+      text-decoration: none;
+    }
+    gr-account-label {
+      --gr-account-label-text-hover-style: {
+        text-decoration: underline;
+      }
+    }
+  </style>
+  <span>
+    <a href$="[[_computeOwnerLink(account)]]">
+      <gr-account-label
+        account="[[account]]"
+        change="[[change]]"
+        highlight-attention="[[highlightAttention]]"
+        hide-avatar="[[hideAvatar]]"
+        hide-status="[[hideStatus]]"
+        voteable-text="[[voteableText]]"
+      >
+      </gr-account-label>
+    </a>
+  </span>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
deleted file mode 100644
index f3bff6e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
+++ /dev/null
@@ -1,81 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-account-link</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-account-link></gr-account-link>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-account-link.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-account-link tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('computed fields', () => {
-    const url = 'test/url';
-    const urlStub = sandbox.stub(GerritNav, 'getUrlForOwner').returns(url);
-    const account = {
-      email: 'email',
-      username: 'username',
-      name: 'name',
-      _account_id: '_account_id',
-    };
-    assert.isNotOk(element._computeOwnerLink());
-    assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('email'));
-
-    delete account.email;
-    assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('username'));
-
-    delete account.username;
-    assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('name'));
-
-    delete account.name;
-    assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('_account_id'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
new file mode 100644
index 0000000..554953e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-account-link.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+
+const basicFixture = fixtureFromElement('gr-account-link');
+
+suite('gr-account-link tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('computed fields', () => {
+    const url = 'test/url';
+    const urlStub = sinon.stub(GerritNav, 'getUrlForOwner').returns(url);
+    const account = {
+      email: 'email',
+      username: 'username',
+      name: 'name',
+      _account_id: '_account_id',
+    };
+    assert.isNotOk(element._computeOwnerLink());
+    assert.equal(element._computeOwnerLink(account), url);
+    assert.isTrue(urlStub.lastCall.calledWithExactly('email'));
+
+    delete account.email;
+    assert.equal(element._computeOwnerLink(account), url);
+    assert.isTrue(urlStub.lastCall.calledWithExactly('username'));
+
+    delete account.username;
+    assert.equal(element._computeOwnerLink(account), url);
+    assert.isTrue(urlStub.lastCall.calledWithExactly('name'));
+
+    delete account.name;
+    assert.equal(element._computeOwnerLink(account), url);
+    assert.isTrue(urlStub.lastCall.calledWithExactly('_account_id'));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
index 73ccf7d..0cf9af2 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../gr-account-chip/gr-account-chip.js';
 import '../gr-account-entry/gr-account-entry.js';
 import '../../../styles/shared-styles.js';
@@ -24,11 +22,12 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-account-list_html.js';
+import {appContext} from '../../../services/app-context.js';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrAccountList extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
@@ -119,6 +118,11 @@
     };
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   /** @override */
   created() {
     super.created();
@@ -154,25 +158,28 @@
   }
 
   _handleAdd(e) {
-    this._addAccountItem(e.detail.value);
+    this.addAccountItem(e.detail.value);
   }
 
-  _addAccountItem(item) {
+  addAccountItem(item) {
     // Append new account or group to the accounts property. We add our own
     // internal properties to the account/group here, so we clone the object
     // to avoid cluttering up the shared change object.
+    let itemTypeAdded = 'unknown';
     if (item.account) {
       const account =
-          Object.assign({}, item.account, {_pendingAdd: true});
+          {...item.account, _pendingAdd: true};
       this.push('accounts', account);
+      itemTypeAdded = 'account';
     } else if (item.group) {
       if (item.confirm) {
         this.pendingConfirmation = item;
         return;
       }
-      const group = Object.assign({}, item.group,
-          {_pendingAdd: true, _group: true});
+      const group = {...item.group,
+        _pendingAdd: true, _group: true};
       this.push('accounts', group);
+      itemTypeAdded = 'group';
     } else if (this.allowAnyInput) {
       if (!item.includes('@')) {
         // Repopulate the input with what the user tried to enter and have
@@ -187,15 +194,18 @@
       } else {
         const account = {email: item, _pendingAdd: true};
         this.push('accounts', account);
+        itemTypeAdded = 'email';
       }
     }
+
+    this.reporting.reportInteraction(`Add to ${this.id}`, {itemTypeAdded});
     this.pendingConfirmation = null;
     return true;
   }
 
   confirmGroup(group) {
-    group = Object.assign(
-        {}, group, {confirmed: true, _pendingAdd: true, _group: true});
+    group = {
+      ...group, confirmed: true, _pendingAdd: true, _group: true};
     this.push('accounts', group);
     this.pendingConfirmation = null;
   }
@@ -238,11 +248,11 @@
 
   _handleRemove(e) {
     const toRemove = e.detail.account;
-    this._removeAccount(toRemove);
+    this.removeAccount(toRemove);
     this.$.entry.focus();
   }
 
-  _removeAccount(toRemove) {
+  removeAccount(toRemove) {
     if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
       return;
     }
@@ -256,6 +266,7 @@
       }
       if (matches) {
         this.splice('accounts', i, 1);
+        this.reporting.reportInteraction(`Remove from ${this.id}`);
         return;
       }
     }
@@ -275,7 +286,7 @@
     }
     switch (e.detail.keyCode) {
       case 8: // Backspace
-        this._removeAccount(this.accounts[this.accounts.length - 1]);
+        this.removeAccount(this.accounts[this.accounts.length - 1]);
         break;
       case 37: // Left arrow
         if (this.accountChips[this.accountChips.length - 1]) {
@@ -294,7 +305,7 @@
       case 13: // Enter
       case 32: // Spacebar
       case 46: // Delete
-        this._removeAccount(chip.account);
+        this.removeAccount(chip.account);
         // Splice from this array to avoid inconsistent ordering of
         // event handling.
         chips.splice(index, 1);
@@ -334,7 +345,7 @@
   submitEntryText() {
     const text = this.$.entry.getText();
     if (!text.length) { return true; }
-    const wasSubmitted = this._addAccountItem(text);
+    const wasSubmitted = this.addAccountItem(text);
     if (wasSubmitted) { this.$.entry.clear(); }
     return wasSubmitted;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.js
deleted file mode 100644
index 6fee9f3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.js
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-account-chip {
-      display: inline-block;
-      margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
-    }
-    gr-account-entry {
-      display: flex;
-      flex: 1;
-      min-width: 10em;
-      margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
-    }
-    .group {
-      --account-label-suffix: ' (group)';
-    }
-    .pending-add {
-      font-style: italic;
-    }
-    .list {
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-      @apply --account-list-style;
-    }
-  </style>
-  <!--
-      NOTE(Issue 6419): Nest the inner dom-repeat template in a div rather than
-      as a direct child of the dom-module's template.
-    -->
-  <div class="list">
-    <template id="chips" is="dom-repeat" items="[[accounts]]" as="account">
-      <gr-account-chip
-        account="[[account]]"
-        class$="[[_computeChipClass(account)]]"
-        data-account-id$="[[account._account_id]]"
-        removable="[[_computeRemovable(account, readonly)]]"
-        on-keydown="_handleChipKeydown"
-        tabindex="-1"
-      >
-      </gr-account-chip>
-    </template>
-  </div>
-  <gr-account-entry
-    borderless=""
-    hidden$="[[_computeEntryHidden(maxCount, accounts.*, readonly)]]"
-    id="entry"
-    placeholder="[[placeholder]]"
-    on-add="_handleAdd"
-    on-input-keydown="_handleInputKeydown"
-    allow-any-input="[[allowAnyInput]]"
-    query-suggestions="[[_querySuggestions]]"
-  >
-  </gr-account-entry>
-  <slot></slot>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts
new file mode 100644
index 0000000..2824bb5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts
@@ -0,0 +1,73 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    gr-account-chip {
+      display: inline-block;
+      margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
+    }
+    gr-account-entry {
+      display: flex;
+      flex: 1;
+      min-width: 10em;
+      margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
+    }
+    .group {
+      --account-label-suffix: ' (group)';
+    }
+    .pending-add {
+      font-style: italic;
+    }
+    .list {
+      align-items: center;
+      display: flex;
+      flex-wrap: wrap;
+      @apply --account-list-style;
+    }
+  </style>
+  <!--
+      NOTE(Issue 6419): Nest the inner dom-repeat template in a div rather than
+      as a direct child of the dom-module's template.
+    -->
+  <div class="list">
+    <template id="chips" is="dom-repeat" items="[[accounts]]" as="account">
+      <gr-account-chip
+        account="[[account]]"
+        class$="[[_computeChipClass(account)]]"
+        data-account-id$="[[account._account_id]]"
+        removable="[[_computeRemovable(account, readonly)]]"
+        on-keydown="_handleChipKeydown"
+        tabindex="-1"
+      >
+      </gr-account-chip>
+    </template>
+  </div>
+  <gr-account-entry
+    borderless=""
+    hidden$="[[_computeEntryHidden(maxCount, accounts.*, readonly)]]"
+    id="entry"
+    placeholder="[[placeholder]]"
+    on-add="_handleAdd"
+    on-input-keydown="_handleInputKeydown"
+    allow-any-input="[[allowAnyInput]]"
+    query-suggestions="[[_querySuggestions]]"
+  >
+  </gr-account-entry>
+  <slot></slot>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
deleted file mode 100644
index b3b32606..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.html
+++ /dev/null
@@ -1,554 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-account-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-account-list></gr-account-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-account-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-class MockSuggestionsProvider {
-  getSuggestions(input) {
-    return Promise.resolve([]);
-  }
-
-  makeSuggestionItem(item) {
-    return item;
-  }
-}
-
-suite('gr-account-list tests', () => {
-  let _nextAccountId = 0;
-  const makeAccount = function() {
-    const accountId = ++_nextAccountId;
-    return {
-      _account_id: accountId,
-    };
-  };
-  const makeGroup = function() {
-    const groupId = 'group' + (++_nextAccountId);
-    return {
-      id: groupId,
-      _group: true,
-    };
-  };
-
-  let existingAccount1;
-  let existingAccount2;
-  let sandbox;
-  let element;
-  let suggestionsProvider;
-
-  function getChips() {
-    return dom(element.root).querySelectorAll('gr-account-chip');
-  }
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    existingAccount1 = makeAccount();
-    existingAccount2 = makeAccount();
-
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
-    element = fixture('basic');
-    element.accounts = [existingAccount1, existingAccount2];
-    suggestionsProvider = new MockSuggestionsProvider();
-    element.suggestionsProvider = suggestionsProvider;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('account entry only appears when editable', () => {
-    element.readonly = false;
-    assert.isFalse(element.$.entry.hasAttribute('hidden'));
-    element.readonly = true;
-    assert.isTrue(element.$.entry.hasAttribute('hidden'));
-  });
-
-  test('addition and removal of account/group chips', () => {
-    flushAsynchronousOperations();
-    sandbox.stub(element, '_computeRemovable').returns(true);
-    // Existing accounts are listed.
-    let chips = getChips();
-    assert.equal(chips.length, 2);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    assert.isFalse(chips[1].classList.contains('pendingAdd'));
-
-    // New accounts are added to end with pendingAdd class.
-    const newAccount = makeAccount();
-    element._handleAdd({
-      detail: {
-        value: {
-          account: newAccount,
-        },
-      },
-    });
-    flushAsynchronousOperations();
-    chips = getChips();
-    assert.equal(chips.length, 3);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    assert.isFalse(chips[1].classList.contains('pendingAdd'));
-    assert.isTrue(chips[2].classList.contains('pendingAdd'));
-
-    // Removed accounts are taken out of the list.
-    element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: existingAccount1},
-          composed: true, bubbles: true,
-        }));
-    flushAsynchronousOperations();
-    chips = getChips();
-    assert.equal(chips.length, 2);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    assert.isTrue(chips[1].classList.contains('pendingAdd'));
-
-    // Invalid remove is ignored.
-    element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: existingAccount1},
-          composed: true, bubbles: true,
-        }));
-    element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: newAccount},
-          composed: true, bubbles: true,
-        }));
-    flushAsynchronousOperations();
-    chips = getChips();
-    assert.equal(chips.length, 1);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-
-    // New groups are added to end with pendingAdd and group classes.
-    const newGroup = makeGroup();
-    element._handleAdd({
-      detail: {
-        value: {
-          group: newGroup,
-        },
-      },
-    });
-    flushAsynchronousOperations();
-    chips = getChips();
-    assert.equal(chips.length, 2);
-    assert.isTrue(chips[1].classList.contains('group'));
-    assert.isTrue(chips[1].classList.contains('pendingAdd'));
-
-    // Removed groups are taken out of the list.
-    element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: newGroup},
-          composed: true, bubbles: true,
-        }));
-    flushAsynchronousOperations();
-    chips = getChips();
-    assert.equal(chips.length, 1);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-  });
-
-  test('_getSuggestions uses filter correctly', done => {
-    const originalSuggestions = [
-      {
-        email: 'abc@example.com',
-        text: 'abcd',
-        _account_id: 3,
-      },
-      {
-        email: 'qwe@example.com',
-        text: 'qwer',
-        _account_id: 1,
-      },
-      {
-        email: 'xyz@example.com',
-        text: 'aaaaa',
-        _account_id: 25,
-      },
-    ];
-    sandbox.stub(suggestionsProvider, 'getSuggestions')
-        .returns(Promise.resolve(originalSuggestions));
-    sandbox.stub(suggestionsProvider, 'makeSuggestionItem', suggestion => {
-      return {
-        name: suggestion.email,
-        value: suggestion._account_id,
-      };
-    });
-
-    element._getSuggestions().then(suggestions => {
-      // Default is no filtering.
-      assert.equal(suggestions.length, 3);
-
-      // Set up filter that only accepts suggestion1.
-      const accountId = originalSuggestions[0]._account_id;
-      element.filter = function(suggestion) {
-        return suggestion._account_id === accountId;
-      };
-
-      element._getSuggestions()
-          .then(suggestions => {
-            assert.deepEqual(suggestions,
-                [{name: originalSuggestions[0].email,
-                  value: originalSuggestions[0]._account_id}]);
-          })
-          .then(done);
-    });
-  });
-
-  test('_computeChipClass', () => {
-    const account = makeAccount();
-    assert.equal(element._computeChipClass(account), '');
-    account._pendingAdd = true;
-    assert.equal(element._computeChipClass(account), 'pendingAdd');
-    account._group = true;
-    assert.equal(element._computeChipClass(account), 'group pendingAdd');
-    account._pendingAdd = false;
-    assert.equal(element._computeChipClass(account), 'group');
-  });
-
-  test('_computeRemovable', () => {
-    const newAccount = makeAccount();
-    newAccount._pendingAdd = true;
-    element.readonly = false;
-    element.removableValues = [];
-    assert.isFalse(element._computeRemovable(existingAccount1, false));
-    assert.isTrue(element._computeRemovable(newAccount, false));
-
-    element.removableValues = [existingAccount1];
-    assert.isTrue(element._computeRemovable(existingAccount1, false));
-    assert.isTrue(element._computeRemovable(newAccount, false));
-    assert.isFalse(element._computeRemovable(existingAccount2, false));
-
-    element.readonly = true;
-    assert.isFalse(element._computeRemovable(existingAccount1, true));
-    assert.isFalse(element._computeRemovable(newAccount, true));
-  });
-
-  test('submitEntryText', () => {
-    element.allowAnyInput = true;
-    flushAsynchronousOperations();
-
-    const getTextStub = sandbox.stub(element.$.entry, 'getText');
-    getTextStub.onFirstCall().returns('');
-    getTextStub.onSecondCall().returns('test');
-    getTextStub.onThirdCall().returns('test@test');
-
-    // When entry is empty, return true.
-    const clearStub = sandbox.stub(element.$.entry, 'clear');
-    assert.isTrue(element.submitEntryText());
-    assert.isFalse(clearStub.called);
-
-    // When entry is invalid, return false.
-    assert.isFalse(element.submitEntryText());
-    assert.isFalse(clearStub.called);
-
-    // When entry is valid, return true and clear text.
-    assert.isTrue(element.submitEntryText());
-    assert.isTrue(clearStub.called);
-    assert.equal(element.additions()[0].account.email, 'test@test');
-  });
-
-  test('additions returns sanitized new accounts and groups', () => {
-    assert.equal(element.additions().length, 0);
-
-    const newAccount = makeAccount();
-    element._handleAdd({
-      detail: {
-        value: {
-          account: newAccount,
-        },
-      },
-    });
-    const newGroup = makeGroup();
-    element._handleAdd({
-      detail: {
-        value: {
-          group: newGroup,
-        },
-      },
-    });
-
-    assert.deepEqual(element.additions(), [
-      {
-        account: {
-          _account_id: newAccount._account_id,
-          _pendingAdd: true,
-        },
-      },
-      {
-        group: {
-          id: newGroup.id,
-          _group: true,
-          _pendingAdd: true,
-        },
-      },
-    ]);
-  });
-
-  test('large group confirmations', () => {
-    assert.isNull(element.pendingConfirmation);
-    assert.deepEqual(element.additions(), []);
-
-    const group = makeGroup();
-    const reviewer = {
-      group,
-      count: 10,
-      confirm: true,
-    };
-    element._handleAdd({
-      detail: {
-        value: reviewer,
-      },
-    });
-
-    assert.deepEqual(element.pendingConfirmation, reviewer);
-    assert.deepEqual(element.additions(), []);
-
-    element.confirmGroup(group);
-    assert.isNull(element.pendingConfirmation);
-    assert.deepEqual(element.additions(), [
-      {
-        group: {
-          id: group.id,
-          _group: true,
-          _pendingAdd: true,
-          confirmed: true,
-        },
-      },
-    ]);
-  });
-
-  test('removeAccount fails if account is not removable', () => {
-    element.readonly = true;
-    const acct = makeAccount();
-    element.accounts = [acct];
-    element._removeAccount(acct);
-    assert.equal(element.accounts.length, 1);
-  });
-
-  test('max-count', () => {
-    element.maxCount = 1;
-    const acct = makeAccount();
-    element._handleAdd({
-      detail: {
-        value: {
-          account: acct,
-        },
-      },
-    });
-    flushAsynchronousOperations();
-    assert.isTrue(element.$.entry.hasAttribute('hidden'));
-  });
-
-  test('enter text calls suggestions provider', done => {
-    const suggestions = [
-      {
-        email: 'abc@example.com',
-        text: 'abcd',
-      },
-      {
-        email: 'qwe@example.com',
-        text: 'qwer',
-      },
-    ];
-    const getSuggestionsStub =
-        sandbox.stub(suggestionsProvider, 'getSuggestions')
-            .returns(Promise.resolve(suggestions));
-
-    const makeSuggestionItemStub =
-        sandbox.stub(suggestionsProvider, 'makeSuggestionItem', item => item);
-
-    const input = element.$.entry.$.input;
-
-    input.text = 'newTest';
-    MockInteractions.focus(input.$.input);
-    input.noDebounce = true;
-    flushAsynchronousOperations();
-    flush(() => {
-      assert.isTrue(getSuggestionsStub.calledOnce);
-      assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
-      assert.equal(makeSuggestionItemStub.getCalls().length, 2);
-      done();
-    });
-  });
-
-  test('suggestion on empty', done => {
-    element.skipSuggestOnEmpty = false;
-    const suggestions = [
-      {
-        email: 'abc@example.com',
-        text: 'abcd',
-      },
-      {
-        email: 'qwe@example.com',
-        text: 'qwer',
-      },
-    ];
-    const getSuggestionsStub =
-        sandbox.stub(suggestionsProvider, 'getSuggestions')
-            .returns(Promise.resolve(suggestions));
-
-    const makeSuggestionItemStub =
-        sandbox.stub(suggestionsProvider, 'makeSuggestionItem', item => item);
-
-    const input = element.$.entry.$.input;
-
-    input.text = '';
-    MockInteractions.focus(input.$.input);
-    input.noDebounce = true;
-    flushAsynchronousOperations();
-    flush(() => {
-      assert.isTrue(getSuggestionsStub.calledOnce);
-      assert.equal(getSuggestionsStub.lastCall.args[0], '');
-      assert.equal(makeSuggestionItemStub.getCalls().length, 2);
-      done();
-    });
-  });
-
-  test('skip suggestion on empty', done => {
-    element.skipSuggestOnEmpty = true;
-    const getSuggestionsStub =
-        sandbox.stub(suggestionsProvider, 'getSuggestions')
-            .returns(Promise.resolve([]));
-
-    const input = element.$.entry.$.input;
-
-    input.text = '';
-    MockInteractions.focus(input.$.input);
-    input.noDebounce = true;
-    flushAsynchronousOperations();
-    flush(() => {
-      assert.isTrue(getSuggestionsStub.notCalled);
-      done();
-    });
-  });
-
-  suite('allowAnyInput', () => {
-    setup(() => {
-      element.allowAnyInput = true;
-    });
-
-    test('adds emails', () => {
-      const accountLen = element.accounts.length;
-      element._handleAdd({detail: {value: 'test@test'}});
-      assert.equal(element.accounts.length, accountLen + 1);
-      assert.equal(element.accounts[accountLen].email, 'test@test');
-    });
-
-    test('toasts on invalid email', () => {
-      const toastHandler = sandbox.stub();
-      element.addEventListener('show-alert', toastHandler);
-      element._handleAdd({detail: {value: 'test'}});
-      assert.isTrue(toastHandler.called);
-    });
-  });
-
-  test('_accountMatches', () => {
-    const acct = makeAccount();
-
-    assert.isTrue(element._accountMatches(acct, acct));
-    acct.email = 'test';
-    assert.isTrue(element._accountMatches(acct, acct));
-    assert.isTrue(element._accountMatches({email: 'test'}, acct));
-
-    assert.isFalse(element._accountMatches({}, acct));
-    assert.isFalse(element._accountMatches({email: 'test2'}, acct));
-    assert.isFalse(element._accountMatches({_account_id: -1}, acct));
-  });
-
-  suite('keyboard interactions', () => {
-    test('backspace at text input start removes last account', done => {
-      const input = element.$.entry.$.input;
-      sandbox.stub(input, '_updateSuggestions');
-      sandbox.stub(element, '_computeRemovable').returns(true);
-      flush(() => {
-        // Next line is a workaround for Firefix not moving cursor
-        // on input field update
-        assert.equal(
-            element._getNativeInput(input.$.input).selectionStart, 0);
-        input.text = 'test';
-        MockInteractions.focus(input.$.input);
-        flushAsynchronousOperations();
-        assert.equal(element.accounts.length, 2);
-        MockInteractions.pressAndReleaseKeyOn(
-            element._getNativeInput(input.$.input), 8); // Backspace
-        assert.equal(element.accounts.length, 2);
-        input.text = '';
-        MockInteractions.pressAndReleaseKeyOn(
-            element._getNativeInput(input.$.input), 8); // Backspace
-        flushAsynchronousOperations();
-        assert.equal(element.accounts.length, 1);
-        done();
-      });
-    });
-
-    test('arrow key navigation', done => {
-      const input = element.$.entry.$.input;
-      input.text = '';
-      element.accounts = [makeAccount(), makeAccount()];
-      flush(() => {
-        MockInteractions.focus(input.$.input);
-        flushAsynchronousOperations();
-        const chips = element.accountChips;
-        const chipsOneSpy = sandbox.spy(chips[1], 'focus');
-        MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
-        assert.isTrue(chipsOneSpy.called);
-        const chipsZeroSpy = sandbox.spy(chips[0], 'focus');
-        MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
-        assert.isTrue(chipsZeroSpy.called);
-        MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
-        assert.isTrue(chipsZeroSpy.calledOnce);
-        MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
-        assert.isTrue(chipsOneSpy.calledTwice);
-        done();
-      });
-    });
-
-    test('delete', done => {
-      element.accounts = [makeAccount(), makeAccount()];
-      flush(() => {
-        const focusSpy = sandbox.spy(element.accountChips[1], 'focus');
-        const removeSpy = sandbox.spy(element, '_removeAccount');
-        MockInteractions.pressAndReleaseKeyOn(
-            element.accountChips[0], 8); // Backspace
-        assert.isTrue(focusSpy.called);
-        assert.isTrue(removeSpy.calledOnce);
-
-        MockInteractions.pressAndReleaseKeyOn(
-            element.accountChips[1], 46); // Delete
-        assert.isTrue(removeSpy.calledTwice);
-        done();
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
new file mode 100644
index 0000000..b26f468
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
@@ -0,0 +1,537 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-account-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-account-list');
+
+class MockSuggestionsProvider {
+  getSuggestions(input) {
+    return Promise.resolve([]);
+  }
+
+  makeSuggestionItem(item) {
+    return item;
+  }
+}
+
+suite('gr-account-list tests', () => {
+  let _nextAccountId = 0;
+  const makeAccount = function() {
+    const accountId = ++_nextAccountId;
+    return {
+      _account_id: accountId,
+    };
+  };
+  const makeGroup = function() {
+    const groupId = 'group' + (++_nextAccountId);
+    return {
+      id: groupId,
+      _group: true,
+    };
+  };
+
+  let existingAccount1;
+  let existingAccount2;
+
+  let element;
+  let suggestionsProvider;
+
+  function getChips() {
+    return dom(element.root).querySelectorAll('gr-account-chip');
+  }
+
+  setup(() => {
+    existingAccount1 = makeAccount();
+    existingAccount2 = makeAccount();
+
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+    element.accounts = [existingAccount1, existingAccount2];
+    suggestionsProvider = new MockSuggestionsProvider();
+    element.suggestionsProvider = suggestionsProvider;
+  });
+
+  test('account entry only appears when editable', () => {
+    element.readonly = false;
+    assert.isFalse(element.$.entry.hasAttribute('hidden'));
+    element.readonly = true;
+    assert.isTrue(element.$.entry.hasAttribute('hidden'));
+  });
+
+  test('addition and removal of account/group chips', () => {
+    flushAsynchronousOperations();
+    sinon.stub(element, '_computeRemovable').returns(true);
+    // Existing accounts are listed.
+    let chips = getChips();
+    assert.equal(chips.length, 2);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isFalse(chips[1].classList.contains('pendingAdd'));
+
+    // New accounts are added to end with pendingAdd class.
+    const newAccount = makeAccount();
+    element._handleAdd({
+      detail: {
+        value: {
+          account: newAccount,
+        },
+      },
+    });
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 3);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isFalse(chips[1].classList.contains('pendingAdd'));
+    assert.isTrue(chips[2].classList.contains('pendingAdd'));
+
+    // Removed accounts are taken out of the list.
+    element.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: existingAccount1},
+          composed: true, bubbles: true,
+        }));
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 2);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isTrue(chips[1].classList.contains('pendingAdd'));
+
+    // Invalid remove is ignored.
+    element.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: existingAccount1},
+          composed: true, bubbles: true,
+        }));
+    element.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: newAccount},
+          composed: true, bubbles: true,
+        }));
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 1);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+
+    // New groups are added to end with pendingAdd and group classes.
+    const newGroup = makeGroup();
+    element._handleAdd({
+      detail: {
+        value: {
+          group: newGroup,
+        },
+      },
+    });
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 2);
+    assert.isTrue(chips[1].classList.contains('group'));
+    assert.isTrue(chips[1].classList.contains('pendingAdd'));
+
+    // Removed groups are taken out of the list.
+    element.dispatchEvent(
+        new CustomEvent('remove', {
+          detail: {account: newGroup},
+          composed: true, bubbles: true,
+        }));
+    flushAsynchronousOperations();
+    chips = getChips();
+    assert.equal(chips.length, 1);
+    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+  });
+
+  test('_getSuggestions uses filter correctly', done => {
+    const originalSuggestions = [
+      {
+        email: 'abc@example.com',
+        text: 'abcd',
+        _account_id: 3,
+      },
+      {
+        email: 'qwe@example.com',
+        text: 'qwer',
+        _account_id: 1,
+      },
+      {
+        email: 'xyz@example.com',
+        text: 'aaaaa',
+        _account_id: 25,
+      },
+    ];
+    sinon.stub(suggestionsProvider, 'getSuggestions')
+        .returns(Promise.resolve(originalSuggestions));
+    sinon.stub(suggestionsProvider, 'makeSuggestionItem')
+        .callsFake( suggestion => {
+          return {
+            name: suggestion.email,
+            value: suggestion._account_id,
+          };
+        });
+
+    element._getSuggestions().then(suggestions => {
+      // Default is no filtering.
+      assert.equal(suggestions.length, 3);
+
+      // Set up filter that only accepts suggestion1.
+      const accountId = originalSuggestions[0]._account_id;
+      element.filter = function(suggestion) {
+        return suggestion._account_id === accountId;
+      };
+
+      element._getSuggestions()
+          .then(suggestions => {
+            assert.deepEqual(suggestions,
+                [{name: originalSuggestions[0].email,
+                  value: originalSuggestions[0]._account_id}]);
+          })
+          .then(done);
+    });
+  });
+
+  test('_computeChipClass', () => {
+    const account = makeAccount();
+    assert.equal(element._computeChipClass(account), '');
+    account._pendingAdd = true;
+    assert.equal(element._computeChipClass(account), 'pendingAdd');
+    account._group = true;
+    assert.equal(element._computeChipClass(account), 'group pendingAdd');
+    account._pendingAdd = false;
+    assert.equal(element._computeChipClass(account), 'group');
+  });
+
+  test('_computeRemovable', () => {
+    const newAccount = makeAccount();
+    newAccount._pendingAdd = true;
+    element.readonly = false;
+    element.removableValues = [];
+    assert.isFalse(element._computeRemovable(existingAccount1, false));
+    assert.isTrue(element._computeRemovable(newAccount, false));
+
+    element.removableValues = [existingAccount1];
+    assert.isTrue(element._computeRemovable(existingAccount1, false));
+    assert.isTrue(element._computeRemovable(newAccount, false));
+    assert.isFalse(element._computeRemovable(existingAccount2, false));
+
+    element.readonly = true;
+    assert.isFalse(element._computeRemovable(existingAccount1, true));
+    assert.isFalse(element._computeRemovable(newAccount, true));
+  });
+
+  test('submitEntryText', () => {
+    element.allowAnyInput = true;
+    flushAsynchronousOperations();
+
+    const getTextStub = sinon.stub(element.$.entry, 'getText');
+    getTextStub.onFirstCall().returns('');
+    getTextStub.onSecondCall().returns('test');
+    getTextStub.onThirdCall().returns('test@test');
+
+    // When entry is empty, return true.
+    const clearStub = sinon.stub(element.$.entry, 'clear');
+    assert.isTrue(element.submitEntryText());
+    assert.isFalse(clearStub.called);
+
+    // When entry is invalid, return false.
+    assert.isFalse(element.submitEntryText());
+    assert.isFalse(clearStub.called);
+
+    // When entry is valid, return true and clear text.
+    assert.isTrue(element.submitEntryText());
+    assert.isTrue(clearStub.called);
+    assert.equal(element.additions()[0].account.email, 'test@test');
+  });
+
+  test('additions returns sanitized new accounts and groups', () => {
+    assert.equal(element.additions().length, 0);
+
+    const newAccount = makeAccount();
+    element._handleAdd({
+      detail: {
+        value: {
+          account: newAccount,
+        },
+      },
+    });
+    const newGroup = makeGroup();
+    element._handleAdd({
+      detail: {
+        value: {
+          group: newGroup,
+        },
+      },
+    });
+
+    assert.deepEqual(element.additions(), [
+      {
+        account: {
+          _account_id: newAccount._account_id,
+          _pendingAdd: true,
+        },
+      },
+      {
+        group: {
+          id: newGroup.id,
+          _group: true,
+          _pendingAdd: true,
+        },
+      },
+    ]);
+  });
+
+  test('large group confirmations', () => {
+    assert.isNull(element.pendingConfirmation);
+    assert.deepEqual(element.additions(), []);
+
+    const group = makeGroup();
+    const reviewer = {
+      group,
+      count: 10,
+      confirm: true,
+    };
+    element._handleAdd({
+      detail: {
+        value: reviewer,
+      },
+    });
+
+    assert.deepEqual(element.pendingConfirmation, reviewer);
+    assert.deepEqual(element.additions(), []);
+
+    element.confirmGroup(group);
+    assert.isNull(element.pendingConfirmation);
+    assert.deepEqual(element.additions(), [
+      {
+        group: {
+          id: group.id,
+          _group: true,
+          _pendingAdd: true,
+          confirmed: true,
+        },
+      },
+    ]);
+  });
+
+  test('removeAccount fails if account is not removable', () => {
+    element.readonly = true;
+    const acct = makeAccount();
+    element.accounts = [acct];
+    element.removeAccount(acct);
+    assert.equal(element.accounts.length, 1);
+  });
+
+  test('max-count', () => {
+    element.maxCount = 1;
+    const acct = makeAccount();
+    element._handleAdd({
+      detail: {
+        value: {
+          account: acct,
+        },
+      },
+    });
+    flushAsynchronousOperations();
+    assert.isTrue(element.$.entry.hasAttribute('hidden'));
+  });
+
+  test('enter text calls suggestions provider', done => {
+    const suggestions = [
+      {
+        email: 'abc@example.com',
+        text: 'abcd',
+      },
+      {
+        email: 'qwe@example.com',
+        text: 'qwer',
+      },
+    ];
+    const getSuggestionsStub =
+        sinon.stub(suggestionsProvider, 'getSuggestions')
+            .returns(Promise.resolve(suggestions));
+
+    const makeSuggestionItemStub =
+        sinon.stub(suggestionsProvider, 'makeSuggestionItem')
+            .callsFake( item => item);
+
+    const input = element.$.entry.$.input;
+
+    input.text = 'newTest';
+    MockInteractions.focus(input.$.input);
+    input.noDebounce = true;
+    flushAsynchronousOperations();
+    flush(() => {
+      assert.isTrue(getSuggestionsStub.calledOnce);
+      assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
+      assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+      done();
+    });
+  });
+
+  test('suggestion on empty', done => {
+    element.skipSuggestOnEmpty = false;
+    const suggestions = [
+      {
+        email: 'abc@example.com',
+        text: 'abcd',
+      },
+      {
+        email: 'qwe@example.com',
+        text: 'qwer',
+      },
+    ];
+    const getSuggestionsStub =
+        sinon.stub(suggestionsProvider, 'getSuggestions')
+            .returns(Promise.resolve(suggestions));
+
+    const makeSuggestionItemStub =
+        sinon.stub(suggestionsProvider, 'makeSuggestionItem')
+            .callsFake( item => item);
+
+    const input = element.$.entry.$.input;
+
+    input.text = '';
+    MockInteractions.focus(input.$.input);
+    input.noDebounce = true;
+    flushAsynchronousOperations();
+    flush(() => {
+      assert.isTrue(getSuggestionsStub.calledOnce);
+      assert.equal(getSuggestionsStub.lastCall.args[0], '');
+      assert.equal(makeSuggestionItemStub.getCalls().length, 2);
+      done();
+    });
+  });
+
+  test('skip suggestion on empty', done => {
+    element.skipSuggestOnEmpty = true;
+    const getSuggestionsStub =
+        sinon.stub(suggestionsProvider, 'getSuggestions')
+            .returns(Promise.resolve([]));
+
+    const input = element.$.entry.$.input;
+
+    input.text = '';
+    MockInteractions.focus(input.$.input);
+    input.noDebounce = true;
+    flushAsynchronousOperations();
+    flush(() => {
+      assert.isTrue(getSuggestionsStub.notCalled);
+      done();
+    });
+  });
+
+  suite('allowAnyInput', () => {
+    setup(() => {
+      element.allowAnyInput = true;
+    });
+
+    test('adds emails', () => {
+      const accountLen = element.accounts.length;
+      element._handleAdd({detail: {value: 'test@test'}});
+      assert.equal(element.accounts.length, accountLen + 1);
+      assert.equal(element.accounts[accountLen].email, 'test@test');
+    });
+
+    test('toasts on invalid email', () => {
+      const toastHandler = sinon.stub();
+      element.addEventListener('show-alert', toastHandler);
+      element._handleAdd({detail: {value: 'test'}});
+      assert.isTrue(toastHandler.called);
+    });
+  });
+
+  test('_accountMatches', () => {
+    const acct = makeAccount();
+
+    assert.isTrue(element._accountMatches(acct, acct));
+    acct.email = 'test';
+    assert.isTrue(element._accountMatches(acct, acct));
+    assert.isTrue(element._accountMatches({email: 'test'}, acct));
+
+    assert.isFalse(element._accountMatches({}, acct));
+    assert.isFalse(element._accountMatches({email: 'test2'}, acct));
+    assert.isFalse(element._accountMatches({_account_id: -1}, acct));
+  });
+
+  suite('keyboard interactions', () => {
+    test('backspace at text input start removes last account', done => {
+      const input = element.$.entry.$.input;
+      sinon.stub(input, '_updateSuggestions');
+      sinon.stub(element, '_computeRemovable').returns(true);
+      flush(() => {
+        // Next line is a workaround for Firefox not moving cursor
+        // on input field update
+        assert.equal(
+            element._getNativeInput(input.$.input).selectionStart, 0);
+        input.text = 'test';
+        MockInteractions.focus(input.$.input);
+        flushAsynchronousOperations();
+        assert.equal(element.accounts.length, 2);
+        MockInteractions.pressAndReleaseKeyOn(
+            element._getNativeInput(input.$.input), 8); // Backspace
+        assert.equal(element.accounts.length, 2);
+        input.text = '';
+        MockInteractions.pressAndReleaseKeyOn(
+            element._getNativeInput(input.$.input), 8); // Backspace
+        flushAsynchronousOperations();
+        assert.equal(element.accounts.length, 1);
+        done();
+      });
+    });
+
+    test('arrow key navigation', done => {
+      const input = element.$.entry.$.input;
+      input.text = '';
+      element.accounts = [makeAccount(), makeAccount()];
+      flush(() => {
+        MockInteractions.focus(input.$.input);
+        flushAsynchronousOperations();
+        const chips = element.accountChips;
+        const chipsOneSpy = sinon.spy(chips[1], 'focus');
+        MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
+        assert.isTrue(chipsOneSpy.called);
+        const chipsZeroSpy = sinon.spy(chips[0], 'focus');
+        MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
+        assert.isTrue(chipsZeroSpy.called);
+        MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
+        assert.isTrue(chipsZeroSpy.calledOnce);
+        MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
+        assert.isTrue(chipsOneSpy.calledTwice);
+        done();
+      });
+    });
+
+    test('delete', done => {
+      element.accounts = [makeAccount(), makeAccount()];
+      flush(() => {
+        const focusSpy = sinon.spy(element.accountChips[1], 'focus');
+        const removeSpy = sinon.spy(element, 'removeAccount');
+        MockInteractions.pressAndReleaseKeyOn(
+            element.accountChips[0], 8); // Backspace
+        assert.isTrue(focusSpy.called);
+        assert.isTrue(removeSpy.calledOnce);
+
+        MockInteractions.pressAndReleaseKeyOn(
+            element.accountChips[1], 46); // Delete
+        assert.isTrue(removeSpy.calledTwice);
+        done();
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
index 1ec453e..d3a6fad 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../gr-button/gr-button.js';
 import '../../../styles/shared-styles.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
@@ -24,7 +22,7 @@
 import {htmlTemplate} from './gr-alert_html.js';
 import {getRootElement} from '../../../scripts/rootElement.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrAlert extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.js
deleted file mode 100644
index e9f386d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.js
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /**
-       * ALERT: DO NOT ADD TRANSITION PROPERTIES WITHOUT PROPERLY UNDERSTANDING
-       * HOW THEY ARE USED IN THE CODE.
-       */
-    :host([toast]) {
-      background-color: var(--tooltip-background-color);
-      bottom: 1.25rem;
-      border-radius: var(--border-radius);
-      box-shadow: var(--elevation-level-2);
-      color: var(--view-background-color);
-      left: 1.25rem;
-      position: fixed;
-      transform: translateY(5rem);
-      transition: transform var(--gr-alert-transition-duration, 80ms) ease-out;
-      z-index: 1000;
-    }
-    :host([shown]) {
-      transform: translateY(0);
-    }
-    /**
-       * NOTE: To avoid style being overwritten by outside of the shadow DOM
-       * (as outside styles always win), .content-wrapper is introduced as a
-       * wrapper around main content to have better encapsulation, styles that
-       * may be affected by outside should be defined on it.
-       * In this case, \`padding:0px\` is defined in main.css for all elements
-       * with the universal selector: *.
-       */
-    .content-wrapper {
-      padding: var(--spacing-l) var(--spacing-xl);
-    }
-    .text {
-      color: var(--tooltip-text-color);
-      display: inline-block;
-      max-height: 10rem;
-      max-width: 80vw;
-      vertical-align: bottom;
-      word-break: break-all;
-    }
-    .action {
-      color: var(--link-color);
-      font-weight: var(--font-weight-bold);
-      margin-left: var(--spacing-l);
-      text-decoration: none;
-      --gr-button: {
-        padding: 0;
-      }
-    }
-  </style>
-  <div class="content-wrapper">
-    <span class="text">[[text]]</span>
-    <gr-button
-      link=""
-      class="action"
-      hidden$="[[_hideActionButton]]"
-      on-click="_handleActionTap"
-      >[[actionText]]</gr-button
-    >
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
new file mode 100644
index 0000000..d2aed40
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
@@ -0,0 +1,79 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /**
+       * ALERT: DO NOT ADD TRANSITION PROPERTIES WITHOUT PROPERLY UNDERSTANDING
+       * HOW THEY ARE USED IN THE CODE.
+       */
+    :host([toast]) {
+      background-color: var(--tooltip-background-color);
+      bottom: 1.25rem;
+      border-radius: var(--border-radius);
+      box-shadow: var(--elevation-level-2);
+      color: var(--view-background-color);
+      left: 1.25rem;
+      position: fixed;
+      transform: translateY(5rem);
+      transition: transform var(--gr-alert-transition-duration, 80ms) ease-out;
+      z-index: 1000;
+    }
+    :host([shown]) {
+      transform: translateY(0);
+    }
+    /**
+       * NOTE: To avoid style being overwritten by outside of the shadow DOM
+       * (as outside styles always win), .content-wrapper is introduced as a
+       * wrapper around main content to have better encapsulation, styles that
+       * may be affected by outside should be defined on it.
+       * In this case, \`padding:0px\` is defined in main.css for all elements
+       * with the universal selector: *.
+       */
+    .content-wrapper {
+      padding: var(--spacing-l) var(--spacing-xl);
+    }
+    .text {
+      color: var(--tooltip-text-color);
+      display: inline-block;
+      max-height: 10rem;
+      max-width: 80vw;
+      vertical-align: bottom;
+      word-break: break-all;
+    }
+    .action {
+      color: var(--link-color);
+      font-weight: var(--font-weight-bold);
+      margin-left: var(--spacing-l);
+      text-decoration: none;
+      --gr-button: {
+        padding: 0;
+      }
+    }
+  </style>
+  <div class="content-wrapper">
+    <span class="text">[[text]]</span>
+    <gr-button
+      link=""
+      class="action"
+      hidden$="[[_hideActionButton]]"
+      on-click="_handleActionTap"
+      >[[actionText]]</gr-button
+    >
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
deleted file mode 100644
index 557ec28..0000000
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
+++ /dev/null
@@ -1,59 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-alert</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-alert.js';
-suite('gr-alert tests', () => {
-  let element;
-
-  setup(() => {
-    element = document.createElement('gr-alert');
-  });
-
-  teardown(() => {
-    if (element.parentNode) {
-      element.parentNode.removeChild(element);
-    }
-  });
-
-  test('show/hide', () => {
-    assert.isNull(element.parentNode);
-    element.show();
-    assert.equal(element.parentNode, document.body);
-    element.updateStyles({'--gr-alert-transition-duration': '0ms'});
-    element.hide();
-    assert.isNull(element.parentNode);
-  });
-
-  test('action event', done => {
-    element.show();
-    element._actionCallback = done;
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.action'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js
new file mode 100644
index 0000000..8105584
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-alert.js';
+suite('gr-alert tests', () => {
+  let element;
+
+  setup(() => {
+    element = document.createElement('gr-alert');
+  });
+
+  teardown(() => {
+    if (element.parentNode) {
+      element.parentNode.removeChild(element);
+    }
+  });
+
+  test('show/hide', () => {
+    assert.isNull(element.parentNode);
+    element.show();
+    assert.equal(element.parentNode, document.body);
+    element.updateStyles({'--gr-alert-transition-duration': '0ms'});
+    element.hide();
+    assert.isNull(element.parentNode);
+  });
+
+  test('action event', done => {
+    element.show();
+    element._actionCallback = done;
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.action'));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
index 46e8829..f4c4886 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
@@ -14,28 +14,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 import '@polymer/iron-dropdown/iron-dropdown.js';
-import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior.js';
 import '../gr-cursor-manager/gr-cursor-manager.js';
 import '../../../styles/shared-styles.js';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-autocomplete-dropdown_html.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {IronFitMixin} from '../../../mixins/iron-fit-mixin/iron-fit-mixin.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrAutocompleteDropdown extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-  IronFitBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrAutocompleteDropdown extends IronFitMixin(KeyboardShortcutMixin(
+    GestureEventListeners(LegacyElementMixin(PolymerElement)))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-autocomplete-dropdown'; }
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.js
deleted file mode 100644
index b31af73..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.js
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      z-index: 100;
-    }
-    :host([is-hidden]) {
-      display: none;
-    }
-    ul {
-      list-style: none;
-    }
-    li {
-      border-bottom: 1px solid var(--border-color);
-      cursor: pointer;
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    li:last-of-type {
-      border: none;
-    }
-    li:focus {
-      outline: none;
-    }
-    li:hover {
-      background-color: var(--hover-background-color);
-    }
-    li.selected {
-      background-color: var(--selection-background-color);
-    }
-    .dropdown-content {
-      background: var(--dropdown-background-color);
-      box-shadow: var(--elevation-level-2);
-      border-radius: var(--border-radius);
-      max-height: 50vh;
-      overflow: auto;
-    }
-    @media only screen and (max-height: 35em) {
-      .dropdown-content {
-        max-height: 80vh;
-      }
-    }
-    .label {
-      color: var(--deemphasized-text-color);
-      padding-left: var(--spacing-l);
-    }
-    .hide {
-      display: none;
-    }
-  </style>
-  <div
-    class="dropdown-content"
-    slot="dropdown-content"
-    id="suggestions"
-    role="listbox"
-  >
-    <ul>
-      <template is="dom-repeat" items="[[suggestions]]">
-        <li
-          data-index$="[[index]]"
-          data-value$="[[item.dataValue]]"
-          tabindex="-1"
-          aria-label$="[[item.name]]"
-          class="autocompleteOption"
-          role="option"
-          on-click="_handleClickItem"
-        >
-          <span>[[item.text]]</span>
-          <span class$="label [[_computeLabelClass(item)]]"
-            >[[item.label]]</span
-          >
-        </li>
-      </template>
-    </ul>
-  </div>
-  <gr-cursor-manager
-    id="cursor"
-    index="{{index}}"
-    cursor-target-class="selected"
-    scroll-behavior="never"
-    focus-on-move=""
-    stops="[[_suggestionEls]]"
-  ></gr-cursor-manager>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts
new file mode 100644
index 0000000..d3d2481
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts
@@ -0,0 +1,102 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      z-index: 100;
+    }
+    :host([is-hidden]) {
+      display: none;
+    }
+    ul {
+      list-style: none;
+    }
+    li {
+      border-bottom: 1px solid var(--border-color);
+      cursor: pointer;
+      display: flex;
+      justify-content: space-between;
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    li:last-of-type {
+      border: none;
+    }
+    li:focus {
+      outline: none;
+    }
+    li:hover {
+      background-color: var(--hover-background-color);
+    }
+    li.selected {
+      background-color: var(--selection-background-color);
+    }
+    .dropdown-content {
+      background: var(--dropdown-background-color);
+      box-shadow: var(--elevation-level-2);
+      border-radius: var(--border-radius);
+      max-height: 50vh;
+      overflow: auto;
+    }
+    @media only screen and (max-height: 35em) {
+      .dropdown-content {
+        max-height: 80vh;
+      }
+    }
+    .label {
+      color: var(--deemphasized-text-color);
+      padding-left: var(--spacing-l);
+    }
+    .hide {
+      display: none;
+    }
+  </style>
+  <div
+    class="dropdown-content"
+    slot="dropdown-content"
+    id="suggestions"
+    role="listbox"
+  >
+    <ul>
+      <template is="dom-repeat" items="[[suggestions]]">
+        <li
+          data-index$="[[index]]"
+          data-value$="[[item.dataValue]]"
+          tabindex="-1"
+          aria-label$="[[item.name]]"
+          class="autocompleteOption"
+          role="option"
+          on-click="_handleClickItem"
+        >
+          <span>[[item.text]]</span>
+          <span class$="label [[_computeLabelClass(item)]]"
+            >[[item.label]]</span
+          >
+        </li>
+      </template>
+    </ul>
+  </div>
+  <gr-cursor-manager
+    id="cursor"
+    index="{{index}}"
+    cursor-target-class="selected"
+    scroll-mode="never"
+    focus-on-move=""
+    stops="[[_suggestionEls]]"
+  ></gr-cursor-manager>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
deleted file mode 100644
index d836155..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
+++ /dev/null
@@ -1,154 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-autocomplete-dropdown</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-autocomplete-dropdown></gr-autocomplete-dropdown>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-autocomplete-dropdown.js';
-suite('gr-autocomplete-dropdown', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    element.open();
-    element.suggestions = [
-      {dataValue: 'test value 1', name: 'test name 1', text: 1, label: 'hi'},
-      {dataValue: 'test value 2', name: 'test name 2', text: 2}];
-    flushAsynchronousOperations();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-    if (element.isOpen) element.close();
-  });
-
-  test('shows labels', () => {
-    const els = element.$.suggestions.querySelectorAll('li');
-    assert.equal(els[0].innerText.trim(), '1\nhi');
-    assert.equal(els[1].innerText.trim(), '2');
-  });
-
-  test('escape key', done => {
-    const closeSpy = sandbox.spy(element, 'close');
-    MockInteractions.pressAndReleaseKeyOn(element, 27);
-    flushAsynchronousOperations();
-    assert.isTrue(closeSpy.called);
-    done();
-  });
-
-  test('tab key', () => {
-    const handleTabSpy = sandbox.spy(element, '_handleTab');
-    const itemSelectedStub = sandbox.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    MockInteractions.pressAndReleaseKeyOn(element, 9);
-    assert.isTrue(handleTabSpy.called);
-    assert.equal(element.$.cursor.index, 0);
-    assert.isTrue(itemSelectedStub.called);
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'tab',
-      selected: element.getCursorTarget(),
-    });
-  });
-
-  test('enter key', () => {
-    const handleEnterSpy = sandbox.spy(element, '_handleEnter');
-    const itemSelectedStub = sandbox.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    MockInteractions.pressAndReleaseKeyOn(element, 13);
-    assert.isTrue(handleEnterSpy.called);
-    assert.equal(element.$.cursor.index, 0);
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'enter',
-      selected: element.getCursorTarget(),
-    });
-  });
-
-  test('down key', () => {
-    element.isHidden = true;
-    const nextSpy = sandbox.spy(element.$.cursor, 'next');
-    MockInteractions.pressAndReleaseKeyOn(element, 40);
-    assert.isFalse(nextSpy.called);
-    assert.equal(element.$.cursor.index, 0);
-    element.isHidden = false;
-    MockInteractions.pressAndReleaseKeyOn(element, 40);
-    assert.isTrue(nextSpy.called);
-    assert.equal(element.$.cursor.index, 1);
-  });
-
-  test('up key', () => {
-    element.isHidden = true;
-    const prevSpy = sandbox.spy(element.$.cursor, 'previous');
-    MockInteractions.pressAndReleaseKeyOn(element, 38);
-    assert.isFalse(prevSpy.called);
-    assert.equal(element.$.cursor.index, 0);
-    element.isHidden = false;
-    element.$.cursor.setCursorAtIndex(1);
-    assert.equal(element.$.cursor.index, 1);
-    MockInteractions.pressAndReleaseKeyOn(element, 38);
-    assert.isTrue(prevSpy.called);
-    assert.equal(element.$.cursor.index, 0);
-  });
-
-  test('tapping selects item', () => {
-    const itemSelectedStub = sandbox.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-
-    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
-    flushAsynchronousOperations();
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'click',
-      selected: element.$.suggestions.querySelectorAll('li')[1],
-    });
-  });
-
-  test('tapping child still selects item', () => {
-    const itemSelectedStub = sandbox.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-
-    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[0]
-        .lastElementChild);
-    flushAsynchronousOperations();
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'click',
-      selected: element.$.suggestions.querySelectorAll('li')[0],
-    });
-  });
-
-  test('updated suggestions resets cursor stops', () => {
-    const resetStopsSpy = sandbox.spy(element, '_resetCursorStops');
-    element.suggestions = [];
-    assert.isTrue(resetStopsSpy.called);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js
new file mode 100644
index 0000000..f76d070
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js
@@ -0,0 +1,137 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-autocomplete-dropdown.js';
+
+const basicFixture = fixtureFromElement('gr-autocomplete-dropdown');
+
+suite('gr-autocomplete-dropdown', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.open();
+    element.suggestions = [
+      {dataValue: 'test value 1', name: 'test name 1', text: 1, label: 'hi'},
+      {dataValue: 'test value 2', name: 'test name 2', text: 2}];
+    flushAsynchronousOperations();
+  });
+
+  teardown(() => {
+    if (element.isOpen) element.close();
+  });
+
+  test('shows labels', () => {
+    const els = element.$.suggestions.querySelectorAll('li');
+    assert.equal(els[0].innerText.trim(), '1\nhi');
+    assert.equal(els[1].innerText.trim(), '2');
+  });
+
+  test('escape key', done => {
+    const closeSpy = sinon.spy(element, 'close');
+    MockInteractions.pressAndReleaseKeyOn(element, 27);
+    flushAsynchronousOperations();
+    assert.isTrue(closeSpy.called);
+    done();
+  });
+
+  test('tab key', () => {
+    const handleTabSpy = sinon.spy(element, '_handleTab');
+    const itemSelectedStub = sinon.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+    MockInteractions.pressAndReleaseKeyOn(element, 9);
+    assert.isTrue(handleTabSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+    assert.isTrue(itemSelectedStub.called);
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'tab',
+      selected: element.getCursorTarget(),
+    });
+  });
+
+  test('enter key', () => {
+    const handleEnterSpy = sinon.spy(element, '_handleEnter');
+    const itemSelectedStub = sinon.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+    MockInteractions.pressAndReleaseKeyOn(element, 13);
+    assert.isTrue(handleEnterSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'enter',
+      selected: element.getCursorTarget(),
+    });
+  });
+
+  test('down key', () => {
+    element.isHidden = true;
+    const nextSpy = sinon.spy(element.$.cursor, 'next');
+    MockInteractions.pressAndReleaseKeyOn(element, 40);
+    assert.isFalse(nextSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+    element.isHidden = false;
+    MockInteractions.pressAndReleaseKeyOn(element, 40);
+    assert.isTrue(nextSpy.called);
+    assert.equal(element.$.cursor.index, 1);
+  });
+
+  test('up key', () => {
+    element.isHidden = true;
+    const prevSpy = sinon.spy(element.$.cursor, 'previous');
+    MockInteractions.pressAndReleaseKeyOn(element, 38);
+    assert.isFalse(prevSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+    element.isHidden = false;
+    element.$.cursor.setCursorAtIndex(1);
+    assert.equal(element.$.cursor.index, 1);
+    MockInteractions.pressAndReleaseKeyOn(element, 38);
+    assert.isTrue(prevSpy.called);
+    assert.equal(element.$.cursor.index, 0);
+  });
+
+  test('tapping selects item', () => {
+    const itemSelectedStub = sinon.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+
+    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
+    flushAsynchronousOperations();
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'click',
+      selected: element.$.suggestions.querySelectorAll('li')[1],
+    });
+  });
+
+  test('tapping child still selects item', () => {
+    const itemSelectedStub = sinon.stub();
+    element.addEventListener('item-selected', itemSelectedStub);
+
+    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[0]
+        .lastElementChild);
+    flushAsynchronousOperations();
+    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+      trigger: 'click',
+      selected: element.$.suggestions.querySelectorAll('li')[0],
+    });
+  });
+
+  test('updated suggestions resets cursor stops', () => {
+    const resetStopsSpy = sinon.spy(element, '_resetCursorStops');
+    element.suggestions = [];
+    assert.isTrue(resetStopsSpy.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index 7f9ed72..90b966f 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -14,32 +14,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/paper-input/paper-input.js';
 import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown.js';
 import '../gr-cursor-manager/gr-cursor-manager.js';
 import '../gr-icons/gr-icons.js';
 import '../../../styles/shared-styles.js';
 import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-autocomplete_html.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
 const DEBOUNCE_WAIT_MS = 200;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrAutocomplete extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrAutocomplete extends KeyboardShortcutMixin(
+    GestureEventListeners(
+        LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-autocomplete'; }
@@ -186,6 +181,14 @@
         type: Boolean,
         value: false,
       },
+      /**
+       * Invisible label for input element. This label is exposed to
+       * screen readers by paper-input
+       */
+      label: {
+        type: String,
+        value: '',
+      },
 
       /** The DOM element of the selected suggestion. */
       _selected: Object,
@@ -275,7 +278,7 @@
 
   _updateSuggestions(text, threshold, noDebounce) {
     // Polymer 2: check for undefined
-    if ([text, threshold, noDebounce].some(arg => arg === undefined)) {
+    if ([text, threshold, noDebounce].includes(undefined)) {
       return;
     }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js
deleted file mode 100644
index eae8741..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.js
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .searchIcon {
-      display: none;
-    }
-    .searchIcon.showSearchIcon {
-      display: inline-block;
-    }
-    iron-icon {
-      margin: 0 var(--spacing-xs);
-      vertical-align: top;
-    }
-    paper-input.borderless {
-      border: none;
-      padding: 0;
-    }
-    paper-input {
-      background-color: var(--view-background-color);
-      color: var(--primary-text-color);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      padding: var(--spacing-s);
-      --paper-input-container: {
-        padding: 0;
-      }
-      --paper-input-container-input: {
-        font-size: var(--font-size-normal);
-        line-height: var(--line-height-normal);
-      }
-      /* This is a hack for not being able to set height:0 on the underline
-           of a paper-input 2.2.3 element. All the underline fixes below only
-           actually work in 3.x.x, so the height must be adjusted directly as
-           a workaround until we are on Polymer 3. */
-      height: var(--line-height-normal);
-      --paper-input-container-underline-height: 0;
-      --paper-input-container-underline-wrapper-height: 0;
-      --paper-input-container-underline-focus-height: 0;
-      --paper-input-container-underline-legacy-height: 0;
-      --paper-input-container-underline: {
-        height: 0;
-        display: none;
-      }
-      --paper-input-container-underline-focus: {
-        height: 0;
-        display: none;
-      }
-      --paper-input-container-underline-disabled: {
-        height: 0;
-        display: none;
-      }
-    }
-    paper-input.warnUncommitted {
-      --paper-input-container-input: {
-        color: var(--error-text-color);
-        font-size: inherit;
-      }
-    }
-  </style>
-  <paper-input
-    no-label-float=""
-    id="input"
-    class$="[[_computeClass(borderless)]]"
-    disabled$="[[disabled]]"
-    value="{{text}}"
-    placeholder="[[placeholder]]"
-    on-keydown="_handleKeydown"
-    on-focus="_onInputFocus"
-    on-blur="_onInputBlur"
-    autocomplete="off"
-  >
-    <!-- prefix as attribute is required to for polymer 1 -->
-    <div slot="prefix" prefix="">
-      <iron-icon
-        icon="gr-icons:search"
-        class$="searchIcon [[_computeShowSearchIconClass(showSearchIcon)]]"
-      >
-      </iron-icon>
-    </div>
-  </paper-input>
-  <gr-autocomplete-dropdown
-    vertical-align="top"
-    vertical-offset="[[verticalOffset]]"
-    horizontal-align="left"
-    id="suggestions"
-    on-item-selected="_handleItemSelect"
-    on-keydown="_handleKeydown"
-    suggestions="[[_suggestions]]"
-    role="listbox"
-    index="[[_index]]"
-    position-target="[[_inputElement]]"
-  >
-  </gr-autocomplete-dropdown>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts
new file mode 100644
index 0000000..8ab4828
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts
@@ -0,0 +1,118 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .searchIcon {
+      display: none;
+    }
+    .searchIcon.showSearchIcon {
+      display: inline-block;
+    }
+    iron-icon {
+      margin: 0 var(--spacing-xs);
+      vertical-align: top;
+    }
+    paper-input.borderless {
+      border: none;
+      padding: 0;
+    }
+    paper-input {
+      background-color: var(--view-background-color);
+      color: var(--primary-text-color);
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      padding: var(--spacing-s);
+      --paper-input-container: {
+        padding: 0;
+      }
+      --paper-input-container-input: {
+        font-size: var(--font-size-normal);
+        line-height: var(--line-height-normal);
+      }
+      /* This is a hack for not being able to set height:0 on the underline
+           of a paper-input 2.2.3 element. All the underline fixes below only
+           actually work in 3.x.x, so the height must be adjusted directly as
+           a workaround until we are on Polymer 3. */
+      height: var(--line-height-normal);
+      --paper-input-container-underline-height: 0;
+      --paper-input-container-underline-wrapper-height: 0;
+      --paper-input-container-underline-focus-height: 0;
+      --paper-input-container-underline-legacy-height: 0;
+      --paper-input-container-underline: {
+        height: 0;
+        display: none;
+      }
+      --paper-input-container-underline-focus: {
+        height: 0;
+        display: none;
+      }
+      --paper-input-container-underline-disabled: {
+        height: 0;
+        display: none;
+      }
+      /* Hide label for input. The label is still visible for
+      screen readers. Workaround found at:
+      https://github.com/PolymerElements/paper-input/issues/478 */
+      --paper-input-container-label: {
+        display: none;
+      }
+    }
+    paper-input.warnUncommitted {
+      --paper-input-container-input: {
+        color: var(--error-text-color);
+        font-size: inherit;
+      }
+    }
+  </style>
+  <paper-input
+    no-label-float=""
+    id="input"
+    class$="[[_computeClass(borderless)]]"
+    disabled$="[[disabled]]"
+    value="{{text}}"
+    placeholder="[[placeholder]]"
+    on-keydown="_handleKeydown"
+    on-focus="_onInputFocus"
+    on-blur="_onInputBlur"
+    autocomplete="off"
+    label="[[label]]"
+  >
+    <!-- prefix as attribute is required to for polymer 1 -->
+    <div slot="prefix" prefix="">
+      <iron-icon
+        icon="gr-icons:search"
+        class$="searchIcon [[_computeShowSearchIconClass(showSearchIcon)]]"
+      >
+      </iron-icon>
+    </div>
+  </paper-input>
+  <gr-autocomplete-dropdown
+    vertical-align="top"
+    vertical-offset="[[verticalOffset]]"
+    horizontal-align="left"
+    id="suggestions"
+    on-item-selected="_handleItemSelect"
+    on-keydown="_handleKeydown"
+    suggestions="[[_suggestions]]"
+    role="listbox"
+    index="[[_index]]"
+    position-target="[[_inputElement]]"
+  >
+  </gr-autocomplete-dropdown>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
deleted file mode 100644
index 6dd5a97..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ /dev/null
@@ -1,612 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-reviewer-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-autocomplete no-debounce></gr-autocomplete>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-autocomplete.js';
-import {dom, flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-autocomplete tests', () => {
-  let element;
-  let sandbox;
-  const focusOnInput = element => {
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-        'enter');
-  };
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('renders', () => {
-    let promise;
-    const queryStub = sandbox.spy(input => promise = Promise.resolve([
-      {name: input + ' 0', value: 0},
-      {name: input + ' 1', value: 1},
-      {name: input + ' 2', value: 2},
-      {name: input + ' 3', value: 3},
-      {name: input + ' 4', value: 4},
-    ]));
-    element.query = queryStub;
-    assert.isTrue(element.$.suggestions.isHidden);
-    assert.equal(element.$.suggestions.$.cursor.index, -1);
-
-    focusOnInput(element);
-    element.text = 'blah';
-
-    assert.isTrue(queryStub.called);
-    element._focused = true;
-
-    return promise.then(() => {
-      assert.isFalse(element.$.suggestions.isHidden);
-      const suggestions =
-          dom(element.$.suggestions.root).querySelectorAll('li');
-      assert.equal(suggestions.length, 5);
-
-      for (let i = 0; i < 5; i++) {
-        assert.equal(suggestions[i].innerText.trim(), 'blah ' + i);
-      }
-
-      assert.notEqual(element.$.suggestions.$.cursor.index, -1);
-    });
-  });
-
-  test('selectAll', done => {
-    flush(() => {
-      const nativeInput = element._nativeInput;
-      const selectionStub = sandbox.stub(nativeInput, 'setSelectionRange');
-
-      element.selectAll();
-      assert.isFalse(selectionStub.called);
-
-      element.$.input.value = 'test';
-      element.selectAll();
-      assert.isTrue(selectionStub.called);
-      done();
-    });
-  });
-
-  test('esc key behavior', done => {
-    let promise;
-    const queryStub = sandbox.spy(() => promise = Promise.resolve([
-      {name: 'blah', value: 123},
-    ]));
-    element.query = queryStub;
-
-    assert.isTrue(element.$.suggestions.isHidden);
-
-    element._focused = true;
-    element.text = 'blah';
-
-    promise.then(() => {
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      const cancelHandler = sandbox.spy();
-      element.addEventListener('cancel', cancelHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-      assert.isFalse(cancelHandler.called);
-      assert.isTrue(element.$.suggestions.isHidden);
-      assert.equal(element._suggestions.length, 0);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-      assert.isTrue(cancelHandler.called);
-      done();
-    });
-  });
-
-  test('emits commit and handles cursor movement', done => {
-    let promise;
-    const queryStub = sandbox.spy(input => promise = Promise.resolve([
-      {name: input + ' 0', value: 0},
-      {name: input + ' 1', value: 1},
-      {name: input + ' 2', value: 2},
-      {name: input + ' 3', value: 3},
-      {name: input + ' 4', value: 4},
-    ]));
-    element.query = queryStub;
-
-    assert.isTrue(element.$.suggestions.isHidden);
-    assert.equal(element.$.suggestions.$.cursor.index, -1);
-    element._focused = true;
-    element.text = 'blah';
-
-    promise.then(() => {
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      const commitHandler = sandbox.spy();
-      element.addEventListener('commit', commitHandler);
-
-      assert.equal(element.$.suggestions.$.cursor.index, 0);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
-          'down');
-
-      assert.equal(element.$.suggestions.$.cursor.index, 1);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
-          'down');
-
-      assert.equal(element.$.suggestions.$.cursor.index, 2);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
-
-      assert.equal(element.$.suggestions.$.cursor.index, 1);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.equal(element.value, 1);
-      assert.isTrue(commitHandler.called);
-      assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
-      assert.isTrue(element.$.suggestions.isHidden);
-      assert.isTrue(element._focused);
-      done();
-    });
-  });
-
-  test('clear-on-commit behavior (off)', done => {
-    let promise;
-    const queryStub = sandbox.spy(() => {
-      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
-      return promise;
-    });
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'blah';
-
-    promise.then(() => {
-      const commitHandler = sandbox.spy();
-      element.addEventListener('commit', commitHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.isTrue(commitHandler.called);
-      assert.equal(element.text, 'suggestion');
-      done();
-    });
-  });
-
-  test('clear-on-commit behavior (on)', done => {
-    let promise;
-    const queryStub = sandbox.spy(() => {
-      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
-      return promise;
-    });
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'blah';
-    element.clearOnCommit = true;
-
-    promise.then(() => {
-      const commitHandler = sandbox.spy();
-      element.addEventListener('commit', commitHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.isTrue(commitHandler.called);
-      assert.equal(element.text, '');
-      done();
-    });
-  });
-
-  test('threshold guards the query', () => {
-    const queryStub = sandbox.spy(() => Promise.resolve([]));
-    element.query = queryStub;
-    element.threshold = 2;
-    focusOnInput(element);
-    element.text = 'a';
-    assert.isFalse(queryStub.called);
-    element.text = 'ab';
-    assert.isTrue(queryStub.called);
-  });
-
-  test('noDebounce=false debounces the query', () => {
-    const queryStub = sandbox.spy(() => Promise.resolve([]));
-    let callback;
-    const debounceStub = sandbox.stub(element, 'debounce',
-        (name, cb) => { callback = cb; });
-    element.query = queryStub;
-    element.noDebounce = false;
-    focusOnInput(element);
-    element.text = 'a';
-    assert.isFalse(queryStub.called);
-    assert.isTrue(debounceStub.called);
-    assert.equal(debounceStub.lastCall.args[2], 200);
-    assert.isFunction(callback);
-    callback();
-    assert.isTrue(queryStub.called);
-  });
-
-  test('_computeClass respects border property', () => {
-    assert.equal(element._computeClass(), '');
-    assert.equal(element._computeClass(false), '');
-    assert.equal(element._computeClass(true), 'borderless');
-  });
-
-  test('undefined or empty text results in no suggestions', () => {
-    element._updateSuggestions(undefined, 0, null);
-    assert.equal(element._suggestions.length, 0);
-  });
-
-  test('when focused', done => {
-    let promise;
-    const queryStub = sandbox.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    element.suggestOnlyWhenFocus = true;
-    focusOnInput(element);
-    element.text = 'bla';
-    assert.equal(element._focused, true);
-    flushAsynchronousOperations();
-    promise.then(() => {
-      assert.equal(element._suggestions.length, 1);
-      assert.equal(queryStub.notCalled, false);
-      done();
-    });
-  });
-
-  test('when not focused', done => {
-    let promise;
-    const queryStub = sandbox.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    element.suggestOnlyWhenFocus = true;
-    element.text = 'bla';
-    assert.equal(element._focused, false);
-    flushAsynchronousOperations();
-    promise.then(() => {
-      assert.equal(element._suggestions.length, 0);
-      done();
-    });
-  });
-
-  test('suggestions should not carry over', done => {
-    let promise;
-    const queryStub = sandbox.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'bla';
-    flushAsynchronousOperations();
-    promise.then(() => {
-      assert.equal(element._suggestions.length, 1);
-      element._updateSuggestions('', 0, false);
-      assert.equal(element._suggestions.length, 0);
-      done();
-    });
-  });
-
-  test('multi completes only the last part of the query', done => {
-    let promise;
-    const queryStub = sandbox.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'blah blah';
-    element.multi = true;
-
-    promise.then(() => {
-      const commitHandler = sandbox.spy();
-      element.addEventListener('commit', commitHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.isTrue(commitHandler.called);
-      assert.equal(element.text, 'blah 0');
-      done();
-    });
-  });
-
-  test('tabComplete flag functions', () => {
-    // commitHandler checks for the commit event, whereas commitSpy checks for
-    // the _commit function of the element.
-    const commitHandler = sandbox.spy();
-    element.addEventListener('commit', commitHandler);
-    const commitSpy = sandbox.spy(element, '_commit');
-    element._focused = true;
-
-    element._suggestions = ['tunnel snakes rule!'];
-    element.tabComplete = false;
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-    assert.isFalse(commitHandler.called);
-    assert.isFalse(commitSpy.called);
-    assert.isFalse(element._focused);
-
-    element.tabComplete = true;
-    element._focused = true;
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-    assert.isFalse(commitHandler.called);
-    assert.isTrue(commitSpy.called);
-    assert.isTrue(element._focused);
-  });
-
-  test('_focused flag properly triggered', done => {
-    flush(() => {
-      assert.isFalse(element._focused);
-      const input = element.shadowRoot
-          .querySelector('paper-input').inputElement;
-      MockInteractions.focus(input);
-      assert.isTrue(element._focused);
-      done();
-    });
-  });
-
-  test('search icon shows with showSearchIcon property', done => {
-    flush(() => {
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('iron-icon')).display,
-      'none');
-      element.showSearchIcon = true;
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('iron-icon')).display,
-      'none');
-      done();
-    });
-  });
-
-  test('vertical offset overridden by param if it exists', () => {
-    assert.equal(element.$.suggestions.verticalOffset, 31);
-    element.verticalOffset = 30;
-    assert.equal(element.$.suggestions.verticalOffset, 30);
-  });
-
-  test('_focused flag shows/hides the suggestions', () => {
-    const openStub = sandbox.stub(element.$.suggestions, 'open');
-    const closedStub = sandbox.stub(element.$.suggestions, 'close');
-    element._suggestions = ['hello', 'its me'];
-    assert.isFalse(openStub.called);
-    assert.isTrue(closedStub.calledOnce);
-    element._focused = true;
-    assert.isTrue(openStub.calledOnce);
-    element._suggestions = [];
-    assert.isTrue(closedStub.calledTwice);
-    assert.isTrue(openStub.calledOnce);
-  });
-
-  test('_handleInputCommit with autocomplete hidden does nothing without' +
-        'without allowNonSuggestedValues', () => {
-    const commitStub = sandbox.stub(element, '_commit');
-    element.$.suggestions.isHidden = true;
-    element._handleInputCommit();
-    assert.isFalse(commitStub.called);
-  });
-
-  test('_handleInputCommit with autocomplete hidden with' +
-        'allowNonSuggestedValues', () => {
-    const commitStub = sandbox.stub(element, '_commit');
-    element.allowNonSuggestedValues = true;
-    element.$.suggestions.isHidden = true;
-    element._handleInputCommit();
-    assert.isTrue(commitStub.called);
-  });
-
-  test('_handleInputCommit with autocomplete open calls commit', () => {
-    const commitStub = sandbox.stub(element, '_commit');
-    element.$.suggestions.isHidden = false;
-    element._handleInputCommit();
-    assert.isTrue(commitStub.calledOnce);
-  });
-
-  test('_handleInputCommit with autocomplete open calls commit' +
-        'with allowNonSuggestedValues', () => {
-    const commitStub = sandbox.stub(element, '_commit');
-    element.allowNonSuggestedValues = true;
-    element.$.suggestions.isHidden = false;
-    element._handleInputCommit();
-    assert.isTrue(commitStub.calledOnce);
-  });
-
-  test('issue 8655', () => {
-    function makeSuggestion(s) { return {name: s, text: s, value: s}; }
-    const keydownSpy = sandbox.spy(element, '_handleKeydown');
-    element.setText('file:');
-    element._suggestions =
-        [makeSuggestion('file:'), makeSuggestion('-file:')];
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 88, null, 'x');
-    // Must set the value, because the MockInteraction does not.
-    element.$.input.value = 'file:x';
-    assert.isTrue(keydownSpy.calledOnce);
-    MockInteractions.pressAndReleaseKeyOn(
-        element.$.input,
-        13,
-        null,
-        'enter'
-    );
-    assert.isTrue(keydownSpy.calledTwice);
-    assert.equal(element.text, 'file:x');
-  });
-
-  suite('focus', () => {
-    let commitSpy;
-    let focusSpy;
-
-    setup(() => {
-      commitSpy = sandbox.spy(element, '_commit');
-    });
-
-    test('enter does not call focus', () => {
-      element._suggestions = ['sugar bombs'];
-      focusSpy = sandbox.spy(element, 'focus');
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-      flushAsynchronousOperations();
-
-      assert.isTrue(commitSpy.called);
-      assert.isFalse(focusSpy.called);
-      assert.equal(element._suggestions.length, 0);
-    });
-
-    test('tab in input, tabComplete = true', () => {
-      focusSpy = sandbox.spy(element, 'focus');
-      const commitHandler = sandbox.stub();
-      element.addEventListener('commit', commitHandler);
-      element.tabComplete = true;
-      element._suggestions = ['tunnel snakes drool'];
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      flushAsynchronousOperations();
-
-      assert.isTrue(commitSpy.called);
-      assert.isTrue(focusSpy.called);
-      assert.isFalse(commitHandler.called);
-      assert.equal(element._suggestions.length, 0);
-    });
-
-    test('tab in input, tabComplete = false', () => {
-      element._suggestions = ['sugar bombs'];
-      focusSpy = sandbox.spy(element, 'focus');
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      flushAsynchronousOperations();
-
-      assert.isFalse(commitSpy.called);
-      assert.isFalse(focusSpy.called);
-      assert.equal(element._suggestions.length, 1);
-    });
-
-    test('tab on suggestion, tabComplete = false', () => {
-      element._suggestions = [{name: 'sugar bombs'}];
-      element._focused = true;
-      // When tabComplete is false, do not focus.
-      element.tabComplete = false;
-      focusSpy = sandbox.spy(element, 'focus');
-      flush$0();
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.suggestions.shadowRoot
-              .querySelector('li:first-child'), 9, null, 'tab');
-      flushAsynchronousOperations();
-      assert.isFalse(commitSpy.called);
-      assert.isFalse(element._focused);
-    });
-
-    test('tab on suggestion, tabComplete = true', () => {
-      element._suggestions = [{name: 'sugar bombs'}];
-      element._focused = true;
-      // When tabComplete is true, focus.
-      element.tabComplete = true;
-      focusSpy = sandbox.spy(element, 'focus');
-      flush$0();
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.suggestions.shadowRoot
-              .querySelector('li:first-child'), 9, null, 'tab');
-      flushAsynchronousOperations();
-
-      assert.isTrue(commitSpy.called);
-      assert.isTrue(element._focused);
-    });
-
-    test('tap on suggestion commits, does not call focus', () => {
-      focusSpy = sandbox.spy(element, 'focus');
-      element._focused = true;
-      element._suggestions = [{name: 'first suggestion'}];
-      flush$0();
-      assert.isFalse(element.$.suggestions.isHidden);
-      MockInteractions.tap(element.$.suggestions.shadowRoot
-          .querySelector('li:first-child'));
-      flushAsynchronousOperations();
-
-      assert.isFalse(focusSpy.called);
-      assert.isTrue(commitSpy.called);
-      assert.isTrue(element.$.suggestions.isHidden);
-    });
-  });
-
-  test('input-keydown event fired', () => {
-    const listener = sandbox.spy();
-    element.addEventListener('input-keydown', listener);
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-    flushAsynchronousOperations();
-    assert.isTrue(listener.called);
-  });
-
-  test('enter with modifier does not complete', () => {
-    const handleSpy = sandbox.spy(element, '_handleKeydown');
-    const commitStub = sandbox.stub(element, '_handleInputCommit');
-    MockInteractions.pressAndReleaseKeyOn(
-        element.$.input, 13, 'ctrl', 'enter');
-    assert.isTrue(handleSpy.called);
-    assert.isFalse(commitStub.called);
-    MockInteractions.pressAndReleaseKeyOn(
-        element.$.input, 13, null, 'enter');
-    assert.isTrue(commitStub.called);
-  });
-
-  suite('warnUncommitted', () => {
-    let inputClassList;
-    setup(() => {
-      inputClassList = element.$.input.classList;
-    });
-
-    test('enabled', () => {
-      element.warnUncommitted = true;
-      element.text = 'blah blah blah';
-      MockInteractions.blur(element.$.input);
-      assert.isTrue(inputClassList.contains('warnUncommitted'));
-      MockInteractions.focus(element.$.input);
-      assert.isFalse(inputClassList.contains('warnUncommitted'));
-    });
-
-    test('disabled', () => {
-      element.warnUncommitted = false;
-      element.text = 'blah blah blah';
-      MockInteractions.blur(element.$.input);
-      assert.isFalse(inputClassList.contains('warnUncommitted'));
-    });
-
-    test('no text', () => {
-      element.warnUncommitted = true;
-      element.text = '';
-      MockInteractions.blur(element.$.input);
-      assert.isFalse(inputClassList.contains('warnUncommitted'));
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
new file mode 100644
index 0000000..e9753c9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
@@ -0,0 +1,594 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-autocomplete.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {dom, flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromTemplate(
+    html`<gr-autocomplete no-debounce></gr-autocomplete>`);
+
+suite('gr-autocomplete tests', () => {
+  let element;
+
+  const focusOnInput = element => {
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+        'enter');
+  };
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('renders', () => {
+    let promise;
+    const queryStub = sinon.spy(input => promise = Promise.resolve([
+      {name: input + ' 0', value: 0},
+      {name: input + ' 1', value: 1},
+      {name: input + ' 2', value: 2},
+      {name: input + ' 3', value: 3},
+      {name: input + ' 4', value: 4},
+    ]));
+    element.query = queryStub;
+    assert.isTrue(element.$.suggestions.isHidden);
+    assert.equal(element.$.suggestions.$.cursor.index, -1);
+
+    focusOnInput(element);
+    element.text = 'blah';
+
+    assert.isTrue(queryStub.called);
+    element._focused = true;
+
+    return promise.then(() => {
+      assert.isFalse(element.$.suggestions.isHidden);
+      const suggestions =
+          dom(element.$.suggestions.root).querySelectorAll('li');
+      assert.equal(suggestions.length, 5);
+
+      for (let i = 0; i < 5; i++) {
+        assert.equal(suggestions[i].innerText.trim(), 'blah ' + i);
+      }
+
+      assert.notEqual(element.$.suggestions.$.cursor.index, -1);
+    });
+  });
+
+  test('selectAll', done => {
+    flush(() => {
+      const nativeInput = element._nativeInput;
+      const selectionStub = sinon.stub(nativeInput, 'setSelectionRange');
+
+      element.selectAll();
+      assert.isFalse(selectionStub.called);
+
+      element.$.input.value = 'test';
+      element.selectAll();
+      assert.isTrue(selectionStub.called);
+      done();
+    });
+  });
+
+  test('esc key behavior', done => {
+    let promise;
+    const queryStub = sinon.spy(() => promise = Promise.resolve([
+      {name: 'blah', value: 123},
+    ]));
+    element.query = queryStub;
+
+    assert.isTrue(element.$.suggestions.isHidden);
+
+    element._focused = true;
+    element.text = 'blah';
+
+    promise.then(() => {
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      const cancelHandler = sinon.spy();
+      element.addEventListener('cancel', cancelHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
+      assert.isFalse(cancelHandler.called);
+      assert.isTrue(element.$.suggestions.isHidden);
+      assert.equal(element._suggestions.length, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
+      assert.isTrue(cancelHandler.called);
+      done();
+    });
+  });
+
+  test('emits commit and handles cursor movement', done => {
+    let promise;
+    const queryStub = sinon.spy(input => promise = Promise.resolve([
+      {name: input + ' 0', value: 0},
+      {name: input + ' 1', value: 1},
+      {name: input + ' 2', value: 2},
+      {name: input + ' 3', value: 3},
+      {name: input + ' 4', value: 4},
+    ]));
+    element.query = queryStub;
+
+    assert.isTrue(element.$.suggestions.isHidden);
+    assert.equal(element.$.suggestions.$.cursor.index, -1);
+    element._focused = true;
+    element.text = 'blah';
+
+    promise.then(() => {
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      assert.equal(element.$.suggestions.$.cursor.index, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
+          'down');
+
+      assert.equal(element.$.suggestions.$.cursor.index, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
+          'down');
+
+      assert.equal(element.$.suggestions.$.cursor.index, 2);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
+
+      assert.equal(element.$.suggestions.$.cursor.index, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.equal(element.value, 1);
+      assert.isTrue(commitHandler.called);
+      assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
+      assert.isTrue(element.$.suggestions.isHidden);
+      assert.isTrue(element._focused);
+      done();
+    });
+  });
+
+  test('clear-on-commit behavior (off)', done => {
+    let promise;
+    const queryStub = sinon.spy(() => {
+      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'blah';
+
+    promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'suggestion');
+      done();
+    });
+  });
+
+  test('clear-on-commit behavior (on)', done => {
+    let promise;
+    const queryStub = sinon.spy(() => {
+      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'blah';
+    element.clearOnCommit = true;
+
+    promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, '');
+      done();
+    });
+  });
+
+  test('threshold guards the query', () => {
+    const queryStub = sinon.spy(() => Promise.resolve([]));
+    element.query = queryStub;
+    element.threshold = 2;
+    focusOnInput(element);
+    element.text = 'a';
+    assert.isFalse(queryStub.called);
+    element.text = 'ab';
+    assert.isTrue(queryStub.called);
+  });
+
+  test('noDebounce=false debounces the query', () => {
+    const queryStub = sinon.spy(() => Promise.resolve([]));
+    let callback;
+    const debounceStub = sinon.stub(element, 'debounce').callsFake(
+        (name, cb) => { callback = cb; });
+    element.query = queryStub;
+    element.noDebounce = false;
+    focusOnInput(element);
+    element.text = 'a';
+    assert.isFalse(queryStub.called);
+    assert.isTrue(debounceStub.called);
+    assert.equal(debounceStub.lastCall.args[2], 200);
+    assert.isFunction(callback);
+    callback();
+    assert.isTrue(queryStub.called);
+  });
+
+  test('_computeClass respects border property', () => {
+    assert.equal(element._computeClass(), '');
+    assert.equal(element._computeClass(false), '');
+    assert.equal(element._computeClass(true), 'borderless');
+  });
+
+  test('undefined or empty text results in no suggestions', () => {
+    element._updateSuggestions(undefined, 0, null);
+    assert.equal(element._suggestions.length, 0);
+  });
+
+  test('when focused', done => {
+    let promise;
+    const queryStub = sinon.stub()
+        .returns(promise = Promise.resolve([
+          {name: 'suggestion', value: 0},
+        ]));
+    element.query = queryStub;
+    element.suggestOnlyWhenFocus = true;
+    focusOnInput(element);
+    element.text = 'bla';
+    assert.equal(element._focused, true);
+    flushAsynchronousOperations();
+    promise.then(() => {
+      assert.equal(element._suggestions.length, 1);
+      assert.equal(queryStub.notCalled, false);
+      done();
+    });
+  });
+
+  test('when not focused', done => {
+    let promise;
+    const queryStub = sinon.stub()
+        .returns(promise = Promise.resolve([
+          {name: 'suggestion', value: 0},
+        ]));
+    element.query = queryStub;
+    element.suggestOnlyWhenFocus = true;
+    element.text = 'bla';
+    assert.equal(element._focused, false);
+    flushAsynchronousOperations();
+    promise.then(() => {
+      assert.equal(element._suggestions.length, 0);
+      done();
+    });
+  });
+
+  test('suggestions should not carry over', done => {
+    let promise;
+    const queryStub = sinon.stub()
+        .returns(promise = Promise.resolve([
+          {name: 'suggestion', value: 0},
+        ]));
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'bla';
+    flushAsynchronousOperations();
+    promise.then(() => {
+      assert.equal(element._suggestions.length, 1);
+      element._updateSuggestions('', 0, false);
+      assert.equal(element._suggestions.length, 0);
+      done();
+    });
+  });
+
+  test('multi completes only the last part of the query', done => {
+    let promise;
+    const queryStub = sinon.stub()
+        .returns(promise = Promise.resolve([
+          {name: 'suggestion', value: 0},
+        ]));
+    element.query = queryStub;
+    focusOnInput(element);
+    element.text = 'blah blah';
+    element.multi = true;
+
+    promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'blah 0');
+      done();
+    });
+  });
+
+  test('tabComplete flag functions', () => {
+    // commitHandler checks for the commit event, whereas commitSpy checks for
+    // the _commit function of the element.
+    const commitHandler = sinon.spy();
+    element.addEventListener('commit', commitHandler);
+    const commitSpy = sinon.spy(element, '_commit');
+    element._focused = true;
+
+    element._suggestions = ['tunnel snakes rule!'];
+    element.tabComplete = false;
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+    assert.isFalse(commitHandler.called);
+    assert.isFalse(commitSpy.called);
+    assert.isFalse(element._focused);
+
+    element.tabComplete = true;
+    element._focused = true;
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+    assert.isFalse(commitHandler.called);
+    assert.isTrue(commitSpy.called);
+    assert.isTrue(element._focused);
+  });
+
+  test('_focused flag properly triggered', done => {
+    flush(() => {
+      assert.isFalse(element._focused);
+      const input = element.shadowRoot
+          .querySelector('paper-input').inputElement;
+      MockInteractions.focus(input);
+      assert.isTrue(element._focused);
+      done();
+    });
+  });
+
+  test('search icon shows with showSearchIcon property', done => {
+    flush(() => {
+      assert.equal(getComputedStyle(element.shadowRoot
+          .querySelector('iron-icon')).display,
+      'none');
+      element.showSearchIcon = true;
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('iron-icon')).display,
+      'none');
+      done();
+    });
+  });
+
+  test('vertical offset overridden by param if it exists', () => {
+    assert.equal(element.$.suggestions.verticalOffset, 31);
+    element.verticalOffset = 30;
+    assert.equal(element.$.suggestions.verticalOffset, 30);
+  });
+
+  test('_focused flag shows/hides the suggestions', () => {
+    const openStub = sinon.stub(element.$.suggestions, 'open');
+    const closedStub = sinon.stub(element.$.suggestions, 'close');
+    element._suggestions = ['hello', 'its me'];
+    assert.isFalse(openStub.called);
+    assert.isTrue(closedStub.calledOnce);
+    element._focused = true;
+    assert.isTrue(openStub.calledOnce);
+    element._suggestions = [];
+    assert.isTrue(closedStub.calledTwice);
+    assert.isTrue(openStub.calledOnce);
+  });
+
+  test('_handleInputCommit with autocomplete hidden does nothing without' +
+        'without allowNonSuggestedValues', () => {
+    const commitStub = sinon.stub(element, '_commit');
+    element.$.suggestions.isHidden = true;
+    element._handleInputCommit();
+    assert.isFalse(commitStub.called);
+  });
+
+  test('_handleInputCommit with autocomplete hidden with' +
+        'allowNonSuggestedValues', () => {
+    const commitStub = sinon.stub(element, '_commit');
+    element.allowNonSuggestedValues = true;
+    element.$.suggestions.isHidden = true;
+    element._handleInputCommit();
+    assert.isTrue(commitStub.called);
+  });
+
+  test('_handleInputCommit with autocomplete open calls commit', () => {
+    const commitStub = sinon.stub(element, '_commit');
+    element.$.suggestions.isHidden = false;
+    element._handleInputCommit();
+    assert.isTrue(commitStub.calledOnce);
+  });
+
+  test('_handleInputCommit with autocomplete open calls commit' +
+        'with allowNonSuggestedValues', () => {
+    const commitStub = sinon.stub(element, '_commit');
+    element.allowNonSuggestedValues = true;
+    element.$.suggestions.isHidden = false;
+    element._handleInputCommit();
+    assert.isTrue(commitStub.calledOnce);
+  });
+
+  test('issue 8655', () => {
+    function makeSuggestion(s) { return {name: s, text: s, value: s}; }
+    const keydownSpy = sinon.spy(element, '_handleKeydown');
+    element.setText('file:');
+    element._suggestions =
+        [makeSuggestion('file:'), makeSuggestion('-file:')];
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 88, null, 'x');
+    // Must set the value, because the MockInteraction does not.
+    element.$.input.value = 'file:x';
+    assert.isTrue(keydownSpy.calledOnce);
+    MockInteractions.pressAndReleaseKeyOn(
+        element.$.input,
+        13,
+        null,
+        'enter'
+    );
+    assert.isTrue(keydownSpy.calledTwice);
+    assert.equal(element.text, 'file:x');
+  });
+
+  suite('focus', () => {
+    let commitSpy;
+    let focusSpy;
+
+    setup(() => {
+      commitSpy = sinon.spy(element, '_commit');
+    });
+
+    test('enter does not call focus', () => {
+      element._suggestions = ['sugar bombs'];
+      focusSpy = sinon.spy(element, 'focus');
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+          'enter');
+      flushAsynchronousOperations();
+
+      assert.isTrue(commitSpy.called);
+      assert.isFalse(focusSpy.called);
+      assert.equal(element._suggestions.length, 0);
+    });
+
+    test('tab in input, tabComplete = true', () => {
+      focusSpy = sinon.spy(element, 'focus');
+      const commitHandler = sinon.stub();
+      element.addEventListener('commit', commitHandler);
+      element.tabComplete = true;
+      element._suggestions = ['tunnel snakes drool'];
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      flushAsynchronousOperations();
+
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(focusSpy.called);
+      assert.isFalse(commitHandler.called);
+      assert.equal(element._suggestions.length, 0);
+    });
+
+    test('tab in input, tabComplete = false', () => {
+      element._suggestions = ['sugar bombs'];
+      focusSpy = sinon.spy(element, 'focus');
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+      flushAsynchronousOperations();
+
+      assert.isFalse(commitSpy.called);
+      assert.isFalse(focusSpy.called);
+      assert.equal(element._suggestions.length, 1);
+    });
+
+    test('tab on suggestion, tabComplete = false', () => {
+      element._suggestions = [{name: 'sugar bombs'}];
+      element._focused = true;
+      // When tabComplete is false, do not focus.
+      element.tabComplete = false;
+      focusSpy = sinon.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+          element.$.suggestions.shadowRoot
+              .querySelector('li:first-child'), 9, null, 'tab');
+      flushAsynchronousOperations();
+      assert.isFalse(commitSpy.called);
+      assert.isFalse(element._focused);
+    });
+
+    test('tab on suggestion, tabComplete = true', () => {
+      element._suggestions = [{name: 'sugar bombs'}];
+      element._focused = true;
+      // When tabComplete is true, focus.
+      element.tabComplete = true;
+      focusSpy = sinon.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(element.$.suggestions.isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+          element.$.suggestions.shadowRoot
+              .querySelector('li:first-child'), 9, null, 'tab');
+      flushAsynchronousOperations();
+
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(element._focused);
+    });
+
+    test('tap on suggestion commits, does not call focus', () => {
+      focusSpy = sinon.spy(element, 'focus');
+      element._focused = true;
+      element._suggestions = [{name: 'first suggestion'}];
+      flush$0();
+      assert.isFalse(element.$.suggestions.isHidden);
+      MockInteractions.tap(element.$.suggestions.shadowRoot
+          .querySelector('li:first-child'));
+      flushAsynchronousOperations();
+
+      assert.isFalse(focusSpy.called);
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(element.$.suggestions.isHidden);
+    });
+  });
+
+  test('input-keydown event fired', () => {
+    const listener = sinon.spy();
+    element.addEventListener('input-keydown', listener);
+    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+    flushAsynchronousOperations();
+    assert.isTrue(listener.called);
+  });
+
+  test('enter with modifier does not complete', () => {
+    const handleSpy = sinon.spy(element, '_handleKeydown');
+    const commitStub = sinon.stub(element, '_handleInputCommit');
+    MockInteractions.pressAndReleaseKeyOn(
+        element.$.input, 13, 'ctrl', 'enter');
+    assert.isTrue(handleSpy.called);
+    assert.isFalse(commitStub.called);
+    MockInteractions.pressAndReleaseKeyOn(
+        element.$.input, 13, null, 'enter');
+    assert.isTrue(commitStub.called);
+  });
+
+  suite('warnUncommitted', () => {
+    let inputClassList;
+    setup(() => {
+      inputClassList = element.$.input.classList;
+    });
+
+    test('enabled', () => {
+      element.warnUncommitted = true;
+      element.text = 'blah blah blah';
+      MockInteractions.blur(element.$.input);
+      assert.isTrue(inputClassList.contains('warnUncommitted'));
+      MockInteractions.focus(element.$.input);
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+
+    test('disabled', () => {
+      element.warnUncommitted = false;
+      element.text = 'blah blah blah';
+      MockInteractions.blur(element.$.input);
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+
+    test('no text', () => {
+      element.warnUncommitted = true;
+      element.text = '';
+      MockInteractions.blur(element.$.input);
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
index 75181b1..0d30179 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
@@ -14,27 +14,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../gr-js-api-interface/gr-js-api-interface.js';
 import '../gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-avatar_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 import {pluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrAvatar extends mixinBehaviors( [
-  BaseUrlBehavior,
-], GestureEventListeners(
+class GrAvatar extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-avatar'; }
@@ -103,7 +98,7 @@
         return avatars[i].url;
       }
     }
-    return this.getBaseUrl() + '/accounts/' +
+    return getBaseUrl() + '/accounts/' +
       encodeURIComponent(this._getAccounts(account)) +
       '/avatar?s=' + this.imageSize;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.js
deleted file mode 100644
index cc8a42f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: inline-block;
-      border-radius: 50%;
-      background-size: cover;
-      background-color: var(--avatar-background-color, #f1f2f3);
-    }
-  </style>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
new file mode 100644
index 0000000..0d8e78f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: inline-block;
+      border-radius: 50%;
+      background-size: cover;
+      background-color: var(--avatar-background-color, #f1f2f3);
+    }
+  </style>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
deleted file mode 100644
index dddc3d8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
+++ /dev/null
@@ -1,209 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-avatar</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-avatar></gr-avatar>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-avatar.js';
-import {pluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
-
-suite('gr-avatar tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('methods', () => {
-    assert.equal(
-        element._buildAvatarURL({
-          _account_id: 123,
-        }),
-        '/accounts/123/avatar?s=16');
-    assert.equal(
-        element._buildAvatarURL({
-          email: 'test@example.com',
-        }),
-        '/accounts/test%40example.com/avatar?s=16');
-    assert.equal(
-        element._buildAvatarURL({
-          name: 'John Doe',
-        }),
-        '/accounts/John%20Doe/avatar?s=16');
-    assert.equal(
-        element._buildAvatarURL({
-          username: 'John_Doe',
-        }),
-        '/accounts/John_Doe/avatar?s=16');
-    assert.equal(
-        element._buildAvatarURL({
-          _account_id: 123,
-          avatars: [
-            {
-              url: 'https://cdn.example.com/s12-p/photo.jpg',
-              height: 12,
-            },
-            {
-              url: 'https://cdn.example.com/s16-p/photo.jpg',
-              height: 16,
-            },
-            {
-              url: 'https://cdn.example.com/s100-p/photo.jpg',
-              height: 100,
-            },
-          ],
-        }),
-        'https://cdn.example.com/s16-p/photo.jpg');
-    assert.equal(
-        element._buildAvatarURL({
-          _account_id: 123,
-          avatars: [
-            {
-              url: 'https://cdn.example.com/s95-p/photo.jpg',
-              height: 95,
-            },
-          ],
-        }),
-        '/accounts/123/avatar?s=16');
-    assert.equal(element._buildAvatarURL(undefined), '');
-  });
-
-  test('dom for existing account', () => {
-    assert.isFalse(element.hasAttribute('hidden'));
-
-    sandbox.stub(
-        element,
-        '_getConfig',
-        () => Promise.resolve({plugin: {has_avatars: true}}));
-
-    element.imageSize = 64;
-    element.account = {
-      _account_id: 123,
-    };
-
-    assert.strictEqual(element.style.backgroundImage, '');
-
-    // Emulate plugins loaded.
-    pluginLoader.loadPlugins([]);
-
-    Promise.all([
-      element.$.restAPI.getConfig(),
-      pluginLoader.awaitPluginsLoaded(),
-    ]).then(() => {
-      assert.isFalse(element.hasAttribute('hidden'));
-
-      assert.isTrue(
-          element.style.backgroundImage.includes('/accounts/123/avatar?s=64'));
-    });
-  });
-
-  suite('plugin has avatars', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-
-      stub('gr-avatar', {
-        _getConfig: () => Promise.resolve({plugin: {has_avatars: true}}),
-      });
-
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('dom for non available account', () => {
-      assert.isFalse(element.hasAttribute('hidden'));
-
-      // Emulate plugins loaded.
-      pluginLoader.loadPlugins([]);
-
-      return Promise.all([
-        element.$.restAPI.getConfig(),
-        pluginLoader.awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isTrue(element.hasAttribute('hidden'));
-
-        assert.strictEqual(element.style.backgroundImage, '');
-      });
-    });
-  });
-
-  suite('config not set', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-
-      stub('gr-avatar', {
-        _getConfig: () => Promise.resolve({}),
-      });
-
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('avatar hidden when account set', () => {
-      flush(() => {
-        assert.isFalse(element.hasAttribute('hidden'));
-
-        element.imageSize = 64;
-        element.account = {
-          _account_id: 123,
-        };
-        // Emulate plugins loaded.
-        pluginLoader.loadPlugins([]);
-
-        return Promise.all([
-          element.$.restAPI.getConfig(),
-          pluginLoader.awaitPluginsLoaded(),
-        ]).then(() => {
-          assert.isTrue(element.hasAttribute('hidden'));
-        });
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
new file mode 100644
index 0000000..5e43f90
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
@@ -0,0 +1,178 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-avatar.js';
+import {pluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
+
+const basicFixture = fixtureFromElement('gr-avatar');
+
+suite('gr-avatar tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('methods', () => {
+    assert.equal(
+        element._buildAvatarURL({
+          _account_id: 123,
+        }),
+        '/accounts/123/avatar?s=16');
+    assert.equal(
+        element._buildAvatarURL({
+          email: 'test@example.com',
+        }),
+        '/accounts/test%40example.com/avatar?s=16');
+    assert.equal(
+        element._buildAvatarURL({
+          name: 'John Doe',
+        }),
+        '/accounts/John%20Doe/avatar?s=16');
+    assert.equal(
+        element._buildAvatarURL({
+          username: 'John_Doe',
+        }),
+        '/accounts/John_Doe/avatar?s=16');
+    assert.equal(
+        element._buildAvatarURL({
+          _account_id: 123,
+          avatars: [
+            {
+              url: 'https://cdn.example.com/s12-p/photo.jpg',
+              height: 12,
+            },
+            {
+              url: 'https://cdn.example.com/s16-p/photo.jpg',
+              height: 16,
+            },
+            {
+              url: 'https://cdn.example.com/s100-p/photo.jpg',
+              height: 100,
+            },
+          ],
+        }),
+        'https://cdn.example.com/s16-p/photo.jpg');
+    assert.equal(
+        element._buildAvatarURL({
+          _account_id: 123,
+          avatars: [
+            {
+              url: 'https://cdn.example.com/s95-p/photo.jpg',
+              height: 95,
+            },
+          ],
+        }),
+        '/accounts/123/avatar?s=16');
+    assert.equal(element._buildAvatarURL(undefined), '');
+  });
+
+  suite('config set', () => {
+    setup(() => {
+      stub('gr-avatar', {
+        _getConfig: () => Promise.resolve({plugin: {has_avatars: true}}),
+      });
+      element = basicFixture.instantiate();
+    });
+
+    test('dom for existing account', () => {
+      assert.isFalse(element.hasAttribute('hidden'));
+
+      element.imageSize = 64;
+      element.account = {
+        _account_id: 123,
+      };
+
+      assert.strictEqual(element.style.backgroundImage, '');
+
+      // Emulate plugins loaded.
+      pluginLoader.loadPlugins([]);
+
+      return Promise.all([
+        element.$.restAPI.getConfig(),
+        pluginLoader.awaitPluginsLoaded(),
+      ]).then(() => {
+        assert.isFalse(element.hasAttribute('hidden'));
+
+        assert.isTrue(
+            element.style.backgroundImage.includes(
+                '/accounts/123/avatar?s=64'));
+      });
+    });
+  });
+
+  suite('plugin has avatars', () => {
+    let element;
+
+    setup(() => {
+      stub('gr-avatar', {
+        _getConfig: () => Promise.resolve({plugin: {has_avatars: true}}),
+      });
+
+      element = basicFixture.instantiate();
+    });
+
+    test('dom for non available account', () => {
+      assert.isFalse(element.hasAttribute('hidden'));
+
+      // Emulate plugins loaded.
+      pluginLoader.loadPlugins([]);
+
+      return Promise.all([
+        element.$.restAPI.getConfig(),
+        pluginLoader.awaitPluginsLoaded(),
+      ]).then(() => {
+        assert.isTrue(element.hasAttribute('hidden'));
+
+        assert.strictEqual(element.style.backgroundImage, '');
+      });
+    });
+  });
+
+  suite('config not set', () => {
+    let element;
+
+    setup(() => {
+      stub('gr-avatar', {
+        _getConfig: () => Promise.resolve({}),
+      });
+
+      element = basicFixture.instantiate();
+    });
+
+    test('avatar hidden when account set', async () => {
+      await flush();
+      assert.isTrue(element.hasAttribute('hidden'));
+
+      element.imageSize = 64;
+      element.account = {
+        _account_id: 123,
+      };
+      // Emulate plugins loaded.
+      pluginLoader.loadPlugins([]);
+
+      return Promise.all([
+        element.$.restAPI.getConfig(),
+        pluginLoader.awaitPluginsLoaded(),
+      ]).then(() => {
+        assert.isTrue(element.hasAttribute('hidden'));
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
deleted file mode 100644
index 3169c56..0000000
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ /dev/null
@@ -1,132 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import '@polymer/paper-button/paper-button.js';
-import '../../../styles/shared-styles.js';
-import '../../core/gr-reporting/gr-reporting.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-button_html.js';
-import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {util} from '../../../scripts/util.js';
-
-/**
- * @extends Polymer.Element
- */
-class GrButton extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-  TooltipBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-button'; }
-
-  static get properties() {
-    return {
-      tooltip: String,
-      downArrow: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-      link: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      disabled: {
-        type: Boolean,
-        observer: '_disabledChanged',
-        reflectToAttribute: true,
-      },
-      noUppercase: {
-        type: Boolean,
-        value: false,
-      },
-      loading: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-
-      _disabled: {
-        type: Boolean,
-        computed: '_computeDisabled(disabled, loading)',
-      },
-
-      _initialTabindex: {
-        type: String,
-        value: '0',
-      },
-    };
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this._initialTabindex = this.getAttribute('tabindex') || '0';
-    this.addEventListener('click', e => this._handleAction(e));
-    this.addEventListener('keydown',
-        e => this._handleKeydown(e));
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('role', 'button');
-    this._ensureAttribute('tabindex', '0');
-  }
-
-  _handleAction(e) {
-    if (this._disabled) {
-      e.preventDefault();
-      e.stopPropagation();
-      e.stopImmediatePropagation();
-      return;
-    }
-
-    this.$.reporting.reportInteraction('button-click',
-        {path: util.getEventPath(e)});
-  }
-
-  _disabledChanged(disabled) {
-    this.setAttribute('tabindex', disabled ? '-1' : this._initialTabindex);
-    this.updateStyles();
-  }
-
-  _computeDisabled(disabled, loading) {
-    return disabled || loading;
-  }
-
-  _handleKeydown(e) {
-    if (this.modifierPressed(e)) { return; }
-    e = this.getKeyboardEvent(e);
-    // Handle `enter`, `space`.
-    if (e.keyCode === 13 || e.keyCode === 32) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.click();
-    }
-  }
-}
-
-customElements.define(GrButton.is, GrButton);
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
new file mode 100644
index 0000000..a0d8d13e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -0,0 +1,140 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@polymer/paper-button/paper-button';
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement, property, computed, observe} from '@polymer/decorators';
+import {htmlTemplate} from './gr-button_html';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+import {
+  KeyboardShortcutMixin,
+  CustomKeyboardEvent,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {PolymerEvent, getEventPath} from '../../../utils/dom-util';
+import {appContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-button': GrButton;
+  }
+}
+
+@customElement('gr-button')
+export class GrButton extends LegacyElementMixin(
+  KeyboardShortcutMixin(TooltipMixin(GestureEventListeners(PolymerElement)))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Boolean, reflectToAttribute: true})
+  downArrow = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  link = false;
+
+  @property({type: Boolean})
+  noUppercase = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  loading = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled: boolean | null = null;
+
+  @property({type: String})
+  tooltip = '';
+
+  // Note: don't assign a value to this, since constructor is called
+  // after created, the initial value maybe overriden by this
+  @property({type: String})
+  _initialTabindex?: string;
+
+  @computed('disabled', 'loading')
+  get _disabled() {
+    return this.disabled || this.loading;
+  }
+
+  @property({
+    computed: 'computeAriaDisable(disabled, loading)',
+    reflectToAttribute: true,
+    type: Boolean,
+  })
+  ariaDisabled!: boolean;
+
+  computeAriaDisabled() {
+    return this._disabled;
+  }
+
+  private readonly reporting: ReportingService = appContext.reportingService;
+
+  /** @override */
+  created() {
+    super.created();
+    this._initialTabindex = this.getAttribute('tabindex') || '0';
+    // TODO(TS): try avoid using unknown
+    this.addEventListener('click', e =>
+      this._handleAction((e as unknown) as PolymerEvent)
+    );
+    this.addEventListener('keydown', e =>
+      this._handleKeydown((e as unknown) as CustomKeyboardEvent)
+    );
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'button');
+    this._ensureAttribute('tabindex', '0');
+  }
+
+  _handleAction(e: PolymerEvent) {
+    if (this._disabled) {
+      e.preventDefault();
+      e.stopPropagation();
+      e.stopImmediatePropagation();
+      return;
+    }
+
+    this.reporting.reportInteraction('button-click', {path: getEventPath(e)});
+  }
+
+  @observe('disabled')
+  _disabledChanged(disabled: boolean) {
+    this.setAttribute(
+      'tabindex',
+      disabled ? '-1' : this._initialTabindex || '0'
+    );
+    this.updateStyles();
+  }
+
+  _handleKeydown(e: CustomKeyboardEvent) {
+    if (this.modifierPressed(e)) {
+      return;
+    }
+    e = this.getKeyboardEvent(e);
+    // Handle `enter`, `space`.
+    if (e.keyCode === 13 || e.keyCode === 32) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.click();
+    }
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.js
deleted file mode 100644
index db3b880..0000000
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.js
+++ /dev/null
@@ -1,177 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* general styles for all buttons */
-    :host {
-      --background-color: var(
-        --button-background-color,
-        var(--default-button-background-color)
-      );
-      --text-color: var(--default-button-text-color);
-      display: inline-block;
-      position: relative;
-    }
-    :host([hidden]) {
-      display: none;
-    }
-    :host([no-uppercase]) paper-button {
-      text-transform: none;
-    }
-    paper-button {
-      /* The next lines contains a copy of paper-button style.
-          Without a copy, the @apply works incorrectly with Polymer 2.
-          @apply is deprecated and is not recommended to use. It is expected
-          that @apply will be replaced with the ::part CSS pseudo-element.
-          After replacecment copied lines can be removed.
-        */
-      @apply --layout-inline;
-      @apply --layout-center-center;
-      position: relative;
-      box-sizing: border-box;
-      min-width: 5.14em;
-      margin: 0 0.29em;
-      background: transparent;
-      -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-      -webkit-tap-highlight-color: transparent;
-      font: inherit;
-      text-transform: uppercase;
-      outline-width: 0;
-      border-radius: var(--border-radius);
-      -moz-user-select: none;
-      -ms-user-select: none;
-      -webkit-user-select: none;
-      user-select: none;
-      cursor: pointer;
-      z-index: 0;
-      padding: var(--spacing-m);
-
-      @apply --paper-font-common-base;
-      @apply --paper-button;
-      /* End of copy*/
-
-      /* paper-button sets this to anti-aliased, which appears different than
-          bold font elsewhere on macOS. */
-      -webkit-font-smoothing: initial;
-      align-items: center;
-      background-color: var(--background-color);
-      color: var(--text-color);
-      display: flex;
-      font-family: inherit;
-      justify-content: center;
-      margin: var(--margin, 0);
-      min-width: var(--border, 0);
-      padding: var(--padding, 4px 8px);
-      @apply --gr-button;
-    }
-    /* https://github.com/PolymerElements/paper-button/blob/2.x/paper-button.html */
-    /* BEGIN: Copy from paper-button */
-    paper-button[elevation='1'] {
-      @apply --paper-material-elevation-1;
-    }
-    paper-button[elevation='2'] {
-      @apply --paper-material-elevation-2;
-    }
-    paper-button[elevation='3'] {
-      @apply --paper-material-elevation-3;
-    }
-    paper-button[elevation='4'] {
-      @apply --paper-material-elevation-4;
-    }
-    paper-button[elevation='5'] {
-      @apply --paper-material-elevation-5;
-    }
-    /* END: Copy from paper-button */
-    paper-button:hover {
-      background: linear-gradient(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.12)),
-        var(--background-color);
-    }
-
-    /* Some mobile browsers treat focused element as hovered element.
-      As a result, element remains hovered after click (has grey background in default theme).
-      Use @media (hover:none) to remove background if
-      user's primary input mechanism can't hover over elements.
-      See: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover
-
-      Note 1: not all browsers support this media query
-      (see https://caniuse.com/#feat=css-media-interaction).
-      If browser doesn't support it, then the whole content of @media .. is ignored.
-      This is why the default behavior is placed outside of @media.
-      */
-    @media (hover: none) {
-      paper-button:hover {
-        background: transparent;
-      }
-    }
-
-    :host([primary]) {
-      --background-color: var(--primary-button-background-color);
-      --text-color: var(--primary-button-text-color);
-    }
-    :host([link][primary]) {
-      --text-color: var(--primary-button-background-color);
-    }
-
-    /* Keep below color definition for primary so that this takes precedence
-        when disabled. */
-    :host([disabled]),
-    :host([loading]) {
-      --background-color: var(--disabled-button-background-color);
-      --text-color: var(--deemphasized-text-color);
-      cursor: default;
-    }
-
-    /* Styles for link buttons specifically */
-    :host([link]) {
-      --background-color: transparent;
-      --margin: 0;
-      --padding: 5px 4px;
-    }
-    :host([disabled][link]),
-    :host([loading][link]) {
-      --background-color: transparent;
-      --text-color: var(--deemphasized-text-color);
-      cursor: default;
-    }
-
-    /* Styles for the optional down arrow */
-    :host(:not([down-arrow])) .downArrow {
-      display: none;
-    }
-    :host([down-arrow]) .downArrow {
-      border-top: 0.36em solid #ccc;
-      border-left: 0.36em solid transparent;
-      border-right: 0.36em solid transparent;
-      margin-bottom: var(--spacing-xxs);
-      margin-left: var(--spacing-m);
-      transition: border-top-color 200ms;
-    }
-    :host([down-arrow]) paper-button:hover .downArrow {
-      border-top-color: var(--deemphasized-text-color);
-    }
-  </style>
-  <paper-button raised="[[!link]]" disabled="[[_disabled]]" tabindex="-1">
-    <template is="dom-if" if="[[loading]]">
-      <span class="loadingSpin"></span>
-    </template>
-    <slot></slot>
-    <i class="downArrow"></i>
-  </paper-button>
-  <gr-reporting id="reporting"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts
new file mode 100644
index 0000000..b272951
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts
@@ -0,0 +1,176 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* general styles for all buttons */
+    :host {
+      --background-color: var(
+        --button-background-color,
+        var(--default-button-background-color)
+      );
+      --text-color: var(--default-button-text-color);
+      display: inline-block;
+      position: relative;
+    }
+    :host([hidden]) {
+      display: none;
+    }
+    :host([no-uppercase]) paper-button {
+      text-transform: none;
+    }
+    paper-button {
+      /* The next lines contains a copy of paper-button style.
+          Without a copy, the @apply works incorrectly with Polymer 2.
+          @apply is deprecated and is not recommended to use. It is expected
+          that @apply will be replaced with the ::part CSS pseudo-element.
+          After replacement copied lines can be removed.
+        */
+      @apply --layout-inline;
+      @apply --layout-center-center;
+      position: relative;
+      box-sizing: border-box;
+      min-width: 5.14em;
+      margin: 0 0.29em;
+      background: transparent;
+      -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+      -webkit-tap-highlight-color: transparent;
+      font: inherit;
+      text-transform: uppercase;
+      outline-width: 0;
+      border-radius: var(--border-radius);
+      -moz-user-select: none;
+      -ms-user-select: none;
+      -webkit-user-select: none;
+      user-select: none;
+      cursor: pointer;
+      z-index: 0;
+      padding: var(--spacing-m);
+
+      @apply --paper-font-common-base;
+      @apply --paper-button;
+      /* End of copy*/
+
+      /* paper-button sets this to anti-aliased, which appears different than
+          bold font elsewhere on macOS. */
+      -webkit-font-smoothing: initial;
+      align-items: center;
+      background-color: var(--background-color);
+      color: var(--text-color);
+      display: flex;
+      font-family: inherit;
+      justify-content: center;
+      margin: var(--margin, 0);
+      min-width: var(--border, 0);
+      padding: var(--padding, 4px 8px);
+      @apply --gr-button;
+    }
+    /* https://github.com/PolymerElements/paper-button/blob/2.x/paper-button.html */
+    /* BEGIN: Copy from paper-button */
+    paper-button[elevation='1'] {
+      @apply --paper-material-elevation-1;
+    }
+    paper-button[elevation='2'] {
+      @apply --paper-material-elevation-2;
+    }
+    paper-button[elevation='3'] {
+      @apply --paper-material-elevation-3;
+    }
+    paper-button[elevation='4'] {
+      @apply --paper-material-elevation-4;
+    }
+    paper-button[elevation='5'] {
+      @apply --paper-material-elevation-5;
+    }
+    /* END: Copy from paper-button */
+    paper-button:hover {
+      background: linear-gradient(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.12)),
+        var(--background-color);
+    }
+
+    /* Some mobile browsers treat focused element as hovered element.
+      As a result, element remains hovered after click (has grey background in default theme).
+      Use @media (hover:none) to remove background if
+      user's primary input mechanism can't hover over elements.
+      See: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover
+
+      Note 1: not all browsers support this media query
+      (see https://caniuse.com/#feat=css-media-interaction).
+      If browser doesn't support it, then the whole content of @media .. is ignored.
+      This is why the default behavior is placed outside of @media.
+      */
+    @media (hover: none) {
+      paper-button:hover {
+        background: transparent;
+      }
+    }
+
+    :host([primary]) {
+      --background-color: var(--primary-button-background-color);
+      --text-color: var(--primary-button-text-color);
+    }
+    :host([link][primary]) {
+      --text-color: var(--primary-button-background-color);
+    }
+
+    /* Keep below color definition for primary so that this takes precedence
+        when disabled. */
+    :host([disabled]),
+    :host([loading]) {
+      --background-color: var(--disabled-button-background-color);
+      --text-color: var(--deemphasized-text-color);
+      cursor: default;
+    }
+
+    /* Styles for link buttons specifically */
+    :host([link]) {
+      --background-color: transparent;
+      --margin: 0;
+      --padding: var(--spacing-s);
+    }
+    :host([disabled][link]),
+    :host([loading][link]) {
+      --background-color: transparent;
+      --text-color: var(--deemphasized-text-color);
+      cursor: default;
+    }
+
+    /* Styles for the optional down arrow */
+    :host(:not([down-arrow])) .downArrow {
+      display: none;
+    }
+    :host([down-arrow]) .downArrow {
+      border-top: 0.36em solid #ccc;
+      border-left: 0.36em solid transparent;
+      border-right: 0.36em solid transparent;
+      margin-bottom: var(--spacing-xxs);
+      margin-left: var(--spacing-m);
+      transition: border-top-color 200ms;
+    }
+    :host([down-arrow]) paper-button:hover .downArrow {
+      border-top-color: var(--deemphasized-text-color);
+    }
+  </style>
+  <paper-button raised="[[!link]]" disabled="[[_disabled]]" tabindex="-1">
+    <template is="dom-if" if="[[loading]]">
+      <span class="loadingSpin"></span>
+    </template>
+    <slot></slot>
+    <i class="downArrow"></i>
+  </paper-button>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
deleted file mode 100644
index ae627d1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
+++ /dev/null
@@ -1,223 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-button</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-button></gr-button>
-  </template>
-</test-fixture>
-
-<test-fixture id="nested">
-  <template>
-    <div id="test">
-      <gr-button class="testBtn"></gr-button>
-    </div>
-  </template>
-</test-fixture>
-
-<test-fixture id="tabindex">
-  <template>
-    <gr-button tabindex="3"></gr-button>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-button.js';
-import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
-suite('gr-button tests', () => {
-  let element;
-  let sandbox;
-
-  const addSpyOn = function(eventName) {
-    const spy = sandbox.spy();
-    if (eventName == 'tap') {
-      addListener(element, eventName, spy);
-    } else {
-      element.addEventListener(eventName, spy);
-    }
-    return spy;
-  };
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('disabled is set by disabled', () => {
-    const paperBtn = element.shadowRoot.querySelector('paper-button');
-    assert.isFalse(paperBtn.disabled);
-    element.disabled = true;
-    assert.isTrue(paperBtn.disabled);
-    element.disabled = false;
-    assert.isFalse(paperBtn.disabled);
-  });
-
-  test('loading set from listener', done => {
-    let resolve;
-    element.addEventListener('click', e => {
-      e.target.loading = true;
-      resolve = () => e.target.loading = false;
-    });
-    const paperBtn = element.shadowRoot.querySelector('paper-button');
-    assert.isFalse(paperBtn.disabled);
-    MockInteractions.tap(element);
-    assert.isTrue(paperBtn.disabled);
-    assert.isTrue(element.hasAttribute('loading'));
-    resolve();
-    flush(() => {
-      assert.isFalse(paperBtn.disabled);
-      assert.isFalse(element.hasAttribute('loading'));
-      done();
-    });
-  });
-
-  test('tabindex should be -1 if disabled', () => {
-    element.disabled = true;
-    assert.isTrue(element.getAttribute('tabindex') === '-1');
-  });
-
-  // Regression tests for Issue: 11969
-  test('tabindex should be reset to 0 if enabled', () => {
-    element.disabled = false;
-    assert.equal(element.getAttribute('tabindex'), '0');
-    element.disabled = true;
-    assert.equal(element.getAttribute('tabindex'), '-1');
-    element.disabled = false;
-    assert.equal(element.getAttribute('tabindex'), '0');
-  });
-
-  test('tabindex should be preserved', () => {
-    element = fixture('tabindex');
-    element.disabled = false;
-    assert.equal(element.getAttribute('tabindex'), '3');
-    element.disabled = true;
-    assert.equal(element.getAttribute('tabindex'), '-1');
-    element.disabled = false;
-    assert.equal(element.getAttribute('tabindex'), '3');
-  });
-
-  // 'tap' event is tested so we don't loose backward compatibility with older
-  // plugins who didn't move to on-click which is faster and well supported.
-  test('dispatches click event', () => {
-    const spy = addSpyOn('click');
-    MockInteractions.click(element);
-    assert.isTrue(spy.calledOnce);
-  });
-
-  test('dispatches tap event', () => {
-    const spy = addSpyOn('tap');
-    MockInteractions.tap(element);
-    assert.isTrue(spy.calledOnce);
-  });
-
-  test('dispatches click from tap event', () => {
-    const spy = addSpyOn('click');
-    MockInteractions.tap(element);
-    assert.isTrue(spy.calledOnce);
-  });
-
-  // Keycodes: 32 for Space, 13 for Enter.
-  for (const key of [32, 13]) {
-    test('dispatches click event on keycode ' + key, () => {
-      const tapSpy = sandbox.spy();
-      element.addEventListener('click', tapSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, key);
-      assert.isTrue(tapSpy.calledOnce);
-    });
-
-    test('dispatches no click event with modifier on keycode ' + key, () => {
-      const tapSpy = sandbox.spy();
-      element.addEventListener('click', tapSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, key, 'shift');
-      MockInteractions.pressAndReleaseKeyOn(element, key, 'ctrl');
-      MockInteractions.pressAndReleaseKeyOn(element, key, 'meta');
-      MockInteractions.pressAndReleaseKeyOn(element, key, 'alt');
-      assert.isFalse(tapSpy.calledOnce);
-    });
-  }
-
-  suite('disabled', () => {
-    setup(() => {
-      element.disabled = true;
-    });
-
-    for (const eventName of ['tap', 'click']) {
-      test('stops ' + eventName + ' event', () => {
-        const spy = addSpyOn(eventName);
-        MockInteractions.tap(element);
-        assert.isFalse(spy.called);
-      });
-    }
-
-    // Keycodes: 32 for Space, 13 for Enter.
-    for (const key of [32, 13]) {
-      test('stops click event on keycode ' + key, () => {
-        const tapSpy = sandbox.spy();
-        element.addEventListener('click', tapSpy);
-        MockInteractions.pressAndReleaseKeyOn(element, key);
-        assert.isFalse(tapSpy.called);
-      });
-    }
-  });
-
-  suite('reporting', () => {
-    const reportStub = sinon.stub();
-    setup(() => {
-      stub('gr-reporting', {
-        reportInteraction: (...args) => {
-          reportStub(...args);
-        },
-      });
-      reportStub.reset();
-    });
-
-    test('report event after click', () => {
-      MockInteractions.click(element);
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'button-click');
-      assert.deepEqual(reportStub.lastCall.args[1], {
-        path: 'html>body>test-fixture#basic>gr-button',
-      });
-    });
-
-    test('report event after click on nested', () => {
-      element = fixture('nested');
-      MockInteractions.click(element.querySelector('gr-button'));
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'button-click');
-      assert.deepEqual(reportStub.lastCall.args[1], {
-        path: 'html>body>test-fixture#nested>div#test>gr-button.testBtn',
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js
new file mode 100644
index 0000000..4bc3bea
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js
@@ -0,0 +1,199 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-button.js';
+import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
+import {appContext} from '../../../services/app-context.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromElement('gr-button');
+
+const nestedFixture = fixtureFromTemplate(html`
+<div id="test">
+  <gr-button class="testBtn"></gr-button>
+</div>
+`);
+
+const tabindexFixture = fixtureFromTemplate(html`
+  <gr-button tabindex="3"></gr-button>
+`);
+
+suite('gr-button tests', () => {
+  let element;
+
+  const addSpyOn = function(eventName) {
+    const spy = sinon.spy();
+    if (eventName == 'tap') {
+      addListener(element, eventName, spy);
+    } else {
+      element.addEventListener(eventName, spy);
+    }
+    return spy;
+  };
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('disabled is set by disabled', () => {
+    const paperBtn = element.shadowRoot.querySelector('paper-button');
+    assert.isFalse(paperBtn.disabled);
+    element.disabled = true;
+    assert.isTrue(paperBtn.disabled);
+    element.disabled = false;
+    assert.isFalse(paperBtn.disabled);
+  });
+
+  test('loading set from listener', done => {
+    let resolve;
+    element.addEventListener('click', e => {
+      e.target.loading = true;
+      resolve = () => e.target.loading = false;
+    });
+    const paperBtn = element.shadowRoot.querySelector('paper-button');
+    assert.isFalse(paperBtn.disabled);
+    MockInteractions.tap(element);
+    assert.isTrue(paperBtn.disabled);
+    assert.isTrue(element.hasAttribute('loading'));
+    resolve();
+    flush(() => {
+      assert.isFalse(paperBtn.disabled);
+      assert.isFalse(element.hasAttribute('loading'));
+      done();
+    });
+  });
+
+  test('tabindex should be -1 if disabled', () => {
+    element.disabled = true;
+    assert.isTrue(element.getAttribute('tabindex') === '-1');
+  });
+
+  // Regression tests for Issue: 11969
+  test('tabindex should be reset to 0 if enabled', () => {
+    element.disabled = false;
+    assert.equal(element.getAttribute('tabindex'), '0');
+    element.disabled = true;
+    assert.equal(element.getAttribute('tabindex'), '-1');
+    element.disabled = false;
+    assert.equal(element.getAttribute('tabindex'), '0');
+  });
+
+  test('tabindex should be preserved', () => {
+    element = tabindexFixture.instantiate();
+    element.disabled = false;
+    assert.equal(element.getAttribute('tabindex'), '3');
+    element.disabled = true;
+    assert.equal(element.getAttribute('tabindex'), '-1');
+    element.disabled = false;
+    assert.equal(element.getAttribute('tabindex'), '3');
+  });
+
+  // 'tap' event is tested so we don't loose backward compatibility with older
+  // plugins who didn't move to on-click which is faster and well supported.
+  test('dispatches click event', () => {
+    const spy = addSpyOn('click');
+    MockInteractions.click(element);
+    assert.isTrue(spy.calledOnce);
+  });
+
+  test('dispatches tap event', () => {
+    const spy = addSpyOn('tap');
+    MockInteractions.tap(element);
+    assert.isTrue(spy.calledOnce);
+  });
+
+  test('dispatches click from tap event', () => {
+    const spy = addSpyOn('click');
+    MockInteractions.tap(element);
+    assert.isTrue(spy.calledOnce);
+  });
+
+  // Keycodes: 32 for Space, 13 for Enter.
+  for (const key of [32, 13]) {
+    test('dispatches click event on keycode ' + key, () => {
+      const tapSpy = sinon.spy();
+      element.addEventListener('click', tapSpy);
+      MockInteractions.pressAndReleaseKeyOn(element, key);
+      assert.isTrue(tapSpy.calledOnce);
+    });
+
+    test('dispatches no click event with modifier on keycode ' + key, () => {
+      const tapSpy = sinon.spy();
+      element.addEventListener('click', tapSpy);
+      MockInteractions.pressAndReleaseKeyOn(element, key, 'shift');
+      MockInteractions.pressAndReleaseKeyOn(element, key, 'ctrl');
+      MockInteractions.pressAndReleaseKeyOn(element, key, 'meta');
+      MockInteractions.pressAndReleaseKeyOn(element, key, 'alt');
+      assert.isFalse(tapSpy.calledOnce);
+    });
+  }
+
+  suite('disabled', () => {
+    setup(() => {
+      element.disabled = true;
+    });
+
+    for (const eventName of ['tap', 'click']) {
+      test('stops ' + eventName + ' event', () => {
+        const spy = addSpyOn(eventName);
+        MockInteractions.tap(element);
+        assert.isFalse(spy.called);
+      });
+    }
+
+    // Keycodes: 32 for Space, 13 for Enter.
+    for (const key of [32, 13]) {
+      test('stops click event on keycode ' + key, () => {
+        const tapSpy = sinon.spy();
+        element.addEventListener('click', tapSpy);
+        MockInteractions.pressAndReleaseKeyOn(element, key);
+        assert.isFalse(tapSpy.called);
+      });
+    }
+  });
+
+  suite('reporting', () => {
+    let reportStub;
+    setup(() => {
+      reportStub = sinon.stub(appContext.reportingService,
+          'reportInteraction');
+      reportStub.reset();
+    });
+
+    test('report event after click', () => {
+      MockInteractions.click(element);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'button-click');
+      assert.deepEqual(reportStub.lastCall.args[1], {
+        path: `html>body>test-fixture#${basicFixture.fixtureId}>gr-button`,
+      });
+    });
+
+    test('report event after click on nested', () => {
+      element = nestedFixture.instantiate();
+      MockInteractions.click(element.querySelector('gr-button'));
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'button-click');
+      assert.deepEqual(reportStub.lastCall.args[1], {
+        path: `html>body>test-fixture#${nestedFixture.fixtureId}` +
+            `>div#test>gr-button.testBtn`,
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
index 10e06dd..dac1755 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../gr-icons/gr-icons.js';
 import '../../../styles/shared-styles.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
@@ -23,7 +21,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-change-star_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrChangeStar extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -55,6 +53,10 @@
     return `gr-icons:star${starred ? '' : '-border'}`;
   }
 
+  _computeAriaLabel(starred) {
+    return starred ? 'Unstar this change' : 'Star this change';
+  }
+
   toggleStar() {
     const newVal = !this.change.starred;
     this.set('change.starred', newVal);
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js
deleted file mode 100644
index f723717a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    button {
-      background-color: transparent;
-      cursor: pointer;
-    }
-    iron-icon.active {
-      fill: var(--link-color);
-    }
-    iron-icon {
-      vertical-align: top;
-      --iron-icon-height: var(
-        --gr-change-star-size,
-        var(--line-height-normal, 20px)
-      );
-      --iron-icon-width: var(
-        --gr-change-star-size,
-        var(--line-height-normal, 20px)
-      );
-    }
-  </style>
-  <button aria-label="Change star" on-click="toggleStar">
-    <iron-icon
-      class$="[[_computeStarClass(change.starred)]]"
-      icon$="[[_computeStarIcon(change.starred)]]"
-    ></iron-icon>
-  </button>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
new file mode 100644
index 0000000..c7930c0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    button {
+      background-color: transparent;
+      cursor: pointer;
+    }
+    iron-icon.active {
+      fill: var(--link-color);
+    }
+    iron-icon {
+      vertical-align: top;
+      --iron-icon-height: var(
+        --gr-change-star-size,
+        var(--line-height-normal, 20px)
+      );
+      --iron-icon-width: var(
+        --gr-change-star-size,
+        var(--line-height-normal, 20px)
+      );
+    }
+  </style>
+  <button
+    role="checkbox"
+    aria-label="[[_computeAriaLabel(change.starred)]]]"
+    on-click="toggleStar"
+  >
+    <iron-icon
+      class$="[[_computeStarClass(change.starred)]]"
+      icon$="[[_computeStarIcon(change.starred)]]"
+    ></iron-icon>
+  </button>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
deleted file mode 100644
index 1ea9071..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
+++ /dev/null
@@ -1,82 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-change-star</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-star></gr-change-star>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-change-star.js';
-suite('gr-change-star tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-    element.change = {
-      _number: 2,
-      starred: true,
-    };
-  });
-
-  test('star visibility states', () => {
-    element.set('change.starred', true);
-    let icon = element.shadowRoot
-        .querySelector('iron-icon');
-    assert.isTrue(icon.classList.contains('active'));
-    assert.equal(icon.icon, 'gr-icons:star');
-
-    element.set('change.starred', false);
-    icon = element.shadowRoot
-        .querySelector('iron-icon');
-    assert.isFalse(icon.classList.contains('active'));
-    assert.equal(icon.icon, 'gr-icons:star-border');
-  });
-
-  test('starring', done => {
-    element.addEventListener('toggle-star', () => {
-      assert.equal(element.change.starred, true);
-      done();
-    });
-    element.set('change.starred', false);
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('button'));
-  });
-
-  test('unstarring', done => {
-    element.addEventListener('toggle-star', () => {
-      assert.equal(element.change.starred, false);
-      done();
-    });
-    element.set('change.starred', true);
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('button'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js
new file mode 100644
index 0000000..d479ece
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-star.js';
+
+const basicFixture = fixtureFromElement('gr-change-star');
+
+suite('gr-change-star tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.change = {
+      _number: 2,
+      starred: true,
+    };
+  });
+
+  test('star visibility states', () => {
+    element.set('change.starred', true);
+    let icon = element.shadowRoot
+        .querySelector('iron-icon');
+    assert.isTrue(icon.classList.contains('active'));
+    assert.equal(icon.icon, 'gr-icons:star');
+
+    element.set('change.starred', false);
+    icon = element.shadowRoot
+        .querySelector('iron-icon');
+    assert.isFalse(icon.classList.contains('active'));
+    assert.equal(icon.icon, 'gr-icons:star-border');
+  });
+
+  test('starring', done => {
+    element.addEventListener('toggle-star', () => {
+      assert.equal(element.change.starred, true);
+      done();
+    });
+    element.set('change.starred', false);
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('button'));
+  });
+
+  test('unstarring', done => {
+    element.addEventListener('toggle-star', () => {
+      assert.equal(element.change.starred, false);
+      done();
+    });
+    element.set('change.starred', true);
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('button'));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
index b99612e..915d171 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-tooltip-content/gr-tooltip-content.js';
 import '../../../styles/shared-styles.js';
@@ -43,7 +41,7 @@
 const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
     'current reviewers (or anyone with "View Private Changes" permission).';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrChangeStatus extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js
deleted file mode 100644
index 904ef1d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.js
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .chip {
-      border-radius: var(--border-radius);
-      background-color: var(--chip-background-color);
-      padding: 0 var(--spacing-m);
-      white-space: nowrap;
-    }
-    :host(.merged) .chip {
-      background-color: #5b9d52;
-      color: #5b9d52;
-    }
-    :host(.abandoned) .chip {
-      background-color: #afafaf;
-      color: #afafaf;
-    }
-    :host(.wip) .chip {
-      background-color: #8f756c;
-      color: #8f756c;
-    }
-    :host(.private) .chip {
-      background-color: #c17ccf;
-      color: #c17ccf;
-    }
-    :host(.merge-conflict) .chip {
-      background-color: #dc5c60;
-      color: #dc5c60;
-    }
-    :host(.active) .chip {
-      background-color: #29b6f6;
-      color: #29b6f6;
-    }
-    :host(.ready-to-submit) .chip {
-      background-color: #e10ca3;
-      color: #e10ca3;
-    }
-    :host(.custom) .chip {
-      background-color: #825cc2;
-      color: #825cc2;
-    }
-    :host([flat]) .chip {
-      background-color: transparent;
-      padding: 0;
-    }
-    :host(:not([flat])) .chip {
-      color: white;
-    }
-  </style>
-  <gr-tooltip-content
-    has-tooltip=""
-    position-below=""
-    title="[[tooltipText]]"
-    max-width="40em"
-  >
-    <div class="chip" aria-label$="Label: [[status]]">
-      [[_computeStatusString(status)]]
-    </div>
-  </gr-tooltip-content>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
new file mode 100644
index 0000000..542d8be
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .chip {
+      border-radius: var(--border-radius);
+      background-color: var(--chip-background-color);
+      padding: 0 var(--spacing-m);
+      white-space: nowrap;
+    }
+    :host(.merged) .chip {
+      background-color: var(--status-merged);
+      color: var(--status-merged);
+    }
+    :host(.abandoned) .chip {
+      background-color: var(--status-abandoned);
+      color: var(--status-abandoned);
+    }
+    :host(.wip) .chip {
+      background-color: var(--status-wip);
+      color: var(--status-wip);
+    }
+    :host(.private) .chip {
+      background-color: var(--status-private);
+      color: var(--status-private);
+    }
+    :host(.merge-conflict) .chip {
+      background-color: var(--status-conflict);
+      color: var(--status-conflict);
+    }
+    :host(.active) .chip {
+      background-color: var(--status-active);
+      color: var(--status-active);
+    }
+    :host(.ready-to-submit) .chip {
+      background-color: var(--status-ready);
+      color: var(--status-ready);
+    }
+    :host(.custom) .chip {
+      background-color: var(--status-custom);
+      color: var(--status-custom);
+    }
+    :host([flat]) .chip {
+      background-color: transparent;
+      padding: 0;
+    }
+    :host(:not([flat])) .chip {
+      color: var(--status-text-color);
+    }
+  </style>
+  <gr-tooltip-content
+    has-tooltip=""
+    position-below=""
+    title="[[tooltipText]]"
+    max-width="40em"
+  >
+    <div class="chip" aria-label$="Label: [[status]]">
+      [[_computeStatusString(status)]]
+    </div>
+  </gr-tooltip-content>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
deleted file mode 100644
index 806203b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.html
+++ /dev/null
@@ -1,137 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-change-status</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-status></gr-change-status>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-change-status.js';
-const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
-    'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
-    'and email notifications will be silenced until the review is started.';
-
-const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
-  'Download the patch and run "git rebase master". ' +
-  'Upload a new patchset after resolving all merge conflicts.';
-
-const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
-    'current reviewers (or anyone with "View Private Changes" permission).';
-
-suite('gr-change-status tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('WIP', () => {
-    element.status = 'WIP';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, 'Work in Progress');
-    assert.equal(element.tooltipText, WIP_TOOLTIP);
-    assert.isTrue(element.classList.contains('wip'));
-  });
-
-  test('WIP flat', () => {
-    element.flat = true;
-    element.status = 'WIP';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, 'WIP');
-    assert.isDefined(element.tooltipText);
-    assert.isTrue(element.classList.contains('wip'));
-    assert.isTrue(element.hasAttribute('flat'));
-  });
-
-  test('merged', () => {
-    element.status = 'Merged';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('merged'));
-  });
-
-  test('abandoned', () => {
-    element.status = 'Abandoned';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('abandoned'));
-  });
-
-  test('merge conflict', () => {
-    element.status = 'Merge Conflict';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
-    assert.isTrue(element.classList.contains('merge-conflict'));
-  });
-
-  test('private', () => {
-    element.status = 'Private';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
-    assert.isTrue(element.classList.contains('private'));
-  });
-
-  test('active', () => {
-    element.status = 'Active';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('active'));
-  });
-
-  test('ready to submit', () => {
-    element.status = 'Ready to submit';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('ready-to-submit'));
-  });
-
-  test('updating status removes the previous class', () => {
-    element.status = 'Private';
-    assert.isTrue(element.classList.contains('private'));
-    assert.isFalse(element.classList.contains('wip'));
-
-    element.status = 'WIP';
-    assert.isFalse(element.classList.contains('private'));
-    assert.isTrue(element.classList.contains('wip'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
new file mode 100644
index 0000000..770a21c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-change-status.js';
+
+const basicFixture = fixtureFromElement('gr-change-status');
+
+const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
+    'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
+    'and email notifications will be silenced until the review is started.';
+
+const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
+  'Download the patch and run "git rebase master". ' +
+  'Upload a new patchset after resolving all merge conflicts.';
+
+const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
+    'current reviewers (or anyone with "View Private Changes" permission).';
+
+suite('gr-change-status tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('WIP', () => {
+    element.status = 'WIP';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, 'Work in Progress');
+    assert.equal(element.tooltipText, WIP_TOOLTIP);
+    assert.isTrue(element.classList.contains('wip'));
+  });
+
+  test('WIP flat', () => {
+    element.flat = true;
+    element.status = 'WIP';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, 'WIP');
+    assert.isDefined(element.tooltipText);
+    assert.isTrue(element.classList.contains('wip'));
+    assert.isTrue(element.hasAttribute('flat'));
+  });
+
+  test('merged', () => {
+    element.status = 'Merged';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('merged'));
+  });
+
+  test('abandoned', () => {
+    element.status = 'Abandoned';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('abandoned'));
+  });
+
+  test('merge conflict', () => {
+    element.status = 'Merge Conflict';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
+    assert.isTrue(element.classList.contains('merge-conflict'));
+  });
+
+  test('private', () => {
+    element.status = 'Private';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
+    assert.isTrue(element.classList.contains('private'));
+  });
+
+  test('active', () => {
+    element.status = 'Active';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('active'));
+  });
+
+  test('ready to submit', () => {
+    element.status = 'Ready to submit';
+    assert.equal(element.shadowRoot
+        .querySelector('.chip').innerText, element.status);
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('ready-to-submit'));
+  });
+
+  test('updating status removes the previous class', () => {
+    element.status = 'Private';
+    assert.isTrue(element.classList.contains('private'));
+    assert.isFalse(element.classList.contains('wip'));
+
+    element.status = 'WIP';
+    assert.isFalse(element.classList.contains('private'));
+    assert.isTrue(element.classList.contains('wip'));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
index ccfc44c..d6071a5 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
@@ -14,39 +14,33 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
-import '../../core/gr-reporting/gr-reporting.js';
 import '../gr-rest-api-interface/gr-rest-api-interface.js';
 import '../gr-storage/gr-storage.js';
 import '../gr-comment/gr-comment.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-comment-thread_html.js';
-import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
-import {util} from '../../../scripts/util.js';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {parseDate} from '../../../utils/date-util.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {appContext} from '../../../services/app-context.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
+import {computeDisplayPath} from '../../../utils/path-list-util.js';
+import {KnownExperimentId} from '../../../services/flags/flags.js';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrCommentThread extends mixinBehaviors( [
-  /**
-   * Not used in this element rather other elements tests
-   */
-  KeyboardShortcutBehavior,
-  PathListBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrCommentThread extends KeyboardShortcutMixin(GestureEventListeners(
+    LegacyElementMixin(PolymerElement))) {
+  // KeyboardShortcutMixin Not used in this element rather other elements tests
+
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-comment-thread'; }
@@ -156,6 +150,14 @@
         value: false,
         reflectToAttribute: true,
       },
+      showFileName: {
+        type: Boolean,
+        value: true,
+      },
+      showPatchset: {
+        type: Boolean,
+        value: true,
+      },
     };
   }
 
@@ -171,6 +173,12 @@
     };
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+    this.flagsService = appContext.flagsService;
+  }
+
   /** @override */
   created() {
     super.created();
@@ -184,6 +192,8 @@
     this._getLoggedIn().then(loggedIn => {
       this._showActions = loggedIn;
     });
+    this._isChangeCommentsLinkExperimentEnabled = this.flagsService
+        .isEnabled(KnownExperimentId.PATCHSET_CHOICE_FOR_COMMENT_LINKS);
     this._setInitialExpandedState();
   }
 
@@ -217,15 +227,50 @@
         {detail: {rootId: this.rootId}, bubbles: false}));
   }
 
+  _getDiffUrlForPath(path) {
+    if (!this._isChangeCommentsLinkExperimentEnabled ||
+      this.comments[0].__draft) {
+      return GerritNav.getUrlForDiffById(this.changeNum,
+          this.projectName, path, this.patchNum);
+    }
+    return GerritNav.getUrlForComment(this.changeNum, this.projectName,
+        this.comments[0].id);
+  }
+
   _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
-    return GerritNav.getUrlForDiffById(changeNum,
-        projectName, path, patchNum,
-        null, this.lineNum);
+    if (!this._isChangeCommentsLinkExperimentEnabled ||
+      (this.comments.length && this.comments[0].side === 'PARENT') ||
+      this.comments[0].__draft) {
+      return GerritNav.getUrlForDiffById(changeNum,
+          projectName, path, patchNum, null, this.lineNum);
+    }
+    return GerritNav.getUrlForComment(this.changeNum, this.projectName,
+        this.comments[0].id);
+  }
+
+  _isPatchsetLevelComment(path) {
+    return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
   }
 
   _computeDisplayPath(path) {
-    const lineString = this.lineNum ? `#${this.lineNum}` : '';
-    return this.computeDisplayPath(path) + lineString;
+    const displayPath = computeDisplayPath(path);
+    if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+      return `Patchset`;
+    }
+    return displayPath;
+  }
+
+  _computeDisplayLine() {
+    if (this.lineNum) return `#${this.lineNum}`;
+    // If range is set, then lineNum equals the end line of the range.
+    if (!this.lineNum && !this.range) {
+      if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+        return '';
+      }
+      return 'FILE';
+    }
+    if (this.range) return `#${this.range.end_line}`;
+    return '';
   }
 
   _getLoggedIn() {
@@ -304,8 +349,8 @@
 
   _sortedComments(comments) {
     return comments.slice().sort((c1, c2) => {
-      const c1Date = c1.__date || util.parseDate(c1.updated);
-      const c2Date = c2.__date || util.parseDate(c2.updated);
+      const c1Date = c1.__date || parseDate(c1.updated);
+      const c2Date = c2.__date || parseDate(c2.updated);
       const dateCompare = c1Date - c2Date;
       // Ensure drafts are at the end. There should only be one but in edge
       // cases could be more. In the unlikely event two drafts are being
@@ -318,15 +363,13 @@
     });
   }
 
-  _createReplyComment(parent, content, opt_isEditing,
+  _createReplyComment(content, opt_isEditing,
       opt_unresolved) {
-    this.$.reporting.recordDraftInteraction();
+    this.reporting.recordDraftInteraction();
     const reply = this._newReply(
         this._orderedComments[this._orderedComments.length - 1].id,
-        parent.line,
         content,
-        opt_unresolved,
-        parent.range);
+        opt_unresolved);
 
     // If there is currently a comment in an editing state, add an attribute
     // so that the gr-comment knows not to populate the draft text.
@@ -366,25 +409,23 @@
       const msg = comment.message;
       quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
     }
-    this._createReplyComment(comment, quoteStr, true, comment.unresolved);
+    this._createReplyComment(quoteStr, true, comment.unresolved);
   }
 
-  _handleCommentReply(e) {
+  _handleCommentReply() {
     this._processCommentReply();
   }
 
-  _handleCommentQuote(e) {
+  _handleCommentQuote() {
     this._processCommentReply(true);
   }
 
-  _handleCommentAck(e) {
-    const comment = this._lastComment;
-    this._createReplyComment(comment, 'Ack', false, false);
+  _handleCommentAck() {
+    this._createReplyComment('Ack', false, false);
   }
 
-  _handleCommentDone(e) {
-    const comment = this._lastComment;
-    this._createReplyComment(comment, 'Done', false, false);
+  _handleCommentDone() {
+    this._createReplyComment('Done', false, false);
   }
 
   _handleCommentFix(e) {
@@ -392,7 +433,7 @@
     const msg = comment.message;
     const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
     const response = quoteStr + 'Please fix.';
-    this._createReplyComment(comment, response, false, true);
+    this._createReplyComment(response, false, true);
   }
 
   _commentElWithDraftID(id) {
@@ -405,11 +446,9 @@
     return null;
   }
 
-  _newReply(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
-      opt_range) {
-    const d = this._newDraft(opt_lineNum);
+  _newReply(inReplyTo, opt_message, opt_unresolved) {
+    const d = this._newDraft();
     d.in_reply_to = inReplyTo;
-    d.range = opt_range;
     if (opt_message != null) {
       d.message = opt_message;
     }
@@ -428,19 +467,40 @@
       __draft: true,
       __draftID: Math.random().toString(36),
       __date: new Date(),
-      path: this.path,
-      patchNum: this.patchNum,
-      side: this._getSide(this.isOnParent),
-      __commentSide: this.commentSide,
     };
-    if (opt_lineNum) {
-      d.line = opt_lineNum;
-    }
-    if (opt_range) {
-      d.range = opt_range;
-    }
-    if (this.parentIndex) {
-      d.parent = this.parentIndex;
+
+    // For replies, always use same meta info as root.
+    if (this.comments && this.comments.length >= 1) {
+      const rootComment = this.comments[0];
+      [
+        'path',
+        'patchNum',
+        'side',
+        '__commentSide',
+        'line',
+        'range',
+        'parent',
+      ].forEach(key => {
+        if (rootComment.hasOwnProperty(key)) {
+          d[key] = rootComment[key];
+        }
+      });
+    } else {
+      // Set meta info for root comment.
+      d.path = this.path;
+      d.patchNum = this.patchNum;
+      d.side = this._getSide(this.isOnParent);
+      d.__commentSide = this.commentSide;
+
+      if (opt_lineNum) {
+        d.line = opt_lineNum;
+      }
+      if (opt_range) {
+        d.range = opt_range;
+      }
+      if (this.parentIndex) {
+        d.parent = this.parentIndex;
+      }
     }
     return d;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
deleted file mode 100644
index fbc18b4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.js
+++ /dev/null
@@ -1,154 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      font-family: var(--font-family);
-      font-size: var(--font-size-normal);
-      font-weight: var(--font-weight-normal);
-      line-height: var(--line-height-normal);
-    }
-    gr-button {
-      margin-left: var(--spacing-m);
-    }
-    gr-comment:not(:last-of-type) {
-      border-bottom: 1px solid var(--comment-separator-color);
-    }
-    #actions {
-      margin-left: auto;
-      padding: var(--spacing-m);
-    }
-    #container {
-      background-color: var(--comment-background-color);
-      color: var(--comment-text-color);
-      display: block;
-      margin: 0 var(--spacing-s) var(--spacing-s);
-      white-space: normal;
-      box-shadow: var(--elevation-level-2);
-      border-radius: var(--border-radius);
-      /** This is required for firefox to continue the inheritance */
-      -webkit-user-select: inherit;
-      -moz-user-select: inherit;
-      -ms-user-select: inherit;
-      user-select: inherit;
-    }
-    #container.unresolved {
-      background-color: var(--unresolved-comment-background-color);
-    }
-    #container.robotComment {
-      background-color: var(--robot-comment-background-color);
-    }
-    #commentInfoContainer {
-      border-top: 1px dotted var(--border-color);
-      display: flex;
-    }
-    #unresolvedLabel {
-      font-family: var(--font-family);
-      margin: auto 0;
-      padding: var(--spacing-m);
-    }
-    .pathInfo {
-      display: flex;
-      align-items: baseline;
-      justify-content: space-between;
-      padding: 0 var(--spacing-s) var(--spacing-s);
-    }
-    .descriptionText {
-      margin-left: var(--spacing-m);
-      font-style: italic;
-    }
-  </style>
-  <template is="dom-if" if="[[showFilePath]]">
-    <div class="pathInfo">
-      <a
-        href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
-        >[[_computeDisplayPath(path)]]</a
-      >
-      <span class="descriptionText">Patchset [[patchNum]]</span>
-    </div>
-  </template>
-  <div
-    id="container"
-    class$="[[_computeHostClass(unresolved, isRobotComment)]]"
-  >
-    <template
-      id="commentList"
-      is="dom-repeat"
-      items="[[_orderedComments]]"
-      as="comment"
-    >
-      <gr-comment
-        comment="{{comment}}"
-        comments="{{comments}}"
-        robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
-        change-num="[[changeNum]]"
-        patch-num="[[patchNum]]"
-        draft="[[_isDraft(comment)]]"
-        show-actions="[[_showActions]]"
-        comment-side="[[comment.__commentSide]]"
-        side="[[comment.side]]"
-        project-config="[[_projectConfig]]"
-        on-create-fix-comment="_handleCommentFix"
-        on-comment-discard="_handleCommentDiscard"
-        on-comment-save="_handleCommentSavedOrDiscarded"
-      ></gr-comment>
-    </template>
-    <div
-      id="commentInfoContainer"
-      hidden$="[[_hideActions(_showActions, _lastComment)]]"
-    >
-      <span id="unresolvedLabel" hidden$="[[!unresolved]]">Unresolved</span>
-      <div id="actions">
-        <gr-button
-          id="replyBtn"
-          link=""
-          class="action reply"
-          on-click="_handleCommentReply"
-          >Reply</gr-button
-        >
-        <gr-button
-          id="quoteBtn"
-          link=""
-          class="action quote"
-          on-click="_handleCommentQuote"
-          >Quote</gr-button
-        >
-        <template is="dom-if" if="[[unresolved]]">
-          <gr-button
-            id="ackBtn"
-            link=""
-            class="action ack"
-            on-click="_handleCommentAck"
-            >Ack</gr-button
-          >
-          <gr-button
-            id="doneBtn"
-            link=""
-            class="action done"
-            on-click="_handleCommentDone"
-            >Done</gr-button
-          >
-        </template>
-      </div>
-    </div>
-  </div>
-  <gr-reporting id="reporting"></gr-reporting>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
new file mode 100644
index 0000000..4c15383
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
@@ -0,0 +1,169 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      font-family: var(--font-family);
+      font-size: var(--font-size-normal);
+      font-weight: var(--font-weight-normal);
+      line-height: var(--line-height-normal);
+    }
+    gr-button {
+      margin-left: var(--spacing-m);
+    }
+    gr-comment {
+      border-bottom: 1px solid var(--comment-separator-color);
+    }
+    #actions {
+      margin-left: auto;
+      padding: var(--spacing-s) var(--spacing-m);
+    }
+    #container {
+      background-color: var(--comment-background-color);
+      color: var(--comment-text-color);
+      display: var(--gr-comment-thread-display, block);
+      margin: 0 var(--spacing-s) var(--spacing-s);
+      white-space: normal;
+      box-shadow: var(--elevation-level-2);
+      border-radius: var(--border-radius);
+      /** This is required for firefox to continue the inheritance */
+      -webkit-user-select: inherit;
+      -moz-user-select: inherit;
+      -ms-user-select: inherit;
+      user-select: inherit;
+    }
+    #container.unresolved {
+      background-color: var(--unresolved-comment-background-color);
+    }
+    #container.robotComment {
+      background-color: var(--robot-comment-background-color);
+    }
+    #commentInfoContainer {
+      display: flex;
+    }
+    #unresolvedLabel {
+      font-family: var(--font-family);
+      margin: auto 0;
+      padding: var(--spacing-m);
+    }
+    .pathInfo {
+      display: flex;
+      align-items: baseline;
+      justify-content: space-between;
+      padding: 0 var(--spacing-s) var(--spacing-s);
+    }
+    .descriptionText {
+      margin-left: var(--spacing-m);
+      font-style: italic;
+    }
+    .fileName {
+      padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
+    }
+  </style>
+  <template is="dom-if" if="[[showFilePath]]">
+    <template is="dom-if" if="[[showFileName]]">
+      <div class="fileName">
+        <template is="dom-if" if="[[_isPatchsetLevelComment(path)]]">
+          <span> [[_computeDisplayPath(path)]] </span>
+        </template>
+        <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
+          <a href$="[[_getDiffUrlForPath(path)]]">
+            [[_computeDisplayPath(path)]]
+          </a>
+        </template>
+      </div>
+    </template>
+    <div class="pathInfo">
+      <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
+        <a
+          href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
+          >[[_computeDisplayLine()]]</a
+        >
+      </template>
+    </div>
+  </template>
+  <div
+    id="container"
+    class$="[[_computeHostClass(unresolved, isRobotComment)]]"
+  >
+    <template
+      id="commentList"
+      is="dom-repeat"
+      items="[[_orderedComments]]"
+      as="comment"
+    >
+      <gr-comment
+        comment="{{comment}}"
+        comments="{{comments}}"
+        robot-button-disabled="[[_shouldDisableAction(_showActions, _lastComment)]]"
+        change-num="[[changeNum]]"
+        patch-num="[[patchNum]]"
+        draft="[[_isDraft(comment)]]"
+        show-actions="[[_showActions]]"
+        show-patchset="[[showPatchset]]"
+        comment-side="[[comment.__commentSide]]"
+        side="[[comment.side]]"
+        project-config="[[_projectConfig]]"
+        on-create-fix-comment="_handleCommentFix"
+        on-comment-discard="_handleCommentDiscard"
+        on-comment-save="_handleCommentSavedOrDiscarded"
+      ></gr-comment>
+    </template>
+    <div
+      id="commentInfoContainer"
+      hidden$="[[_hideActions(_showActions, _lastComment)]]"
+    >
+      <span id="unresolvedLabel" hidden$="[[!unresolved]]">Unresolved</span>
+      <div id="actions">
+        <gr-button
+          id="replyBtn"
+          link=""
+          class="action reply"
+          on-click="_handleCommentReply"
+          >Reply</gr-button
+        >
+        <gr-button
+          id="quoteBtn"
+          link=""
+          class="action quote"
+          on-click="_handleCommentQuote"
+          >Quote</gr-button
+        >
+        <template is="dom-if" if="[[unresolved]]">
+          <gr-button
+            id="ackBtn"
+            link=""
+            class="action ack"
+            on-click="_handleCommentAck"
+            >Ack</gr-button
+          >
+          <gr-button
+            id="doneBtn"
+            link=""
+            class="action done"
+            on-click="_handleCommentDone"
+            >Done</gr-button
+          >
+        </template>
+      </div>
+    </div>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-storage id="storage"></gr-storage>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
deleted file mode 100644
index 244a9ec..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.html
+++ /dev/null
@@ -1,877 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-comment-thread</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-comment-thread></gr-comment-thread>
-  </template>
-</test-fixture>
-
-<test-fixture id="withComment">
-  <template>
-    <gr-comment-thread></gr-comment-thread>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-comment-thread.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-comment-thread tests', () => {
-  suite('basic test', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-      sandbox = sinon.sandbox.create();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('comments are sorted correctly', () => {
-      const comments = [
-        {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          __date: new Date('2015-12-25'),
-        }, {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sally_to_dr_finklestein',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }, {
-          id: 'dr_finklesteins_response',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000',
-        }, {
-          id: 'sallys_mission',
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000',
-        },
-      ];
-      const results = element._sortedComments(comments);
-      assert.deepEqual(results, [
-        {
-          id: 'sally_to_dr_finklestein',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'dr_finklesteins_response',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'no i will pull a thread and your arm will fall off',
-          updated: '2015-10-31 11:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          in_reply_to: 'sally_to_dr_finklestein',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }, {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sallys_mission',
-          message: 'i have to find santa',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          __date: new Date('2015-12-25'),
-        },
-      ]);
-    });
-
-    test('addOrEditDraft w/ edit draft', () => {
-      element.comments = [{
-        id: 'jacks_reply',
-        message: 'i like you, too',
-        in_reply_to: 'sallys_confession',
-        updated: '2015-12-25 15:00:20.396000000',
-        __draft: true,
-      }];
-      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
-          () => { return {}; });
-      const addDraftStub = sandbox.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isTrue(commentElStub.called);
-      assert.isFalse(addDraftStub.called);
-    });
-
-    test('addOrEditDraft w/o edit draft', () => {
-      element.comments = [];
-      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
-          () => { return {}; });
-      const addDraftStub = sandbox.stub(element, 'addDraft');
-
-      element.addOrEditDraft(123);
-
-      assert.isFalse(commentElStub.called);
-      assert.isTrue(addDraftStub.called);
-    });
-
-    test('_shouldDisableAction', () => {
-      let showActions = true;
-      const lastComment = {};
-      assert.equal(
-          element._shouldDisableAction(showActions, lastComment), false);
-      showActions = false;
-      assert.equal(
-          element._shouldDisableAction(showActions, lastComment), true);
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(
-          element._shouldDisableAction(showActions, lastComment), true);
-      const robotComment = {};
-      robotComment.robot_id = true;
-      assert.equal(
-          element._shouldDisableAction(showActions, robotComment), false);
-    });
-
-    test('_hideActions', () => {
-      let showActions = true;
-      const lastComment = {};
-      assert.equal(element._hideActions(showActions, lastComment), false);
-      showActions = false;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      showActions = true;
-      lastComment.__draft = true;
-      assert.equal(element._hideActions(showActions, lastComment), true);
-      const robotComment = {};
-      robotComment.robot_id = true;
-      assert.equal(element._hideActions(showActions, robotComment), true);
-    });
-
-    test('setting project name loads the project config', done => {
-      const projectName = 'foo/bar/baz';
-      const getProjectStub = sandbox.stub(element.$.restAPI, 'getProjectConfig')
-          .returns(Promise.resolve({}));
-      element.projectName = projectName;
-      flush(() => {
-        assert.isTrue(getProjectStub.calledWithExactly(projectName));
-        done();
-      });
-    });
-
-    test('optionally show file path', () => {
-      // Path info doesn't exist when showFilePath is false. Because it's in a
-      // dom-if it is not yet in the dom.
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.pathInfo'));
-
-      sandbox.stub(GerritNav, 'getUrlForDiffById');
-      element.changeNum = 123;
-      element.projectName = 'test project';
-      element.path = 'path/to/file';
-      element.patchNum = 3;
-      element.lineNum = 5;
-      element.showFilePath = true;
-      flushAsynchronousOperations();
-      assert.isOk(element.shadowRoot
-          .querySelector('.pathInfo'));
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.pathInfo')).display,
-      'none');
-      assert.isTrue(GerritNav.getUrlForDiffById.lastCall.calledWithExactly(
-          element.changeNum, element.projectName, element.path,
-          element.patchNum, null, element.lineNum));
-    });
-
-    test('_computeDisplayPath', () => {
-      const path = 'path/to/file';
-      assert.equal(element._computeDisplayPath(path), 'path/to/file');
-
-      element.lineNum = 5;
-      assert.equal(element._computeDisplayPath(path), 'path/to/file#5');
-    });
-  });
-});
-
-suite('comment action tests with unresolved thread', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      saveDiffDraft() {
-        return Promise.resolve({
-          ok: true,
-          text() {
-            return Promise.resolve(')]}\'\n' +
-                JSON.stringify({
-                  id: '7afa4931_de3d65bd',
-                  path: '/path/to/file.txt',
-                  line: 5,
-                  in_reply_to: 'baf0414d_60047215',
-                  updated: '2015-12-21 02:01:10.850000000',
-                  message: 'Done',
-                }));
-          },
-        });
-      },
-      deleteDiffDraft() { return Promise.resolve({ok: true}); },
-    });
-    element = fixture('withComment');
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:33.843000000',
-      path: '/path/to/file.txt',
-      unresolved: true,
-    }];
-    flushAsynchronousOperations();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('reply', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    const reportStub = sandbox.stub(element.$.reporting,
-        'recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const replyBtn = element.$.replyBtn;
-    MockInteractions.tap(replyBtn);
-    flushAsynchronousOperations();
-
-    const drafts = element._orderedComments.filter(c => c.__draft == true);
-    assert.equal(drafts.length, 1);
-    assert.notOk(drafts[0].message, 'message should be empty');
-    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('quote reply', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    const reportStub = sandbox.stub(element.$.reporting,
-        'recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    MockInteractions.tap(quoteBtn);
-    flushAsynchronousOperations();
-
-    const drafts = element._orderedComments.filter(c => c.__draft == true);
-    assert.equal(drafts.length, 1);
-    assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
-    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('quote reply multiline', () => {
-    const reportStub = sandbox.stub(element.$.reporting,
-        'recordDraftInteraction');
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      message: 'is this a crossover episode!?\nIt might be!',
-      updated: '2015-12-08 19:48:33.843000000',
-    }];
-    flushAsynchronousOperations();
-
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    MockInteractions.tap(quoteBtn);
-    flushAsynchronousOperations();
-
-    const drafts = element._orderedComments.filter(c => c.__draft == true);
-    assert.equal(drafts.length, 1);
-    assert.equal(drafts[0].message,
-        '> is this a crossover episode!?\n> It might be!\n\n');
-    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('ack', done => {
-    const reportStub = sandbox.stub(element.$.reporting,
-        'recordDraftInteraction');
-    element.changeNum = '42';
-    element.patchNum = '1';
-
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot.querySelector('#ackBtn');
-    MockInteractions.tap(ackBtn);
-    flush(() => {
-      const drafts = element.comments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].message, 'Ack');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.equal(drafts[0].unresolved, false);
-      assert.isTrue(reportStub.calledOnce);
-      done();
-    });
-  });
-
-  test('done', done => {
-    const reportStub = sandbox.stub(element.$.reporting,
-        'recordDraftInteraction');
-    element.changeNum = '42';
-    element.patchNum = '1';
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const doneBtn = element.shadowRoot.querySelector('#doneBtn');
-    MockInteractions.tap(doneBtn);
-    flush(() => {
-      const drafts = element.comments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(drafts[0].message, 'Done');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.isFalse(drafts[0].unresolved);
-      assert.isTrue(reportStub.calledOnce);
-      done();
-    });
-  });
-
-  test('save', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    element.path = '/path/to/file.txt';
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const saveOrDiscardStub = sandbox.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
-    element.shadowRoot
-        .querySelector('gr-comment')._fireSave();
-
-    flush(() => {
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-          'baf0414d_60047215');
-      assert.equal(element.rootId, 'baf0414d_60047215');
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-          '/path/to/file.txt');
-      done();
-    });
-  });
-
-  test('please fix', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-    commentEl.addEventListener('create-fix-comment', () => {
-      const drafts = element._orderedComments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 1);
-      assert.equal(
-          drafts[0].message, '> is this a crossover episode!?\n\nPlease fix.');
-      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
-      assert.isTrue(drafts[0].unresolved);
-      done();
-    });
-    commentEl.dispatchEvent(
-        new CustomEvent('create-fix-comment', {
-          detail: {comment: commentEl.comment},
-          composed: true, bubbles: false,
-        }));
-  });
-
-  test('discard', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    element.path = '/path/to/file.txt';
-    element.push('comments', element._newReply(
-        element.comments[0].id,
-        element.comments[0].line,
-        element.comments[0].path,
-        'it’s pronouced jiff, not giff'));
-    flushAsynchronousOperations();
-
-    const saveOrDiscardStub = sandbox.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
-    const draftEl =
-        dom(element.root).querySelectorAll('gr-comment')[1];
-    assert.ok(draftEl);
-    draftEl.addEventListener('comment-discard', () => {
-      const drafts = element.comments.filter(c => c.__draft == true);
-      assert.equal(drafts.length, 0);
-      assert.isTrue(saveOrDiscardStub.called);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-          element.rootId);
-      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-          element.path);
-      done();
-    });
-    draftEl.dispatchEvent(
-        new CustomEvent('comment-discard', {
-          detail: {comment: draftEl.comment},
-          composed: true, bubbles: false,
-        }));
-  });
-
-  test('discard with a single comment still fires event with previous rootId',
-      done => {
-        element.changeNum = '42';
-        element.patchNum = '1';
-        element.path = '/path/to/file.txt';
-        element.comments = [];
-        element.addOrEditDraft('1');
-        flushAsynchronousOperations();
-        const rootId = element.rootId;
-        assert.isOk(rootId);
-
-        const saveOrDiscardStub = sandbox.stub();
-        element.addEventListener('thread-changed', saveOrDiscardStub);
-        const draftEl =
-        dom(element.root).querySelectorAll('gr-comment')[0];
-        assert.ok(draftEl);
-        draftEl.addEventListener('comment-discard', () => {
-          assert.equal(element.comments.length, 0);
-          assert.isTrue(saveOrDiscardStub.called);
-          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
-              rootId);
-          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
-              element.path);
-          done();
-        });
-        draftEl.dispatchEvent(
-            new CustomEvent('comment-discard', {
-              detail: {comment: draftEl.comment},
-              composed: true, bubbles: false,
-            }));
-      });
-
-  test('first editing comment does not add __otherEditing attribute', () => {
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:33.843000000',
-      __draft: true,
-    }];
-
-    const replyBtn = element.$.replyBtn;
-    MockInteractions.tap(replyBtn);
-    flushAsynchronousOperations();
-
-    const editing = element._orderedComments.filter(c => c.__editing == true);
-    assert.equal(editing.length, 1);
-    assert.equal(!!editing[0].__otherEditing, false);
-  });
-
-  test('When not editing other comments, local storage not set' +
-      ' after discard', done => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:31.843000000',
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      __draftID: '1',
-      in_reply_to: 'baf0414d_60047215',
-      line: 5,
-      message: 'yes',
-      updated: '2015-12-08 19:48:32.843000000',
-      __draft: true,
-      __editing: true,
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      __draftID: '2',
-      in_reply_to: 'baf0414d_60047215',
-      line: 5,
-      message: 'no',
-      updated: '2015-12-08 19:48:33.843000000',
-      __draft: true,
-    }];
-    const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
-    flushAsynchronousOperations();
-
-    const draftEl =
-    dom(element.root).querySelectorAll('gr-comment')[1];
-    assert.ok(draftEl);
-    draftEl.addEventListener('comment-discard', () => {
-      assert.isFalse(storageStub.called);
-      storageStub.restore();
-      done();
-    });
-    draftEl.dispatchEvent(
-        new CustomEvent('comment-discard', {
-          detail: {comment: draftEl.comment},
-          composed: true, bubbles: false,
-        }));
-  });
-
-  test('comment-update', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    const updatedComment = {
-      id: element.comments[0].id,
-      foo: 'bar',
-    };
-    commentEl.dispatchEvent(
-        new CustomEvent('comment-update', {
-          detail: {comment: updatedComment},
-          composed: true, bubbles: true,
-        }));
-    assert.strictEqual(element.comments[0], updatedComment);
-  });
-
-  suite('jack and sally comment data test consolidation', () => {
-    setup(() => {
-      element.comments = [
-        {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          updated: '2015-12-25 15:00:20.396000000',
-          unresolved: false,
-        }, {
-          id: 'sallys_confession',
-          in_reply_to: 'nonexistent_comment',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sally_to_dr_finklestein',
-          in_reply_to: 'nonexistent_comment',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }];
-    });
-
-    test('orphan replies', () => {
-      assert.equal(4, element._orderedComments.length);
-    });
-
-    test('keyboard shortcuts', () => {
-      const expandCollapseStub =
-          sinon.stub(element, '_expandCollapseComments');
-      MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
-
-      MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
-      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
-    });
-
-    test('comment in_reply_to is either null or most recent comment', () => {
-      element._createReplyComment(element.comments[3], 'dummy', true);
-      flushAsynchronousOperations();
-      assert.equal(element._orderedComments.length, 5);
-      assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
-    });
-
-    test('resolvable comments', () => {
-      assert.isFalse(element.unresolved);
-      element._createReplyComment(element.comments[3], 'dummy', true, true);
-      flushAsynchronousOperations();
-      assert.isTrue(element.unresolved);
-    });
-
-    test('_setInitialExpandedState with unresolved', () => {
-      element.unresolved = true;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState without unresolved', () => {
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with robot_ids', () => {
-      for (let i = 0; i < element.comments.length; i++) {
-        element.comments[i].robot_id = 123;
-      }
-      element._setInitialExpandedState();
-      for (let i = 0; i < element.comments.length; i++) {
-        assert.isFalse(element.comments[i].collapsed);
-      }
-    });
-
-    test('_setInitialExpandedState with collapsed state', () => {
-      element.comments[0].collapsed = false;
-      element.unresolved = false;
-      element._setInitialExpandedState();
-      assert.isFalse(element.comments[0].collapsed);
-      for (let i = 1; i < element.comments.length; i++) {
-        assert.isTrue(element.comments[i].collapsed);
-      }
-    });
-  });
-
-  test('_computeHostClass', () => {
-    assert.equal(element._computeHostClass(true), 'unresolved');
-    assert.equal(element._computeHostClass(false), '');
-  });
-
-  test('addDraft sets unresolved state correctly', () => {
-    let unresolved = true;
-    element.comments = [];
-    element.addDraft(null, null, unresolved);
-    assert.equal(element.comments[0].unresolved, true);
-
-    unresolved = false; // comment should get added as actually resolved.
-    element.comments = [];
-    element.addDraft(null, null, unresolved);
-    assert.equal(element.comments[0].unresolved, false);
-
-    element.comments = [];
-    element.addDraft();
-    assert.equal(element.comments[0].unresolved, true);
-  });
-
-  test('_newDraft', () => {
-    element.commentSide = 'left';
-    element.patchNum = 3;
-    const draft = element._newDraft();
-    assert.equal(draft.__commentSide, 'left');
-    assert.equal(draft.patchNum, 3);
-  });
-
-  test('new comment gets created', () => {
-    element.comments = [];
-    element.addOrEditDraft(1);
-    assert.equal(element.comments.length, 1);
-    // Mock a submitted comment.
-    element.comments[0].id = element.comments[0].__draftID;
-    element.comments[0].__draft = false;
-    element.addOrEditDraft(1);
-    assert.equal(element.comments.length, 2);
-  });
-
-  test('unresolved label', () => {
-    element.unresolved = false;
-    assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
-    element.unresolved = true;
-    assert.isFalse(element.$.unresolvedLabel.hasAttribute('hidden'));
-  });
-
-  test('draft comments are at the end of orderedComments', () => {
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 2,
-      line: 5,
-      message: 'Earlier draft',
-      updated: '2015-12-08 19:48:33.843000000',
-      __draft: true,
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter2',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 1,
-      line: 5,
-      message: 'This comment was left last but is not a draft',
-      updated: '2015-12-10 19:48:33.843000000',
-    },
-    {
-      author: {
-        name: 'Mr. Peanutbutter2',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 3,
-      line: 5,
-      message: 'Later draft',
-      updated: '2015-12-09 19:48:33.843000000',
-      __draft: true,
-    }];
-    assert.equal(element._orderedComments[0].id, '1');
-    assert.equal(element._orderedComments[1].id, '2');
-    assert.equal(element._orderedComments[2].id, '3');
-  });
-
-  test('reflects lineNum and commentSide to attributes', () => {
-    element.lineNum = 7;
-    element.commentSide = 'left';
-
-    assert.equal(element.getAttribute('line-num'), '7');
-    assert.equal(element.getAttribute('comment-side'), 'left');
-  });
-
-  test('reflects range to JSON serialized attribute if set', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-
-    assert.deepEqual(
-        JSON.parse(element.getAttribute('range')),
-        {start_line: 4, end_line: 5, start_character: 6, end_character: 7});
-  });
-
-  test('removes range attribute if range is unset', () => {
-    element.range = {
-      start_line: 4,
-      end_line: 5,
-      start_character: 6,
-      end_character: 7,
-    };
-    element.range = undefined;
-
-    assert.notOk(element.hasAttribute('range'));
-  });
-});
-
-suite('comment action tests on resolved comments', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(false); },
-      saveDiffDraft() {
-        return Promise.resolve({
-          ok: true,
-          text() {
-            return Promise.resolve(')]}\'\n' +
-                JSON.stringify({
-                  id: '7afa4931_de3d65bd',
-                  path: '/path/to/file.txt',
-                  line: 5,
-                  in_reply_to: 'baf0414d_60047215',
-                  updated: '2015-12-21 02:01:10.850000000',
-                  message: 'Done',
-                }));
-          },
-        });
-      },
-      deleteDiffDraft() { return Promise.resolve({ok: true}); },
-    });
-    element = fixture('withComment');
-    element.comments = [{
-      author: {
-        name: 'Mr. Peanutbutter',
-        email: 'tenn1sballchaser@aol.com',
-      },
-      id: 'baf0414d_60047215',
-      line: 5,
-      message: 'is this a crossover episode!?',
-      updated: '2015-12-08 19:48:33.843000000',
-      path: '/path/to/file.txt',
-      unresolved: false,
-    }];
-    flushAsynchronousOperations();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('ack and done should be hidden', () => {
-    element.changeNum = '42';
-    element.patchNum = '1';
-
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot.querySelector('#ackBtn');
-    const doneBtn = element.shadowRoot.querySelector('#doneBtn');
-    assert.equal(ackBtn, null);
-    assert.equal(doneBtn, null);
-  });
-
-  test('reply and quote button should be visible', () => {
-    const commentEl = element.shadowRoot
-        .querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const replyBtn = element.shadowRoot.querySelector('#replyBtn');
-    const quoteBtn = element.shadowRoot.querySelector('#quoteBtn');
-    assert.ok(replyBtn);
-    assert.ok(quoteBtn);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
new file mode 100644
index 0000000..2a9d0d5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
@@ -0,0 +1,868 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-comment-thread.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
+
+const basicFixture = fixtureFromElement('gr-comment-thread');
+
+const withCommentFixture = fixtureFromElement('gr-comment-thread');
+
+suite('gr-comment-thread tests', () => {
+  suite('basic test', () => {
+    let element;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+      });
+
+      element = basicFixture.instantiate();
+    });
+
+    test('comments are sorted correctly', () => {
+      const comments = [
+        {
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          __date: new Date('2015-12-25'),
+        }, {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sally_to_dr_finklestein',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }, {
+          id: 'dr_finklesteins_response',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000',
+        }, {
+          id: 'sallys_mission',
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000',
+        },
+      ];
+      const results = element._sortedComments(comments);
+      assert.deepEqual(results, [
+        {
+          id: 'sally_to_dr_finklestein',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'dr_finklesteins_response',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }, {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sallys_mission',
+          message: 'i have to find santa',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          __date: new Date('2015-12-25'),
+        },
+      ]);
+    });
+
+    test('addOrEditDraft w/ edit draft', () => {
+      element.comments = [{
+        id: 'jacks_reply',
+        message: 'i like you, too',
+        in_reply_to: 'sallys_confession',
+        updated: '2015-12-25 15:00:20.396000000',
+        __draft: true,
+      }];
+      const commentElStub = sinon.stub(element, '_commentElWithDraftID')
+          .callsFake(() => { return {}; });
+      const addDraftStub = sinon.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isTrue(commentElStub.called);
+      assert.isFalse(addDraftStub.called);
+    });
+
+    test('addOrEditDraft w/o edit draft', () => {
+      element.comments = [];
+      const commentElStub = sinon.stub(element, '_commentElWithDraftID')
+          .callsFake(() => { return {}; });
+      const addDraftStub = sinon.stub(element, 'addDraft');
+
+      element.addOrEditDraft(123);
+
+      assert.isFalse(commentElStub.called);
+      assert.isTrue(addDraftStub.called);
+    });
+
+    test('_shouldDisableAction', () => {
+      let showActions = true;
+      const lastComment = {};
+      assert.equal(
+          element._shouldDisableAction(showActions, lastComment), false);
+      showActions = false;
+      assert.equal(
+          element._shouldDisableAction(showActions, lastComment), true);
+      showActions = true;
+      lastComment.__draft = true;
+      assert.equal(
+          element._shouldDisableAction(showActions, lastComment), true);
+      const robotComment = {};
+      robotComment.robot_id = true;
+      assert.equal(
+          element._shouldDisableAction(showActions, robotComment), false);
+    });
+
+    test('_hideActions', () => {
+      let showActions = true;
+      const lastComment = {};
+      assert.equal(element._hideActions(showActions, lastComment), false);
+      showActions = false;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      showActions = true;
+      lastComment.__draft = true;
+      assert.equal(element._hideActions(showActions, lastComment), true);
+      const robotComment = {};
+      robotComment.robot_id = true;
+      assert.equal(element._hideActions(showActions, robotComment), true);
+    });
+
+    test('setting project name loads the project config', done => {
+      const projectName = 'foo/bar/baz';
+      const getProjectStub = sinon.stub(element.$.restAPI, 'getProjectConfig')
+          .returns(Promise.resolve({}));
+      element.projectName = projectName;
+      flush(() => {
+        assert.isTrue(getProjectStub.calledWithExactly(projectName));
+        done();
+      });
+    });
+
+    test('optionally show file path', () => {
+      // Path info doesn't exist when showFilePath is false. Because it's in a
+      // dom-if it is not yet in the dom.
+      assert.isNotOk(element.shadowRoot
+          .querySelector('.pathInfo'));
+
+      const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
+      element.changeNum = 123;
+      element.projectName = 'test project';
+      element.path = 'path/to/file';
+      element.latestPatchNum = 10;
+      element.patchNum = 3;
+      element.lineNum = 5;
+      element.comments = [{id: 'comment_id'}];
+      element.showFilePath = true;
+      element._isChangeCommentsLinkExperimentEnabled = true;
+      flushAsynchronousOperations();
+      assert.isOk(element.shadowRoot
+          .querySelector('.pathInfo'));
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('.pathInfo')).display,
+      'none');
+      assert.isTrue(commentStub.calledWithExactly(
+          element.changeNum, element.projectName, 'comment_id'));
+    });
+
+    test('_computeDisplayPath', () => {
+      let path = 'path/to/file';
+      assert.equal(element._computeDisplayPath(path), 'path/to/file');
+
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayPath(path), 'path/to/file');
+
+      element.patchNum = '3';
+      path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+      assert.equal(element._computeDisplayPath(path), 'Patchset');
+    });
+
+    test('_computeDisplayLine', () => {
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayLine(), '#5');
+
+      element.path = SpecialFilePath.COMMIT_MESSAGE;
+      element.lineNum = 5;
+      assert.equal(element._computeDisplayLine(), '#5');
+
+      element.lineNum = undefined;
+      element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+      assert.equal(element._computeDisplayLine(), '');
+    });
+  });
+});
+
+suite('comment action tests with unresolved thread', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      saveDiffDraft() {
+        return Promise.resolve({
+          ok: true,
+          text() {
+            return Promise.resolve(')]}\'\n' +
+                JSON.stringify({
+                  id: '7afa4931_de3d65bd',
+                  path: '/path/to/file.txt',
+                  line: 5,
+                  in_reply_to: 'baf0414d_60047215',
+                  updated: '2015-12-21 02:01:10.850000000',
+                  message: 'Done',
+                }));
+          },
+        });
+      },
+      deleteDiffDraft() { return Promise.resolve({ok: true}); },
+    });
+    element = withCommentFixture.instantiate();
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?',
+      updated: '2015-12-08 19:48:33.843000000',
+      path: '/path/to/file.txt',
+      unresolved: true,
+      patchNum: 3,
+      __commentSide: 'left',
+    }];
+    flushAsynchronousOperations();
+  });
+
+  test('reply', () => {
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    const reportStub = sinon.stub(element.reporting,
+        'recordDraftInteraction');
+    assert.ok(commentEl);
+
+    const replyBtn = element.$.replyBtn;
+    MockInteractions.tap(replyBtn);
+    flushAsynchronousOperations();
+
+    const drafts = element._orderedComments.filter(c => c.__draft == true);
+    assert.equal(drafts.length, 1);
+    assert.notOk(drafts[0].message, 'message should be empty');
+    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('quote reply', () => {
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    const reportStub = sinon.stub(element.reporting,
+        'recordDraftInteraction');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    MockInteractions.tap(quoteBtn);
+    flushAsynchronousOperations();
+
+    const drafts = element._orderedComments.filter(c => c.__draft == true);
+    assert.equal(drafts.length, 1);
+    assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
+    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('quote reply multiline', () => {
+    const reportStub = sinon.stub(element.reporting,
+        'recordDraftInteraction');
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?\nIt might be!',
+      updated: '2015-12-08 19:48:33.843000000',
+    }];
+    flushAsynchronousOperations();
+
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const quoteBtn = element.$.quoteBtn;
+    MockInteractions.tap(quoteBtn);
+    flushAsynchronousOperations();
+
+    const drafts = element._orderedComments.filter(c => c.__draft == true);
+    assert.equal(drafts.length, 1);
+    assert.equal(drafts[0].message,
+        '> is this a crossover episode!?\n> It might be!\n\n');
+    assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+    assert.isTrue(reportStub.calledOnce);
+  });
+
+  test('ack', done => {
+    const reportStub = sinon.stub(element.reporting,
+        'recordDraftInteraction');
+    element.changeNum = '42';
+    element.patchNum = '1';
+
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const ackBtn = element.shadowRoot.querySelector('#ackBtn');
+    MockInteractions.tap(ackBtn);
+    flush(() => {
+      const drafts = element.comments.filter(c => c.__draft == true);
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message, 'Ack');
+      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      assert.equal(drafts[0].unresolved, false);
+      assert.isTrue(reportStub.calledOnce);
+      done();
+    });
+  });
+
+  test('done', done => {
+    const reportStub = sinon.stub(element.reporting,
+        'recordDraftInteraction');
+    element.changeNum = '42';
+    element.patchNum = '1';
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const doneBtn = element.shadowRoot.querySelector('#doneBtn');
+    MockInteractions.tap(doneBtn);
+    flush(() => {
+      const drafts = element.comments.filter(c => c.__draft == true);
+      assert.equal(drafts.length, 1);
+      assert.equal(drafts[0].message, 'Done');
+      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      assert.isFalse(drafts[0].unresolved);
+      assert.isTrue(reportStub.calledOnce);
+      done();
+    });
+  });
+
+  test('save', done => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+    element.path = '/path/to/file.txt';
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const saveOrDiscardStub = sinon.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    element.shadowRoot
+        .querySelector('gr-comment')._fireSave();
+
+    flush(() => {
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+          'baf0414d_60047215');
+      assert.equal(element.rootId, 'baf0414d_60047215');
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+          '/path/to/file.txt');
+      done();
+    });
+  });
+
+  test('please fix', done => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+    commentEl.addEventListener('create-fix-comment', () => {
+      const drafts = element._orderedComments.filter(c => c.__draft == true);
+      assert.equal(drafts.length, 1);
+      assert.equal(
+          drafts[0].message, '> is this a crossover episode!?\n\nPlease fix.');
+      assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+      assert.isTrue(drafts[0].unresolved);
+      done();
+    });
+    commentEl.dispatchEvent(
+        new CustomEvent('create-fix-comment', {
+          detail: {comment: commentEl.comment},
+          composed: true, bubbles: false,
+        }));
+  });
+
+  test('discard', done => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+    element.path = '/path/to/file.txt';
+    element.push('comments', element._newReply(
+        element.comments[0].id,
+        element.comments[0].path,
+        'it’s pronouced jiff, not giff'));
+    flushAsynchronousOperations();
+
+    const saveOrDiscardStub = sinon.stub();
+    element.addEventListener('thread-changed', saveOrDiscardStub);
+    const draftEl =
+        dom(element.root).querySelectorAll('gr-comment')[1];
+    assert.ok(draftEl);
+    draftEl.addEventListener('comment-discard', () => {
+      const drafts = element.comments.filter(c => c.__draft == true);
+      assert.equal(drafts.length, 0);
+      assert.isTrue(saveOrDiscardStub.called);
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+          element.rootId);
+      assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+          element.path);
+      done();
+    });
+    draftEl.dispatchEvent(
+        new CustomEvent('comment-discard', {
+          detail: {comment: draftEl.comment},
+          composed: true, bubbles: false,
+        }));
+  });
+
+  test('discard with a single comment still fires event with previous rootId',
+      done => {
+        element.changeNum = '42';
+        element.patchNum = '1';
+        element.path = '/path/to/file.txt';
+        element.comments = [];
+        element.addOrEditDraft('1');
+        flushAsynchronousOperations();
+        const rootId = element.rootId;
+        assert.isOk(rootId);
+
+        const saveOrDiscardStub = sinon.stub();
+        element.addEventListener('thread-changed', saveOrDiscardStub);
+        const draftEl =
+        dom(element.root).querySelectorAll('gr-comment')[0];
+        assert.ok(draftEl);
+        draftEl.addEventListener('comment-discard', () => {
+          assert.equal(element.comments.length, 0);
+          assert.isTrue(saveOrDiscardStub.called);
+          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.rootId,
+              rootId);
+          assert.equal(saveOrDiscardStub.lastCall.args[0].detail.path,
+              element.path);
+          done();
+        });
+        draftEl.dispatchEvent(
+            new CustomEvent('comment-discard', {
+              detail: {comment: draftEl.comment},
+              composed: true, bubbles: false,
+            }));
+      });
+
+  test('first editing comment does not add __otherEditing attribute', () => {
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?',
+      updated: '2015-12-08 19:48:33.843000000',
+      __draft: true,
+    }];
+
+    const replyBtn = element.$.replyBtn;
+    MockInteractions.tap(replyBtn);
+    flushAsynchronousOperations();
+
+    const editing = element._orderedComments.filter(c => c.__editing == true);
+    assert.equal(editing.length, 1);
+    assert.equal(!!editing[0].__otherEditing, false);
+  });
+
+  test('When not editing other comments, local storage not set' +
+      ' after discard', done => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?',
+      updated: '2015-12-08 19:48:31.843000000',
+    },
+    {
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      __draftID: '1',
+      in_reply_to: 'baf0414d_60047215',
+      line: 5,
+      message: 'yes',
+      updated: '2015-12-08 19:48:32.843000000',
+      __draft: true,
+      __editing: true,
+    },
+    {
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      __draftID: '2',
+      in_reply_to: 'baf0414d_60047215',
+      line: 5,
+      message: 'no',
+      updated: '2015-12-08 19:48:33.843000000',
+      __draft: true,
+    }];
+    const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
+    flushAsynchronousOperations();
+
+    const draftEl =
+    dom(element.root).querySelectorAll('gr-comment')[1];
+    assert.ok(draftEl);
+    draftEl.addEventListener('comment-discard', () => {
+      assert.isFalse(storageStub.called);
+      storageStub.restore();
+      done();
+    });
+    draftEl.dispatchEvent(
+        new CustomEvent('comment-discard', {
+          detail: {comment: draftEl.comment},
+          composed: true, bubbles: false,
+        }));
+  });
+
+  test('comment-update', () => {
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    const updatedComment = {
+      id: element.comments[0].id,
+      foo: 'bar',
+    };
+    commentEl.dispatchEvent(
+        new CustomEvent('comment-update', {
+          detail: {comment: updatedComment},
+          composed: true, bubbles: true,
+        }));
+    assert.strictEqual(element.comments[0], updatedComment);
+  });
+
+  suite('jack and sally comment data test consolidation', () => {
+    setup(() => {
+      element.comments = [
+        {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          updated: '2015-12-25 15:00:20.396000000',
+          unresolved: false,
+        }, {
+          id: 'sallys_confession',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sally_to_dr_finklestein',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }];
+    });
+
+    test('orphan replies', () => {
+      assert.equal(4, element._orderedComments.length);
+    });
+
+    test('keyboard shortcuts', () => {
+      const expandCollapseStub =
+          sinon.stub(element, '_expandCollapseComments');
+      MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
+
+      MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
+    });
+
+    test('comment in_reply_to is either null or most recent comment', () => {
+      element._createReplyComment('dummy', true);
+      flushAsynchronousOperations();
+      assert.equal(element._orderedComments.length, 5);
+      assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
+    });
+
+    test('resolvable comments', () => {
+      assert.isFalse(element.unresolved);
+      element._createReplyComment('dummy', true, true);
+      flushAsynchronousOperations();
+      assert.isTrue(element.unresolved);
+    });
+
+    test('_setInitialExpandedState with unresolved', () => {
+      element.unresolved = true;
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isFalse(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState without unresolved', () => {
+      element.unresolved = false;
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isTrue(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState with robot_ids', () => {
+      for (let i = 0; i < element.comments.length; i++) {
+        element.comments[i].robot_id = 123;
+      }
+      element._setInitialExpandedState();
+      for (let i = 0; i < element.comments.length; i++) {
+        assert.isFalse(element.comments[i].collapsed);
+      }
+    });
+
+    test('_setInitialExpandedState with collapsed state', () => {
+      element.comments[0].collapsed = false;
+      element.unresolved = false;
+      element._setInitialExpandedState();
+      assert.isFalse(element.comments[0].collapsed);
+      for (let i = 1; i < element.comments.length; i++) {
+        assert.isTrue(element.comments[i].collapsed);
+      }
+    });
+  });
+
+  test('_computeHostClass', () => {
+    assert.equal(element._computeHostClass(true), 'unresolved');
+    assert.equal(element._computeHostClass(false), '');
+  });
+
+  test('addDraft sets unresolved state correctly', () => {
+    let unresolved = true;
+    element.comments = [];
+    element.addDraft(null, null, unresolved);
+    assert.equal(element.comments[0].unresolved, true);
+
+    unresolved = false; // comment should get added as actually resolved.
+    element.comments = [];
+    element.addDraft(null, null, unresolved);
+    assert.equal(element.comments[0].unresolved, false);
+
+    element.comments = [];
+    element.addDraft();
+    assert.equal(element.comments[0].unresolved, true);
+  });
+
+  test('_newDraft with root', () => {
+    const draft = element._newDraft();
+    assert.equal(draft.__commentSide, 'left');
+    assert.equal(draft.patchNum, 3);
+  });
+
+  test('_newDraft with no root', () => {
+    element.comments = [];
+    element.commentSide = 'right';
+    element.patchNum = 2;
+    const draft = element._newDraft();
+    assert.equal(draft.__commentSide, 'right');
+    assert.equal(draft.patchNum, 2);
+  });
+
+  test('new comment gets created', () => {
+    element.comments = [];
+    element.addOrEditDraft(1);
+    assert.equal(element.comments.length, 1);
+    // Mock a submitted comment.
+    element.comments[0].id = element.comments[0].__draftID;
+    element.comments[0].__draft = false;
+    element.addOrEditDraft(1);
+    assert.equal(element.comments.length, 2);
+  });
+
+  test('unresolved label', () => {
+    element.unresolved = false;
+    assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
+    element.unresolved = true;
+    assert.isFalse(element.$.unresolvedLabel.hasAttribute('hidden'));
+  });
+
+  test('draft comments are at the end of orderedComments', () => {
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 2,
+      line: 5,
+      message: 'Earlier draft',
+      updated: '2015-12-08 19:48:33.843000000',
+      __draft: true,
+    },
+    {
+      author: {
+        name: 'Mr. Peanutbutter2',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 1,
+      line: 5,
+      message: 'This comment was left last but is not a draft',
+      updated: '2015-12-10 19:48:33.843000000',
+    },
+    {
+      author: {
+        name: 'Mr. Peanutbutter2',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 3,
+      line: 5,
+      message: 'Later draft',
+      updated: '2015-12-09 19:48:33.843000000',
+      __draft: true,
+    }];
+    assert.equal(element._orderedComments[0].id, '1');
+    assert.equal(element._orderedComments[1].id, '2');
+    assert.equal(element._orderedComments[2].id, '3');
+  });
+
+  test('reflects lineNum and commentSide to attributes', () => {
+    element.lineNum = 7;
+    element.commentSide = 'left';
+
+    assert.equal(element.getAttribute('line-num'), '7');
+    assert.equal(element.getAttribute('comment-side'), 'left');
+  });
+
+  test('reflects range to JSON serialized attribute if set', () => {
+    element.range = {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    };
+
+    assert.deepEqual(
+        JSON.parse(element.getAttribute('range')),
+        {start_line: 4, end_line: 5, start_character: 6, end_character: 7});
+  });
+
+  test('removes range attribute if range is unset', () => {
+    element.range = {
+      start_line: 4,
+      end_line: 5,
+      start_character: 6,
+      end_character: 7,
+    };
+    element.range = undefined;
+
+    assert.notOk(element.hasAttribute('range'));
+  });
+});
+
+suite('comment action tests on resolved comments', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(false); },
+      saveDiffDraft() {
+        return Promise.resolve({
+          ok: true,
+          text() {
+            return Promise.resolve(')]}\'\n' +
+                JSON.stringify({
+                  id: '7afa4931_de3d65bd',
+                  path: '/path/to/file.txt',
+                  line: 5,
+                  in_reply_to: 'baf0414d_60047215',
+                  updated: '2015-12-21 02:01:10.850000000',
+                  message: 'Done',
+                }));
+          },
+        });
+      },
+      deleteDiffDraft() { return Promise.resolve({ok: true}); },
+    });
+    element = withCommentFixture.instantiate();
+    element.comments = [{
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com',
+      },
+      id: 'baf0414d_60047215',
+      line: 5,
+      message: 'is this a crossover episode!?',
+      updated: '2015-12-08 19:48:33.843000000',
+      path: '/path/to/file.txt',
+      unresolved: false,
+    }];
+    flushAsynchronousOperations();
+  });
+
+  test('ack and done should be hidden', () => {
+    element.changeNum = '42';
+    element.patchNum = '1';
+
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const ackBtn = element.shadowRoot.querySelector('#ackBtn');
+    const doneBtn = element.shadowRoot.querySelector('#doneBtn');
+    assert.equal(ackBtn, null);
+    assert.equal(doneBtn, null);
+  });
+
+  test('reply and quote button should be visible', () => {
+    const commentEl = element.shadowRoot
+        .querySelector('gr-comment');
+    assert.ok(commentEl);
+
+    const replyBtn = element.shadowRoot.querySelector('#replyBtn');
+    const quoteBtn = element.shadowRoot.querySelector('#quoteBtn');
+    assert.ok(replyBtn);
+    assert.ok(quoteBtn);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
index ee9df11..2c8a61a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
@@ -14,11 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 import '../../../styles/shared-styles.js';
-import '../../core/gr-reporting/gr-reporting.js';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
 import '../gr-button/gr-button.js';
@@ -32,15 +29,16 @@
 import '../gr-textarea/gr-textarea.js';
 import '../gr-tooltip-content/gr-tooltip-content.js';
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js';
+import '../gr-account-label/gr-account-label.js';
 import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-comment_html.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {getRootElement} from '../../../scripts/rootElement.js';
-import {GrDisplayNameUtils} from '../../../scripts/gr-display-name-utils/gr-display-name-utils.js';
+import {getDisplayName} from '../../../utils/display-name-util.js';
+import {appContext} from '../../../services/app-context.js';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
@@ -49,6 +47,7 @@
 const DRAFT_SINGULAR = 'draft...';
 const DRAFT_PLURAL = 'drafts...';
 const SAVED_MESSAGE = 'All changes saved';
+const UNSAVED_MESSAGE = 'Unable to save draft';
 
 const REPORT_CREATE_DRAFT = 'CreateDraftComment';
 const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
@@ -56,6 +55,8 @@
 
 const FILE = 'FILE';
 
+export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
+
 /**
  * All candidates tips to show, will pick randomly.
  */
@@ -69,13 +70,10 @@
 ];
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrComment extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrComment extends KeyboardShortcutMixin(GestureEventListeners(
+    LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-comment'; }
@@ -166,6 +164,7 @@
       collapsed: {
         type: Boolean,
         value: true,
+        reflectToAttribute: true,
         observer: '_toggleCollapseClass',
       },
       /** @type {?} */
@@ -214,12 +213,21 @@
         type: Boolean,
         value: false,
       },
+      showPatchset: {
+        type: Boolean,
+        value: true,
+      },
       _respectfulReviewTip: String,
       _respectfulTipDismissed: {
         type: Boolean,
         value: false,
       },
       _serverConfig: Object,
+      _unableToSave: {
+        type: Boolean,
+        value: false,
+      },
+      _selfAccount: Object,
     };
   }
 
@@ -241,9 +249,17 @@
     };
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   /** @override */
   attached() {
     super.attached();
+    this.$.restAPI.getAccount().then(account => {
+      this._selfAccount = account;
+    });
     if (this.editing) {
       this.collapsed = false;
     } else if (this.comment) {
@@ -266,6 +282,10 @@
     }
   }
 
+  _getAuthor(comment) {
+    return comment.author || this._selfAccount;
+  }
+
   _onEditingChange(editing) {
     this.dispatchEvent(new CustomEvent('comment-editing-changed', {
       detail: !!editing,
@@ -283,7 +303,7 @@
       this._showRespectfulTip = true;
       const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
       this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
-      this.$.reporting.reportInteraction(
+      this.reporting.reportInteraction(
           'respectful-tip-appeared',
           {tip: this._respectfulReviewTip}
       );
@@ -303,7 +323,7 @@
 
   _dismissRespectfulTip() {
     this._respectfulTipDismissed = true;
-    this.$.reporting.reportInteraction(
+    this.reporting.reportInteraction(
         'respectful-tip-dismissed',
         {tip: this._respectfulReviewTip}
     );
@@ -312,7 +332,7 @@
   }
 
   _onRespectfulReadMoreClick() {
-    this.$.reporting.reportInteraction('respectful-read-more-clicked');
+    this.reporting.reportInteraction('respectful-read-more-clicked');
   }
 
   get textarea() {
@@ -343,9 +363,13 @@
     return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
   }
 
+  _computeShowHideAriaLabel(collapsed) {
+    return collapsed ? 'Expand' : 'Collapse';
+  }
+
   _calculateActionstoShow(showActions, isRobotComment) {
     // Polymer 2: check for undefined
-    if ([showActions, isRobotComment].some(arg => arg === undefined)) {
+    if ([showActions, isRobotComment].includes(undefined)) {
       return;
     }
 
@@ -365,6 +389,16 @@
     return this.$.restAPI.getIsAdmin();
   }
 
+  _computeDraftTooltip(unableToSave) {
+    return unableToSave ? `Unable to save draft. Please try to save again.` :
+      `This draft is only visible to you. To publish drafts, click the 'Reply'`
+    + `or 'Start review' button at the top of the change or press the 'A' key.`;
+  }
+
+  _computeDraftText(unableToSave) {
+    return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
+  }
+
   /**
    * @param {*=} opt_comment
    */
@@ -445,10 +479,8 @@
    * @return {!Object}
    */
   _getEventPayload(opt_mixin) {
-    return Object.assign({}, opt_mixin, {
-      comment: this.comment,
-      patchNum: this.patchNum,
-    });
+    return {...opt_mixin, comment: this.comment,
+      patchNum: this.patchNum};
   }
 
   _fireSave() {
@@ -467,6 +499,10 @@
     });
   }
 
+  _computeAccountLabelClass(draft) {
+    return draft ? 'draft' : '';
+  }
+
   _draftChanged(draft) {
     this.$.container.classList.toggle('draft', draft);
   }
@@ -479,7 +515,10 @@
 
     this.$.container.classList.toggle('editing', editing);
     if (this.comment && this.comment.id) {
-      this.shadowRoot.querySelector('.cancel').hidden = !editing;
+      const cancelButton = this.shadowRoot.querySelector('.cancel');
+      if (cancelButton) {
+        cancelButton.hidden = !editing;
+      }
     }
     if (this.comment) {
       this.comment.__editing = this.editing;
@@ -583,7 +622,7 @@
     e.preventDefault();
     this._messageText = this.comment.message;
     this.editing = true;
-    this.$.reporting.recordDraftInteraction();
+    this.reporting.recordDraftInteraction();
   }
 
   _handleSave(e) {
@@ -595,7 +634,7 @@
     }
     const timingLabel = this.comment.id ?
       REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
-    const timer = this.$.reporting.getTimer(timingLabel);
+    const timer = this.reporting.getTimer(timingLabel);
     this.set('comment.__editing', false);
     return this.save().then(() => { timer.end(); });
   }
@@ -643,7 +682,7 @@
 
   _handleDiscard(e) {
     e.preventDefault();
-    this.$.reporting.recordDraftInteraction();
+    this.reporting.recordDraftInteraction();
 
     if (!this._messageText) {
       this._discardDraft();
@@ -658,7 +697,7 @@
 
   _handleConfirmDiscard(e) {
     e.preventDefault();
-    const timer = this.$.reporting.getTimer(REPORT_DISCARD_DRAFT);
+    const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
     this._closeConfirmDiscardOverlay();
     return this._discardDraft().then(() => { timer.end(); });
   }
@@ -699,7 +738,10 @@
     this._closeOverlay(this.confirmDiscardOverlay);
   }
 
-  _getSavingMessage(numPending) {
+  _getSavingMessage(numPending, requestFailed) {
+    if (requestFailed) {
+      return UNSAVED_MESSAGE;
+    }
     if (numPending === 0) {
       return SAVED_MESSAGE;
     }
@@ -726,10 +768,12 @@
     // Cancel the debouncer so that error toasts from the error-manager will
     // not be overridden.
     this.cancelDebouncer('draft-toast');
+    this._updateRequestToast(this._numPendingDraftRequests.number,
+        /* requestFailed=*/true);
   }
 
-  _updateRequestToast(numPending) {
-    const message = this._getSavingMessage(numPending);
+  _updateRequestToast(numPending, requestFailed) {
+    const message = this._getSavingMessage(numPending, requestFailed);
     this.debounce('draft-toast', () => {
       // Note: the event is fired on the body rather than this element because
       // this element may not be attached by the time this executes, in which
@@ -743,9 +787,13 @@
     this._showStartRequest();
     return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
         .then(result => {
-          if (result.ok) {
+          if (result.ok) { // remove
+            this._unableToSave = false;
+            this.$.container.classList.remove('unableToSave');
             this._showEndRequest();
           } else {
+            this.$.container.classList.add('unableToSave');
+            this._unableToSave = true;
             this._handleFailedDraftRequest();
           }
           return result;
@@ -771,7 +819,7 @@
 
   _loadLocalDraft(changeNum, patchNum, comment) {
     // Polymer 2: check for undefined
-    if ([changeNum, patchNum, comment].some(arg => arg === undefined)) {
+    if ([changeNum, patchNum, comment].includes(undefined)) {
       return;
     }
 
@@ -799,7 +847,7 @@
   }
 
   _handleToggleResolved() {
-    this.$.reporting.recordDraftInteraction();
+    this.reporting.recordDraftInteraction();
     this.resolved = !this.resolved;
     // Modify payload instead of this.comment, as this.comment is passed from
     // the parent by ref.
@@ -834,7 +882,7 @@
       return comment.robot_id;
     }
     if (comment.author) {
-      return GrDisplayNameUtils.getDisplayName(serverConfig, comment.author);
+      return getDisplayName(serverConfig, comment.author);
     }
     return '';
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
deleted file mode 100644
index fc79cba..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.js
+++ /dev/null
@@ -1,462 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      font-family: var(--font-family);
-      padding: var(--spacing-m);
-    }
-    :host([disabled]) {
-      pointer-events: none;
-    }
-    :host([disabled]) .actions,
-    :host([disabled]) .robotActions,
-    :host([disabled]) .date {
-      opacity: 0.5;
-    }
-    :host([discarding]) {
-      display: none;
-    }
-    .header {
-      align-items: center;
-      cursor: pointer;
-      display: flex;
-      margin: calc(0px - var(--spacing-m)) calc(0px - var(--spacing-m)) 0
-        calc(0px - var(--spacing-m));
-      padding: var(--spacing-m);
-    }
-    .headerLeft > span {
-      font-weight: var(--font-weight-bold);
-    }
-    .container.collapsed .header {
-      margin-bottom: calc(0 - var(--spacing-m));
-    }
-    .headerMiddle {
-      color: var(--deemphasized-text-color);
-      flex: 1;
-      overflow: hidden;
-    }
-    .draftLabel,
-    .draftTooltip {
-      color: var(--deemphasized-text-color);
-      display: none;
-    }
-    .date {
-      justify-content: flex-end;
-      margin-left: 5px;
-      min-width: 4.5em;
-      text-align: right;
-      white-space: nowrap;
-    }
-    span.date {
-      color: var(--deemphasized-text-color);
-    }
-    span.date:hover {
-      text-decoration: underline;
-    }
-    .actions,
-    .robotActions {
-      display: flex;
-      justify-content: flex-end;
-      padding-top: 0;
-    }
-    .action {
-      margin-left: var(--spacing-l);
-    }
-    .rightActions {
-      display: flex;
-      justify-content: flex-end;
-    }
-    .rightActions gr-button {
-      --gr-button: {
-        height: 20px;
-        padding: 0 var(--spacing-s);
-      }
-    }
-    .editMessage {
-      display: none;
-      margin: var(--spacing-m) 0;
-      width: 100%;
-    }
-    .container:not(.draft) .actions .hideOnPublished {
-      display: none;
-    }
-    .draft .reply,
-    .draft .quote,
-    .draft .ack,
-    .draft .done {
-      display: none;
-    }
-    .draft .draftLabel,
-    .draft .draftTooltip {
-      display: inline;
-    }
-    .draft:not(.editing) .save,
-    .draft:not(.editing) .cancel {
-      display: none;
-    }
-    .editing .message,
-    .editing .reply,
-    .editing .quote,
-    .editing .ack,
-    .editing .done,
-    .editing .edit,
-    .editing .discard,
-    .editing .unresolved {
-      display: none;
-    }
-    .editing .editMessage {
-      display: block;
-    }
-    .show-hide {
-      margin-left: var(--spacing-s);
-    }
-    .robotId {
-      color: var(--deemphasized-text-color);
-      margin-bottom: var(--spacing-m);
-      margin-top: -0.4em;
-    }
-    .robotIcon {
-      margin-right: var(--spacing-xs);
-      /* because of the antenna of the robot, it looks off center even when it
-         is centered. artificially adjust margin to account for this. */
-      margin-top: -4px;
-    }
-    .runIdInformation {
-      margin: var(--spacing-m) 0;
-    }
-    .robotRun {
-      margin-left: var(--spacing-m);
-    }
-    .robotRunLink {
-      margin-left: var(--spacing-m);
-    }
-    input.show-hide {
-      display: none;
-    }
-    label.show-hide {
-      cursor: pointer;
-      display: block;
-    }
-    label.show-hide iron-icon {
-      vertical-align: top;
-    }
-    #container .collapsedContent {
-      display: none;
-    }
-    #container.collapsed {
-      padding-bottom: 3px;
-    }
-    #container.collapsed .collapsedContent {
-      display: block;
-      overflow: hidden;
-      padding-left: 5px;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    #container.collapsed .actions,
-    #container.collapsed gr-formatted-text,
-    #container.collapsed gr-textarea,
-    #container.collapsed .respectfulReviewTip {
-      display: none;
-    }
-    .resolve,
-    .unresolved {
-      align-items: center;
-      display: flex;
-      flex: 1;
-      margin: 0;
-    }
-    .resolve label {
-      color: var(--comment-text-color);
-    }
-    gr-dialog .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    .comment-extra-note {
-      color: var(--deemphasized-text-color);
-      border: 1px solid var(--deemphasized-text-color);
-      border-radius: var(--border-radius);
-      padding: 0px var(--spacing-s);
-    }
-    #deleteBtn {
-      display: none;
-      --gr-button: {
-        color: var(--deemphasized-text-color);
-        padding: 0;
-      }
-    }
-    #deleteBtn.showDeleteButtons {
-      display: block;
-    }
-
-    /** Disable select for the caret and actions */
-    .actions,
-    .show-hide {
-      -webkit-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-      user-select: none;
-    }
-
-    .respectfulReviewTip {
-      justify-content: space-between;
-      display: flex;
-      padding: var(--spacing-m);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      margin-bottom: var(--spacing-m);
-    }
-    .respectfulReviewTip div {
-      display: flex;
-    }
-    .respectfulReviewTip div iron-icon {
-      margin-right: var(--spacing-s);
-    }
-    .respectfulReviewTip a {
-      white-space: nowrap;
-      margin-right: var(--spacing-s);
-      padding-left: var(--spacing-m);
-      text-decoration: none;
-    }
-    .pointer {
-      cursor: pointer;
-    }
-  </style>
-  <div id="container" class="container">
-    <div class="header" id="header" on-click="_handleToggleCollapsed">
-      <div class="headerLeft">
-        <span class="authorName">
-          [[_computeAuthorName(comment, _serverConfig)]]
-        </span>
-        <span class="draftLabel">DRAFT</span>
-        <gr-tooltip-content
-          class="draftTooltip"
-          has-tooltip=""
-          title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'A' key."
-          max-width="20em"
-          show-icon=""
-        ></gr-tooltip-content>
-      </div>
-      <div class="headerMiddle">
-        <span class="collapsedContent">[[comment.message]]</span>
-      </div>
-      <div
-        hidden$="[[_computeHideRunDetails(comment, collapsed)]]"
-        class="runIdMessage message"
-      >
-        <div class="runIdInformation">
-          <a class="robotRunLink" href$="[[comment.url]]">
-            <span class="robotRun link">Run Details</span>
-          </a>
-        </div>
-      </div>
-      <template is="dom-if" if="[[comment.extraNote]]">
-        <span class="comment-extra-note">[[comment.extraNote]]</span>
-      </template>
-      <gr-button
-        id="deleteBtn"
-        link=""
-        class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
-        hidden$="[[isRobotComment]]"
-        on-click="_handleCommentDelete"
-      >
-        <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
-      </gr-button>
-      <span class="date" on-click="_handleAnchorClick">
-        <gr-date-formatter
-          has-tooltip=""
-          date-str="[[comment.updated]]"
-        ></gr-date-formatter>
-      </span>
-      <div class="show-hide">
-        <label class="show-hide">
-          <input
-            type="checkbox"
-            class="show-hide"
-            checked$="[[collapsed]]"
-            on-change="_handleToggleCollapsed"
-          />
-          <iron-icon id="icon" icon="[[_computeShowHideIcon(collapsed)]]">
-          </iron-icon>
-        </label>
-      </div>
-    </div>
-    <div class="body">
-      <template is="dom-if" if="[[isRobotComment]]">
-        <div class="robotId" hidden$="[[collapsed]]">
-          [[comment.author.name]]
-        </div>
-      </template>
-      <template is="dom-if" if="[[editing]]">
-        <gr-textarea
-          id="editTextarea"
-          class="editMessage"
-          autocomplete="on"
-          code=""
-          disabled="{{disabled}}"
-          rows="4"
-          text="{{_messageText}}"
-        ></gr-textarea>
-        <template
-          is="dom-if"
-          if="[[_computeVisibilityOfTip(_showRespectfulTip, _respectfulTipDismissed)]]"
-        >
-          <div class="respectfulReviewTip">
-            <div>
-              <gr-tooltip-content
-                has-tooltip=""
-                title="Tips for respectful code reviews."
-              >
-                <iron-icon
-                  class="pointer"
-                  icon="gr-icons:lightbulb-outline"
-                ></iron-icon>
-              </gr-tooltip-content>
-              [[_respectfulReviewTip]]
-            </div>
-            <div>
-              <a
-                tabindex="-1"
-                on-click="_onRespectfulReadMoreClick"
-                href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
-                target="_blank"
-              >
-                Read more
-              </a>
-              <a
-                tabindex="-1"
-                class="close pointer"
-                on-click="_dismissRespectfulTip"
-                >Not helpful</a
-              >
-            </div>
-          </div>
-        </template>
-      </template>
-      <!--The message class is needed to ensure selectability from
-        gr-diff-selection.-->
-      <gr-formatted-text
-        class="message"
-        content="[[comment.message]]"
-        no-trailing-margin="[[!comment.__draft]]"
-        config="[[projectConfig.commentlinks]]"
-      ></gr-formatted-text>
-      <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
-        <div class="action resolve hideOnPublished">
-          <label>
-            <input
-              type="checkbox"
-              id="resolvedCheckbox"
-              checked="[[resolved]]"
-              on-change="_handleToggleResolved"
-            />
-            Resolved
-          </label>
-        </div>
-        <div class="rightActions">
-          <gr-button
-            link=""
-            class="action cancel hideOnPublished"
-            on-click="_handleCancel"
-            >Cancel</gr-button
-          >
-          <gr-button
-            link=""
-            class="action discard hideOnPublished"
-            on-click="_handleDiscard"
-            >Discard</gr-button
-          >
-          <gr-button
-            link=""
-            class="action edit hideOnPublished"
-            on-click="_handleEdit"
-            >Edit</gr-button
-          >
-          <gr-button
-            link=""
-            disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
-            class="action save hideOnPublished"
-            on-click="_handleSave"
-            >Save</gr-button
-          >
-        </div>
-      </div>
-      <div class="robotActions" hidden$="[[!_showRobotActions]]">
-        <template is="dom-if" if="[[isRobotComment]]">
-          <gr-endpoint-decorator name="robot-comment-controls">
-            <gr-endpoint-param name="comment" value="[[comment]]">
-            </gr-endpoint-param>
-          </gr-endpoint-decorator>
-          <gr-button
-            link=""
-            secondary=""
-            class="action show-fix"
-            hidden$="[[_hasNoFix(comment)]]"
-            on-click="_handleShowFix"
-          >
-            Show Fix
-          </gr-button>
-          <template is="dom-if" if="[[!_hasHumanReply]]">
-            <gr-button
-              link=""
-              class="action fix"
-              on-click="_handleFix"
-              disabled="[[robotButtonDisabled]]"
-            >
-              Please Fix
-            </gr-button>
-          </template>
-        </template>
-      </div>
-    </div>
-  </div>
-  <template is="dom-if" if="[[_enableOverlay]]">
-    <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
-      <gr-confirm-delete-comment-dialog
-        id="confirmDeleteComment"
-        on-confirm="_handleConfirmDeleteComment"
-        on-cancel="_handleCancelDeleteComment"
-      >
-      </gr-confirm-delete-comment-dialog>
-    </gr-overlay>
-    <gr-overlay id="confirmDiscardOverlay" with-backdrop="">
-      <gr-dialog
-        id="confirmDiscardDialog"
-        confirm-label="Discard"
-        confirm-on-enter=""
-        on-confirm="_handleConfirmDiscard"
-        on-cancel="_closeConfirmDiscardOverlay"
-      >
-        <div class="header" slot="header">
-          Discard comment
-        </div>
-        <div class="main" slot="main">
-          Are you sure you want to discard this draft comment?
-        </div>
-      </gr-dialog>
-    </gr-overlay>
-  </template>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  <gr-storage id="storage"></gr-storage>
-  <gr-reporting id="reporting"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
new file mode 100644
index 0000000..f557295
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -0,0 +1,477 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      font-family: var(--font-family);
+      padding: var(--spacing-m);
+    }
+    :host([collapsed]) {
+      padding: var(--spacing-s) var(--spacing-m);
+    }
+    :host([disabled]) {
+      pointer-events: none;
+    }
+    :host([disabled]) .actions,
+    :host([disabled]) .robotActions,
+    :host([disabled]) .date {
+      opacity: 0.5;
+    }
+    :host([discarding]) {
+      display: none;
+    }
+    .body {
+      padding-top: var(--spacing-m);
+    }
+    .header {
+      align-items: center;
+      cursor: pointer;
+      display: flex;
+    }
+    .headerLeft > span {
+      font-weight: var(--font-weight-bold);
+    }
+    .headerMiddle {
+      color: var(--deemphasized-text-color);
+      flex: 1;
+      overflow: hidden;
+    }
+    .draftLabel,
+    .draftTooltip {
+      color: var(--deemphasized-text-color);
+      display: none;
+    }
+    .date {
+      justify-content: flex-end;
+      text-align: right;
+      white-space: nowrap;
+    }
+    span.date {
+      color: var(--deemphasized-text-color);
+    }
+    span.date:hover {
+      text-decoration: underline;
+    }
+    .actions,
+    .robotActions {
+      display: flex;
+      justify-content: flex-end;
+      padding-top: 0;
+    }
+    .robotActions {
+      /* Better than the negative margin would be to remove the gr-button
+       * padding, but then we would also need to fix the buttons that are
+       * inserted by plugins. :-/ */
+      margin: 4px 0 -4px;
+    }
+    .action {
+      margin-left: var(--spacing-l);
+    }
+    .rightActions {
+      display: flex;
+      justify-content: flex-end;
+    }
+    .rightActions gr-button {
+      --gr-button: {
+        height: 20px;
+        padding: 0 var(--spacing-s);
+      }
+    }
+    .editMessage {
+      display: none;
+      margin: var(--spacing-m) 0;
+      width: 100%;
+    }
+    .container:not(.draft) .actions .hideOnPublished {
+      display: none;
+    }
+    .draft .reply,
+    .draft .quote,
+    .draft .ack,
+    .draft .done {
+      display: none;
+    }
+    .draft .draftLabel,
+    .draft .draftTooltip {
+      display: inline;
+    }
+    .draft:not(.editing):not(.unableToSave) .save,
+    .draft:not(.editing) .cancel {
+      display: none;
+    }
+    .editing .message,
+    .editing .reply,
+    .editing .quote,
+    .editing .ack,
+    .editing .done,
+    .editing .edit,
+    .editing .discard,
+    .editing .unresolved {
+      display: none;
+    }
+    .editing .editMessage {
+      display: block;
+    }
+    .show-hide {
+      margin-left: var(--spacing-s);
+    }
+    .robotId {
+      color: var(--deemphasized-text-color);
+      margin-bottom: var(--spacing-m);
+    }
+    .robotRun {
+      margin-left: var(--spacing-m);
+    }
+    .robotRunLink {
+      margin-left: var(--spacing-m);
+    }
+    input.show-hide {
+      display: none;
+    }
+    label.show-hide {
+      cursor: pointer;
+      display: block;
+    }
+    label.show-hide iron-icon {
+      vertical-align: top;
+    }
+    #container .collapsedContent {
+      display: none;
+    }
+    #container.collapsed .body {
+      padding-top: 0;
+    }
+    #container.collapsed .collapsedContent {
+      display: block;
+      overflow: hidden;
+      padding-left: var(--spacing-m);
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    #container.collapsed #deleteBtn,
+    #container.collapsed .date,
+    #container.collapsed .actions,
+    #container.collapsed gr-formatted-text,
+    #container.collapsed gr-textarea,
+    #container.collapsed .respectfulReviewTip {
+      display: none;
+    }
+    .resolve,
+    .unresolved {
+      align-items: center;
+      display: flex;
+      flex: 1;
+      margin: 0;
+    }
+    .resolve label {
+      color: var(--comment-text-color);
+    }
+    gr-dialog .main {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+    }
+    #deleteBtn {
+      display: none;
+      --gr-button: {
+        color: var(--deemphasized-text-color);
+        padding: 0;
+      }
+    }
+    #deleteBtn.showDeleteButtons {
+      display: block;
+    }
+
+    /** Disable select for the caret and actions */
+    .actions,
+    .show-hide {
+      -webkit-user-select: none;
+      -moz-user-select: none;
+      -ms-user-select: none;
+      user-select: none;
+    }
+
+    .respectfulReviewTip {
+      justify-content: space-between;
+      display: flex;
+      padding: var(--spacing-m);
+      border: 1px solid var(--border-color);
+      border-radius: var(--border-radius);
+      margin-bottom: var(--spacing-m);
+    }
+    .respectfulReviewTip div {
+      display: flex;
+    }
+    .respectfulReviewTip div iron-icon {
+      margin-right: var(--spacing-s);
+    }
+    .respectfulReviewTip a {
+      white-space: nowrap;
+      margin-right: var(--spacing-s);
+      padding-left: var(--spacing-m);
+      text-decoration: none;
+    }
+    .pointer {
+      cursor: pointer;
+    }
+    .patchset-text {
+      color: var(--deemphasized-text-color);
+      margin-left: var(--spacing-s);
+    }
+    .headerLeft gr-account-label {
+      --gr-account-label-text-style: {
+        font-weight: var(--font-weight-bold);
+      }
+      --account-max-length: 120px;
+      width: 120px;
+    }
+    .draft gr-account-label {
+      width: unset;
+    }
+  </style>
+  <div id="container" class="container">
+    <div class="header" id="header" on-click="_handleToggleCollapsed">
+      <div class="headerLeft">
+        <gr-account-label
+          account="[[_getAuthor(comment, _selfAccount)]]"
+          class$="[[_computeAccountLabelClass(draft)]]"
+          hide-status=""
+        >
+        </gr-account-label>
+        <gr-tooltip-content
+          class="draftTooltip"
+          has-tooltip=""
+          title="[[_computeDraftTooltip(_unableToSave)]]"
+          max-width="20em"
+          show-icon=""
+        >
+          <span class="draftLabel">[[_computeDraftText(_unableToSave)]]</span>
+        </gr-tooltip-content>
+      </div>
+      <div class="headerMiddle">
+        <span class="collapsedContent">[[comment.message]]</span>
+      </div>
+      <div
+        hidden$="[[_computeHideRunDetails(comment, collapsed)]]"
+        class="runIdMessage message"
+      >
+        <div class="runIdInformation">
+          <a class="robotRunLink" href$="[[comment.url]]">
+            <span class="robotRun link">Run Details</span>
+          </a>
+        </div>
+      </div>
+      <gr-button
+        id="deleteBtn"
+        link=""
+        class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
+        hidden$="[[isRobotComment]]"
+        on-click="_handleCommentDelete"
+      >
+        <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
+      </gr-button>
+      <template is="dom-if" if="[[showPatchset]]">
+        <span class="patchset-text"> Patchset [[patchNum]]</span>
+      </template>
+      <span class="separator"></span>
+      <template is="dom-if" if="[[comment.updated]]">
+        <span class="date" tabindex="0" on-click="_handleAnchorClick">
+          <gr-date-formatter
+            has-tooltip=""
+            date-str="[[comment.updated]]"
+          ></gr-date-formatter>
+        </span>
+      </template>
+      <div class="show-hide" tabindex="0">
+        <label
+          class="show-hide"
+          aria-label="[[_computeShowHideAriaLabel(collapsed)]]"
+        >
+          <input
+            type="checkbox"
+            class="show-hide"
+            checked$="[[collapsed]]"
+            on-change="_handleToggleCollapsed"
+          />
+          <iron-icon id="icon" icon="[[_computeShowHideIcon(collapsed)]]">
+          </iron-icon>
+        </label>
+      </div>
+    </div>
+    <div class="body">
+      <template is="dom-if" if="[[isRobotComment]]">
+        <div class="robotId" hidden$="[[collapsed]]">
+          [[comment.author.name]]
+        </div>
+      </template>
+      <template is="dom-if" if="[[editing]]">
+        <gr-textarea
+          id="editTextarea"
+          class="editMessage"
+          autocomplete="on"
+          code=""
+          disabled="{{disabled}}"
+          rows="4"
+          text="{{_messageText}}"
+        ></gr-textarea>
+        <template
+          is="dom-if"
+          if="[[_computeVisibilityOfTip(_showRespectfulTip, _respectfulTipDismissed)]]"
+        >
+          <div class="respectfulReviewTip">
+            <div>
+              <gr-tooltip-content
+                has-tooltip=""
+                title="Tips for respectful code reviews."
+              >
+                <iron-icon
+                  class="pointer"
+                  icon="gr-icons:lightbulb-outline"
+                ></iron-icon>
+              </gr-tooltip-content>
+              [[_respectfulReviewTip]]
+            </div>
+            <div>
+              <a
+                tabindex="-1"
+                on-click="_onRespectfulReadMoreClick"
+                href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
+                target="_blank"
+              >
+                Read more
+              </a>
+              <a
+                tabindex="-1"
+                class="close pointer"
+                on-click="_dismissRespectfulTip"
+                >Not helpful</a
+              >
+            </div>
+          </div>
+        </template>
+      </template>
+      <!--The message class is needed to ensure selectability from
+        gr-diff-selection.-->
+      <gr-formatted-text
+        class="message"
+        content="[[comment.message]]"
+        no-trailing-margin="[[!comment.__draft]]"
+        config="[[projectConfig.commentlinks]]"
+      ></gr-formatted-text>
+      <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
+        <div class="action resolve hideOnPublished">
+          <label>
+            <input
+              type="checkbox"
+              id="resolvedCheckbox"
+              checked="[[resolved]]"
+              on-change="_handleToggleResolved"
+            />
+            Resolved
+          </label>
+        </div>
+        <template is="dom-if" if="[[draft]]">
+          <div class="rightActions">
+            <gr-button
+              link=""
+              class="action cancel hideOnPublished"
+              on-click="_handleCancel"
+              >Cancel</gr-button
+            >
+            <gr-button
+              link=""
+              class="action discard hideOnPublished"
+              on-click="_handleDiscard"
+              >Discard</gr-button
+            >
+            <gr-button
+              link=""
+              class="action edit hideOnPublished"
+              on-click="_handleEdit"
+              >Edit</gr-button
+            >
+            <gr-button
+              link=""
+              disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
+              class="action save hideOnPublished"
+              on-click="_handleSave"
+              >Save</gr-button
+            >
+          </div>
+        </template>
+      </div>
+      <div class="robotActions" hidden$="[[!_showRobotActions]]">
+        <template is="dom-if" if="[[isRobotComment]]">
+          <gr-endpoint-decorator name="robot-comment-controls">
+            <gr-endpoint-param name="comment" value="[[comment]]">
+            </gr-endpoint-param>
+          </gr-endpoint-decorator>
+          <gr-button
+            link=""
+            secondary=""
+            class="action show-fix"
+            hidden$="[[_hasNoFix(comment)]]"
+            on-click="_handleShowFix"
+          >
+            Show Fix
+          </gr-button>
+          <template is="dom-if" if="[[!_hasHumanReply]]">
+            <gr-button
+              link=""
+              class="action fix"
+              on-click="_handleFix"
+              disabled="[[robotButtonDisabled]]"
+            >
+              Please Fix
+            </gr-button>
+          </template>
+        </template>
+      </div>
+    </div>
+  </div>
+  <template is="dom-if" if="[[_enableOverlay]]">
+    <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
+      <gr-confirm-delete-comment-dialog
+        id="confirmDeleteComment"
+        on-confirm="_handleConfirmDeleteComment"
+        on-cancel="_handleCancelDeleteComment"
+      >
+      </gr-confirm-delete-comment-dialog>
+    </gr-overlay>
+    <gr-overlay id="confirmDiscardOverlay" with-backdrop="">
+      <gr-dialog
+        id="confirmDiscardDialog"
+        confirm-label="Discard"
+        confirm-on-enter=""
+        on-confirm="_handleConfirmDiscard"
+        on-cancel="_closeConfirmDiscardOverlay"
+      >
+        <div class="header" slot="header">
+          Discard comment
+        </div>
+        <div class="main" slot="main">
+          Are you sure you want to discard this draft comment?
+        </div>
+      </gr-dialog>
+    </gr-overlay>
+  </template>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  <gr-storage id="storage"></gr-storage>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
deleted file mode 100644
index 56581d4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.html
+++ /dev/null
@@ -1,1297 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-comment</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script src="/node_modules/page/page.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-comment></gr-comment>
-  </template>
-</test-fixture>
-
-<test-fixture id="draft">
-  <template>
-    <gr-comment draft="true"></gr-comment>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-comment.js';
-function isVisible(el) {
-  assert.ok(el);
-  return getComputedStyle(el).getPropertyValue('display') !== 'none';
-}
-
-suite('gr-comment tests', () => {
-  suite('basic tests', () => {
-    let element;
-    let sandbox;
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-      });
-      element = fixture('basic');
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        id: 'baf0414d_60047215',
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000',
-      };
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('collapsible comments', () => {
-      // When a comment (not draft) is loaded, it should be collapsed
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-
-      // The header middle content is only visible when comments are collapsed.
-      // It shows the message in a condensed way, and limits to a single line.
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      // When the header row is clicked, the comment should expand
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is not visible');
-    });
-
-    test('clicking on date link fires event', () => {
-      element.side = 'PARENT';
-      const stub = sinon.stub();
-      element.addEventListener('comment-anchor-tap', stub);
-      const dateEl = element.shadowRoot
-          .querySelector('.date');
-      assert.ok(dateEl);
-      MockInteractions.tap(dateEl);
-
-      assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail,
-          {side: element.side, number: element.comment.line});
-    });
-
-    test('message is not retrieved from storage when other edits', done => {
-      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
-      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1;
-      element.patchNum = 1;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        line: 5,
-        __otherEditing: true,
-      };
-      flush(() => {
-        assert.isTrue(loadSpy.called);
-        assert.isFalse(storageStub.called);
-        done();
-      });
-    });
-
-    test('message is retrieved from storage when no other edits', done => {
-      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
-      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1;
-      element.patchNum = 1;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        line: 5,
-      };
-      flush(() => {
-        assert.isTrue(loadSpy.called);
-        assert.isTrue(storageStub.called);
-        done();
-      });
-    });
-
-    test('_getPatchNum', () => {
-      element.side = 'PARENT';
-      element.patchNum = 1;
-      assert.equal(element._getPatchNum(), 'PARENT');
-      element.side = 'REVISION';
-      assert.equal(element._getPatchNum(), 1);
-    });
-
-    test('comment expand and collapse', () => {
-      element.collapsed = true;
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      element.collapsed = false;
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is is not visible');
-    });
-
-    suite('while editing', () => {
-      setup(() => {
-        element.editing = true;
-        element._messageText = 'test';
-        sandbox.stub(element, '_handleCancel');
-        sandbox.stub(element, '_handleSave');
-        flushAsynchronousOperations();
-      });
-
-      suite('when text is empty', () => {
-        setup(() => {
-          element._messageText = '';
-          element.comment = {};
-        });
-
-        test('esc closes comment when text is empty', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 27); // esc
-          assert.isTrue(element._handleCancel.called);
-        });
-
-        test('ctrl+enter does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 13, 'ctrl'); // ctrl + enter
-          assert.isFalse(element._handleSave.called);
-        });
-
-        test('meta+enter does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 13, 'meta'); // meta + enter
-          assert.isFalse(element._handleSave.called);
-        });
-
-        test('ctrl+s does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 83, 'ctrl'); // ctrl + s
-          assert.isFalse(element._handleSave.called);
-        });
-      });
-
-      test('esc does not close comment that has content', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 27); // esc
-        assert.isFalse(element._handleCancel.called);
-      });
-
-      test('ctrl+enter saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 13, 'ctrl'); // ctrl + enter
-        assert.isTrue(element._handleSave.called);
-      });
-
-      test('meta+enter saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 13, 'meta'); // meta + enter
-        assert.isTrue(element._handleSave.called);
-      });
-
-      test('ctrl+s saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 83, 'ctrl'); // ctrl + s
-        assert.isTrue(element._handleSave.called);
-      });
-    });
-
-    test('extra note shown if exists', () => {
-      element.comment = {id: 'abc_123', extraNote: 'asd'};
-      flushAsynchronousOperations();
-      assert.equal(element.shadowRoot
-          .querySelector('.comment-extra-note')
-          .textContent, 'asd');
-    });
-
-    test('delete comment button for non-admins is hidden', () => {
-      element._isAdmin = false;
-      assert.isFalse(element.shadowRoot
-          .querySelector('.action.delete')
-          .classList.contains('showDeleteButtons'));
-    });
-
-    test('delete comment button for admins with draft is hidden', () => {
-      element._isAdmin = false;
-      element.draft = true;
-      assert.isFalse(element.shadowRoot
-          .querySelector('.action.delete')
-          .classList.contains('showDeleteButtons'));
-    });
-
-    test('delete comment', done => {
-      sandbox.stub(
-          element.$.restAPI, 'deleteComment').returns(Promise.resolve({}));
-      sandbox.spy(element.confirmDeleteOverlay, 'open');
-      element.changeNum = 42;
-      element.patchNum = 0xDEADBEEF;
-      element._isAdmin = true;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.action.delete')
-          .classList.contains('showDeleteButtons'));
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.action.delete'));
-      flush(() => {
-        element.confirmDeleteOverlay.open.lastCall.returnValue.then(() => {
-          const dialog =
-              window.confirmDeleteOverlay
-                  .querySelector('#confirmDeleteComment');
-          dialog.message = 'removal reason';
-          element._handleConfirmDeleteComment();
-          assert.isTrue(element.$.restAPI.deleteComment.calledWith(
-              42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
-          done();
-        });
-      });
-    });
-
-    suite('draft update reporting', () => {
-      let endStub;
-      let getTimerStub;
-      let mockEvent;
-
-      setup(() => {
-        mockEvent = {preventDefault() {}};
-        sandbox.stub(element, 'save')
-            .returns(Promise.resolve({}));
-        sandbox.stub(element, '_discardDraft')
-            .returns(Promise.resolve({}));
-        endStub = sinon.stub();
-        getTimerStub = sandbox.stub(element.$.reporting, 'getTimer')
-            .returns({end: endStub});
-      });
-
-      test('create', () => {
-        element.comment = {};
-        return element._handleSave(mockEvent).then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-        });
-      });
-
-      test('update', () => {
-        element.comment = {id: 'abc_123'};
-        return element._handleSave(mockEvent).then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
-        });
-      });
-
-      test('discard', () => {
-        element.comment = {id: 'abc_123'};
-        sandbox.stub(element, '_closeConfirmDiscardOverlay');
-        return element._handleConfirmDiscard(mockEvent).then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
-        });
-      });
-    });
-
-    test('edit reports interaction', () => {
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('discard reports interaction', () => {
-      const reportStub = sandbox.stub(element.$.reporting,
-          'recordDraftInteraction');
-      element.draft = true;
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.discard'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-  });
-
-  suite('gr-comment draft tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-        getConfig() { return Promise.resolve({}); },
-        saveDiffDraft() {
-          return Promise.resolve({
-            ok: true,
-            text() {
-              return Promise.resolve(
-                  ')]}\'\n{' +
-                  '"id": "baf0414d_40572e03",' +
-                  '"path": "/path/to/file",' +
-                  '"line": 5,' +
-                  '"updated": "2015-12-08 21:52:36.177000000",' +
-                  '"message": "saved!"' +
-                '}'
-              );
-            },
-          });
-        },
-        removeChangeReviewer() {
-          return Promise.resolve({ok: true});
-        },
-      });
-      stub('gr-storage', {
-        getDraftComment() { return null; },
-      });
-      element = fixture('draft');
-      element.changeNum = 42;
-      element.patchNum = 1;
-      element.editing = false;
-      element.comment = {
-        __commentSide: 'right',
-        __draft: true,
-        __draftID: 'temp_draft_id',
-        path: '/path/to/file',
-        line: 5,
-      };
-      element.commentSide = 'right';
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('button visibility states', () => {
-      element.showActions = false;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.showActions = true;
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.draft = true;
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.edit')), 'edit is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.discard')), 'discard is visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.save')), 'save is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.cancel')), 'cancel is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.resolve')), 'resolve is visible');
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.editing = true;
-      flushAsynchronousOperations();
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.edit')), 'edit is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.discard')), 'discard not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.save')), 'save is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.cancel')), 'cancel is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.resolve')), 'resolve is visible');
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.draft = false;
-      element.editing = false;
-      flushAsynchronousOperations();
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.edit')), 'edit is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.discard')),
-      'discard is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.save')), 'save is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.cancel')), 'cancel is not visible');
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.comment.id = 'foo';
-      element.draft = true;
-      element.editing = true;
-      flushAsynchronousOperations();
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.cancel')), 'cancel is visible');
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      // Delete button is not hidden by default
-      assert.isFalse(element.shadowRoot.querySelector('#deleteBtn').hidden);
-
-      element.isRobotComment = true;
-      element.draft = true;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isFalse(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      // It is not expected to see Robot comment drafts, but if they appear,
-      // they will behave the same as non-drafts.
-      element.draft = false;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isFalse(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      // A robot comment with run ID should display plain text.
-      element.set(['comment', 'robot_run_id'], 'text');
-      element.editing = false;
-      element.collapsed = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotRun.link').textContent === 'Run Details');
-
-      // A robot comment with run ID and url should display a link.
-      element.set(['comment', 'url'], '/path/to/run');
-      flushAsynchronousOperations();
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.robotRun.link')).display,
-      'none');
-
-      // Delete button is hidden for robot comments
-      assert.isTrue(element.shadowRoot.querySelector('#deleteBtn').hidden);
-    });
-
-    test('collapsible drafts', () => {
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is is not visible');
-
-      // When the edit button is pressed, should still see the actions
-      // and also textarea
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      flushAsynchronousOperations();
-      assert.isFalse(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is not visible');
-
-      // When toggle again, everything should be hidden except for textarea
-      // and header middle content should be visible
-      MockInteractions.tap(element.$.header);
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-textarea')),
-      'textarea is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      // When toggle again, textarea should remain open in the state it was
-      // before
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is not visible');
-    });
-
-    test('robot comment layout', done => {
-      const comment = Object.assign({
-        robot_id: 'happy_robot_id',
-        url: '/robot/comment',
-        author: {
-          name: 'Happy Robot',
-        },
-      }, element.comment);
-      element.comment = comment;
-      element.collapsed = false;
-      flush(() => {
-        let runIdMessage;
-        runIdMessage = element.shadowRoot
-            .querySelector('.runIdMessage');
-        assert.isFalse(runIdMessage.hidden);
-
-        const runDetailsLink = element.shadowRoot
-            .querySelector('.robotRunLink');
-        assert.isTrue(runDetailsLink.href.indexOf(element.comment.url) !== -1);
-
-        const robotServiceName = element.shadowRoot
-            .querySelector('.authorName');
-        assert.equal(robotServiceName.textContent.trim(), 'happy_robot_id');
-
-        const authorName = element.shadowRoot
-            .querySelector('.robotId');
-        assert.isTrue(authorName.innerText === 'Happy Robot');
-
-        element.collapsed = true;
-        flushAsynchronousOperations();
-        runIdMessage = element.shadowRoot
-            .querySelector('.runIdMessage');
-        assert.isTrue(runIdMessage.hidden);
-        done();
-      });
-    });
-
-    test('author name fallback to email', done => {
-      const comment = Object.assign({
-        url: '/robot/comment',
-        author: {
-          email: 'test@test.com',
-        },
-      }, element.comment);
-      element.comment = comment;
-      element.collapsed = false;
-      flush(() => {
-        const authorName = element.shadowRoot
-            .querySelector('.authorName');
-        assert.equal(authorName.innerText.trim(), 'test@test.com');
-        done();
-      });
-    });
-
-    test('draft creation/cancellation', done => {
-      assert.isFalse(element.editing);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      assert.isTrue(element.editing);
-
-      element._messageText = '';
-      const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
-
-      // Save should be disabled on an empty message.
-      let disabled = element.shadowRoot
-          .querySelector('.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-      element._messageText = '     ';
-      disabled = element.shadowRoot
-          .querySelector('.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-
-      const updateStub = sinon.stub();
-      element.addEventListener('comment-update', updateStub);
-
-      let numDiscardEvents = 0;
-      element.addEventListener('comment-discard', e => {
-        numDiscardEvents++;
-        assert.isFalse(eraseMessageDraftSpy.called);
-        if (numDiscardEvents === 2) {
-          assert.isFalse(updateStub.called);
-          done();
-        }
-      });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.cancel'));
-      element.flushDebouncer('fire-update');
-      element._messageText = '';
-      flushAsynchronousOperations();
-      MockInteractions.pressAndReleaseKeyOn(element.textarea, 27); // esc
-    });
-
-    test('draft discard removes message from storage', done => {
-      element._messageText = '';
-      const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
-      sandbox.stub(element, '_closeConfirmDiscardOverlay');
-
-      element.addEventListener('comment-discard', e => {
-        assert.isTrue(eraseMessageDraftSpy.called);
-        done();
-      });
-      element._handleConfirmDiscard({preventDefault: sinon.stub()});
-    });
-
-    test('storage is cleared only after save success', () => {
-      element._messageText = 'test';
-      const eraseStub = sandbox.stub(element, '_eraseDraftComment');
-      sandbox.stub(element.$.restAPI, 'getResponseObject')
-          .returns(Promise.resolve({}));
-
-      sandbox.stub(element, '_saveDraft').returns(Promise.resolve({ok: false}));
-
-      const savePromise = element.save();
-      assert.isFalse(eraseStub.called);
-      return savePromise.then(() => {
-        assert.isFalse(eraseStub.called);
-
-        element._saveDraft.restore();
-        sandbox.stub(element, '_saveDraft')
-            .returns(Promise.resolve({ok: true}));
-        return element.save().then(() => {
-          assert.isTrue(eraseStub.called);
-        });
-      });
-    });
-
-    test('_computeSaveDisabled', () => {
-      const comment = {unresolved: true};
-      const msgComment = {message: 'test', unresolved: true};
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-      assert.equal(element._computeSaveDisabled('test', comment, false), false);
-      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
-      assert.equal(
-          element._computeSaveDisabled('test', msgComment, false), false);
-      assert.equal(
-          element._computeSaveDisabled('test2', msgComment, false), false);
-      assert.equal(element._computeSaveDisabled('test', comment, true), false);
-      assert.equal(element._computeSaveDisabled('', comment, true), true);
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-    });
-
-    suite('confirm discard', () => {
-      let discardStub;
-      let overlayStub;
-      let mockEvent;
-
-      setup(() => {
-        discardStub = sandbox.stub(element, '_discardDraft');
-        overlayStub = sandbox.stub(element, '_openOverlay')
-            .returns(Promise.resolve());
-        mockEvent = {preventDefault: sinon.stub()};
-      });
-
-      test('confirms discard of comments with message text', () => {
-        element._messageText = 'test';
-        element._handleDiscard(mockEvent);
-        assert.isTrue(overlayStub.calledWith(element.confirmDiscardOverlay));
-        assert.isFalse(discardStub.called);
-      });
-
-      test('no confirmation for comments without message text', () => {
-        element._messageText = '';
-        element._handleDiscard(mockEvent);
-        assert.isFalse(overlayStub.called);
-        assert.isTrue(discardStub.calledOnce);
-      });
-    });
-
-    test('ctrl+s saves comment', done => {
-      const stub = sinon.stub(element, 'save', () => {
-        assert.isTrue(stub.called);
-        stub.restore();
-        done();
-        return Promise.resolve();
-      });
-      element._messageText = 'is that the horse from horsing around??';
-      element.editing = true;
-      flushAsynchronousOperations();
-      MockInteractions.pressAndReleaseKeyOn(
-          element.textarea.$.textarea.textarea,
-          83, 'ctrl'); // 'ctrl + s'
-    });
-
-    test('draft saving/editing', done => {
-      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-      const cancelDebounce = sandbox.stub(element, 'cancelDebouncer');
-
-      element.draft = true;
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      element._messageText = 'good news, everyone!';
-      element.flushDebouncer('fire-update');
-      element.flushDebouncer('store');
-      assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-update'),
-      assert.isTrue(dispatchEventStub.calledTwice);
-
-      element._messageText = 'good news, everyone!';
-      element.flushDebouncer('fire-update');
-      element.flushDebouncer('store');
-      assert.isTrue(dispatchEventStub.calledTwice);
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-
-      assert.isTrue(element.disabled,
-          'Element should be disabled when creating draft.');
-
-      element._xhrPromise.then(draft => {
-        assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-save');
-        assert(cancelDebounce.calledWith('store'));
-
-        assert.deepEqual(dispatchEventStub.lastCall.args[0].detail, {
-          comment: {
-            __commentSide: 'right',
-            __draft: true,
-            __draftID: 'temp_draft_id',
-            id: 'baf0414d_40572e03',
-            line: 5,
-            message: 'saved!',
-            path: '/path/to/file',
-            updated: '2015-12-08 21:52:36.177000000',
-          },
-          patchNum: 1,
-        });
-        assert.isFalse(element.disabled,
-            'Element should be enabled when done creating draft.');
-        assert.equal(draft.message, 'saved!');
-        assert.isFalse(element.editing);
-      }).then(() => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.edit'));
-        element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
-            'a world where humans are killed on sight.';
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.save'));
-        assert.isTrue(element.disabled,
-            'Element should be disabled when updating draft.');
-
-        element._xhrPromise.then(draft => {
-          assert.isFalse(element.disabled,
-              'Element should be enabled when done updating draft.');
-          assert.equal(draft.message, 'saved!');
-          assert.isFalse(element.editing);
-          dispatchEventStub.restore();
-          done();
-        });
-      });
-    });
-
-    test('draft prevent save when disabled', () => {
-      const saveStub = sandbox.stub(element, 'save').returns(Promise.resolve());
-      element.showActions = true;
-      element.draft = true;
-      MockInteractions.tap(element.$.header);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      element._messageText = 'good news, everyone!';
-      element.flushDebouncer('fire-update');
-      element.flushDebouncer('store');
-
-      element.disabled = true;
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-      assert.isFalse(saveStub.called);
-
-      element.disabled = false;
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-      assert.isTrue(saveStub.calledOnce);
-    });
-
-    test('proper event fires on resolve, comment is not saved', done => {
-      const save = sandbox.stub(element, 'save');
-      element.addEventListener('comment-update', e => {
-        assert.isTrue(e.detail.comment.unresolved);
-        assert.isFalse(save.called);
-        done();
-      });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.resolve input'));
-    });
-
-    test('resolved comment state indicated by checkbox', () => {
-      sandbox.stub(element, 'save');
-      element.comment = {unresolved: false};
-      assert.isTrue(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      element.comment = {unresolved: true};
-      assert.isFalse(element.shadowRoot
-          .querySelector('.resolve input').checked);
-    });
-
-    test('resolved checkbox saves with tap when !editing', () => {
-      element.editing = false;
-      const save = sandbox.stub(element, 'save');
-
-      element.comment = {unresolved: false};
-      assert.isTrue(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      element.comment = {unresolved: true};
-      assert.isFalse(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      assert.isFalse(save.called);
-      MockInteractions.tap(element.$.resolvedCheckbox);
-      assert.isTrue(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      assert.isTrue(save.called);
-    });
-
-    suite('draft saving messages', () => {
-      test('_getSavingMessage', () => {
-        assert.equal(element._getSavingMessage(0), 'All changes saved');
-        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
-        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
-        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
-      });
-
-      test('_show{Start,End}Request', () => {
-        const updateStub = sandbox.stub(element, '_updateRequestToast');
-        element._numPendingDraftRequests.number = 1;
-
-        element._showStartRequest();
-        assert.isTrue(updateStub.calledOnce);
-        assert.equal(updateStub.lastCall.args[0], 2);
-        assert.equal(element._numPendingDraftRequests.number, 2);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledTwice);
-        assert.equal(updateStub.lastCall.args[0], 1);
-        assert.equal(element._numPendingDraftRequests.number, 1);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledThrice);
-        assert.equal(updateStub.lastCall.args[0], 0);
-        assert.equal(element._numPendingDraftRequests.number, 0);
-      });
-    });
-
-    test('cancelling an unsaved draft discards, persists in storage', () => {
-      const discardSpy = sandbox.spy(element, '_fireDiscard');
-      const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
-      const eraseStub = sandbox.stub(element.$.storage, 'eraseDraftComment');
-      element._messageText = 'test text';
-      flushAsynchronousOperations();
-      element.flushDebouncer('store');
-
-      assert.isTrue(storeStub.called);
-      assert.equal(storeStub.lastCall.args[1], 'test text');
-      element._handleCancel({preventDefault: () => {}});
-      assert.isTrue(discardSpy.called);
-      assert.isFalse(eraseStub.called);
-    });
-
-    test('cancelling edit on a saved draft does not store', () => {
-      element.comment.id = 'foo';
-      const discardSpy = sandbox.spy(element, '_fireDiscard');
-      const storeStub = sandbox.stub(element.$.storage, 'setDraftComment');
-      element._messageText = 'test text';
-      flushAsynchronousOperations();
-      element.flushDebouncer('store');
-
-      assert.isFalse(storeStub.called);
-      element._handleCancel({preventDefault: () => {}});
-      assert.isTrue(discardSpy.called);
-    });
-
-    test('deleting text from saved draft and saving deletes the draft', () => {
-      element.comment = {id: 'foo', message: 'test'};
-      element._messageText = '';
-      const discardStub = sandbox.stub(element, '_discardDraft');
-
-      element.save();
-      assert.isTrue(discardStub.called);
-    });
-
-    test('_handleFix fires create-fix event', done => {
-      element.addEventListener('create-fix-comment', e => {
-        assert.deepEqual(e.detail, element._getEventPayload());
-        done();
-      });
-      element.isRobotComment = true;
-      element.comments = [element.comment];
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.fix'));
-    });
-
-    test('do not show Please Fix button if human reply exists', () => {
-      element.comments = [
-        {
-          robot_id: 'happy_robot_id',
-          robot_run_id: '5838406743490560',
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf',
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com',
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-              },
-            ],
-          },
-          patch_set: 1,
-          id: 'eb0d03fd_5e95904f',
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000',
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          __commentSide: 'right',
-          collapsed: false,
-        },
-        {
-          __draft: true,
-          __draftID: '0.wbrfbwj89sa',
-          __date: '2019-12-04T13:41:03.689Z',
-          path: 'Documentation/config-gerrit.txt',
-          patchNum: 1,
-          side: 'REVISION',
-          __commentSide: 'right',
-          line: 10,
-          in_reply_to: 'eb0d03fd_5e95904f',
-          message: '> This is a robot comment with a fix.\n\nPlease fix.',
-          unresolved: true,
-        },
-      ];
-      element.comment = element.comments[0];
-      flushAsynchronousOperations();
-      assert.isNull(element.shadowRoot
-          .querySelector('robotActions gr-button'));
-    });
-
-    test('show Please Fix if no human reply', () => {
-      element.comments = [
-        {
-          robot_id: 'happy_robot_id',
-          robot_run_id: '5838406743490560',
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf',
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com',
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-              },
-            ],
-          },
-          patch_set: 1,
-          id: 'eb0d03fd_5e95904f',
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000',
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          __commentSide: 'right',
-          collapsed: false,
-        },
-      ];
-      element.comment = element.comments[0];
-      flushAsynchronousOperations();
-      assert.isNotNull(element.shadowRoot
-          .querySelector('.robotActions gr-button'));
-    });
-
-    test('_handleShowFix fires open-fix-preview event', done => {
-      element.addEventListener('open-fix-preview', e => {
-        assert.deepEqual(e.detail, element._getEventPayload());
-        done();
-      });
-      element.comment = {fix_suggestions: [{}]};
-      element.isRobotComment = true;
-      flushAsynchronousOperations();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.show-fix'));
-    });
-  });
-
-  suite('respectful tips', () => {
-    let element;
-    let sandbox;
-    let clock;
-    setup(() => {
-      stub('gr-rest-api-interface', {
-        getAccount() { return Promise.resolve(null); },
-      });
-      clock = sinon.useFakeTimers();
-      sandbox = sinon.sandbox.create();
-    });
-
-    teardown(() => {
-      clock.restore();
-      sandbox.restore();
-    });
-
-    test('show tip when no cached record', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility() { return respectfulSetStub(); },
-      });
-      respectfulGetStub.returns(null);
-      element = fixture('draft');
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true};
-      flush(() => {
-        assert.isTrue(respectfulGetStub.called);
-        assert.isTrue(respectfulSetStub.called);
-        assert.isTrue(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-        done();
-      });
-    });
-
-    test('add 14-day delays once dismissed', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility(days) { return respectfulSetStub(days); },
-      });
-      respectfulGetStub.returns(null);
-      element = fixture('draft');
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true};
-      flush(() => {
-        assert.isTrue(respectfulGetStub.called);
-        assert.isTrue(respectfulSetStub.called);
-        assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
-        assert.isTrue(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.respectfulReviewTip .close'));
-        flushAsynchronousOperations();
-        assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
-        done();
-      });
-    });
-
-    test('do not show tip when fall out of probability', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility() { return respectfulSetStub(); },
-      });
-      respectfulGetStub.returns(null);
-      element = fixture('draft');
-      // fake random
-      element.getRandomNum = () => 3;
-      element.comment = {__editing: true};
-      flush(() => {
-        assert.isTrue(respectfulGetStub.called);
-        assert.isFalse(respectfulSetStub.called);
-        assert.isFalse(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-        done();
-      });
-    });
-
-    test('show tip when editing changed to true', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility() { return respectfulSetStub(); },
-      });
-      respectfulGetStub.returns(null);
-      element = fixture('draft');
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: false};
-      flush(() => {
-        assert.isFalse(respectfulGetStub.called);
-        assert.isFalse(respectfulSetStub.called);
-        assert.isFalse(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-
-        element.editing = true;
-        flush(() => {
-          assert.isTrue(respectfulGetStub.called);
-          assert.isTrue(respectfulSetStub.called);
-          assert.isTrue(
-              !!element.shadowRoot.querySelector('.respectfulReviewTip')
-          );
-          done();
-        });
-      });
-    });
-
-    test('no tip when cached record', done => {
-      // fake stub for storage
-      const respectfulGetStub = sinon.stub();
-      const respectfulSetStub = sinon.stub();
-      stub('gr-storage', {
-        getRespectfulTipVisibility() { return respectfulGetStub(); },
-        setRespectfulTipVisibility() { return respectfulSetStub(); },
-      });
-      respectfulGetStub.returns({});
-      element = fixture('draft');
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true};
-      flush(() => {
-        assert.isTrue(respectfulGetStub.called);
-        assert.isFalse(respectfulSetStub.called);
-        assert.isFalse(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-        done();
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
new file mode 100644
index 0000000..e7aee7c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
@@ -0,0 +1,1324 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-comment.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {__testOnly_UNSAVED_MESSAGE} from './gr-comment.js';
+
+const basicFixture = fixtureFromElement('gr-comment');
+
+const draftFixture = fixtureFromTemplate(html`
+<gr-comment draft="true"></gr-comment>
+`);
+
+function isVisible(el) {
+  assert.ok(el);
+  return getComputedStyle(el).getPropertyValue('display') !== 'none';
+}
+
+suite('gr-comment tests', () => {
+  suite('basic tests', () => {
+    let element;
+
+    let openOverlaySpy;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getAccount() {
+          return Promise.resolve({
+            email: 'dhruvsri@google.com',
+            name: 'Dhruv Srivastava',
+            _account_id: 1083225,
+            avatars: [{url: 'abc', height: 32}],
+          });
+        },
+      });
+      element = basicFixture.instantiate();
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+      };
+
+      openOverlaySpy = sinon.spy(element, '_openOverlay');
+    });
+
+    teardown(() => {
+      openOverlaySpy.getCalls().forEach(call => {
+        call.args[0].remove();
+      });
+    });
+
+    test('collapsible comments', () => {
+      // When a comment (not draft) is loaded, it should be collapsed
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+
+      // The header middle content is only visible when comments are collapsed.
+      // It shows the message in a condensed way, and limits to a single line.
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
+
+      // When the header row is clicked, the comment should expand
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is not visible');
+    });
+
+    test('clicking on date link fires event', () => {
+      element.side = 'PARENT';
+      const stub = sinon.stub();
+      element.addEventListener('comment-anchor-tap', stub);
+      flushAsynchronousOperations();
+      const dateEl = element.shadowRoot
+          .querySelector('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail,
+          {side: element.side, number: element.comment.line});
+    });
+
+    test('message is not retrieved from storage when other edits', done => {
+      const storageStub = sinon.stub(element.$.storage, 'getDraftComment');
+      const loadSpy = sinon.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1;
+      element.patchNum = 1;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        line: 5,
+        __otherEditing: true,
+      };
+      flush(() => {
+        assert.isTrue(loadSpy.called);
+        assert.isFalse(storageStub.called);
+        done();
+      });
+    });
+
+    test('message is retrieved from storage when no other edits', done => {
+      const storageStub = sinon.stub(element.$.storage, 'getDraftComment');
+      const loadSpy = sinon.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1;
+      element.patchNum = 1;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        line: 5,
+      };
+      flush(() => {
+        assert.isTrue(loadSpy.called);
+        assert.isTrue(storageStub.called);
+        done();
+      });
+    });
+
+    test('_getPatchNum', () => {
+      element.side = 'PARENT';
+      element.patchNum = 1;
+      assert.equal(element._getPatchNum(), 'PARENT');
+      element.side = 'REVISION';
+      assert.equal(element._getPatchNum(), 1);
+    });
+
+    test('comment expand and collapse', () => {
+      element.collapsed = true;
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
+
+      element.collapsed = false;
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is is not visible');
+    });
+
+    suite('while editing', () => {
+      setup(() => {
+        element.editing = true;
+        element._messageText = 'test';
+        sinon.stub(element, '_handleCancel');
+        sinon.stub(element, '_handleSave');
+        flushAsynchronousOperations();
+      });
+
+      suite('when text is empty', () => {
+        setup(() => {
+          element._messageText = '';
+          element.comment = {};
+        });
+
+        test('esc closes comment when text is empty', () => {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.textarea, 27); // esc
+          assert.isTrue(element._handleCancel.called);
+        });
+
+        test('ctrl+enter does not save', () => {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.textarea, 13, 'ctrl'); // ctrl + enter
+          assert.isFalse(element._handleSave.called);
+        });
+
+        test('meta+enter does not save', () => {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.textarea, 13, 'meta'); // meta + enter
+          assert.isFalse(element._handleSave.called);
+        });
+
+        test('ctrl+s does not save', () => {
+          MockInteractions.pressAndReleaseKeyOn(
+              element.textarea, 83, 'ctrl'); // ctrl + s
+          assert.isFalse(element._handleSave.called);
+        });
+      });
+
+      test('esc does not close comment that has content', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 27); // esc
+        assert.isFalse(element._handleCancel.called);
+      });
+
+      test('ctrl+enter saves', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 13, 'ctrl'); // ctrl + enter
+        assert.isTrue(element._handleSave.called);
+      });
+
+      test('meta+enter saves', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 13, 'meta'); // meta + enter
+        assert.isTrue(element._handleSave.called);
+      });
+
+      test('ctrl+s saves', () => {
+        MockInteractions.pressAndReleaseKeyOn(
+            element.textarea, 83, 'ctrl'); // ctrl + s
+        assert.isTrue(element._handleSave.called);
+      });
+    });
+
+    test('delete comment button for non-admins is hidden', () => {
+      element._isAdmin = false;
+      assert.isFalse(element.shadowRoot
+          .querySelector('.action.delete')
+          .classList.contains('showDeleteButtons'));
+    });
+
+    test('delete comment button for admins with draft is hidden', () => {
+      element._isAdmin = false;
+      element.draft = true;
+      assert.isFalse(element.shadowRoot
+          .querySelector('.action.delete')
+          .classList.contains('showDeleteButtons'));
+    });
+
+    test('delete comment', done => {
+      sinon.stub(
+          element.$.restAPI, 'deleteComment').returns(Promise.resolve({}));
+      sinon.spy(element.confirmDeleteOverlay, 'open');
+      element.changeNum = 42;
+      element.patchNum = 0xDEADBEEF;
+      element._isAdmin = true;
+      assert.isTrue(element.shadowRoot
+          .querySelector('.action.delete')
+          .classList.contains('showDeleteButtons'));
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.action.delete'));
+      flush(() => {
+        element.confirmDeleteOverlay.open.lastCall.returnValue.then(() => {
+          const dialog =
+              window.confirmDeleteOverlay
+                  .querySelector('#confirmDeleteComment');
+          dialog.message = 'removal reason';
+          element._handleConfirmDeleteComment();
+          assert.isTrue(element.$.restAPI.deleteComment.calledWith(
+              42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
+          done();
+        });
+      });
+    });
+
+    suite('draft update reporting', () => {
+      let endStub;
+      let getTimerStub;
+      let mockEvent;
+
+      setup(() => {
+        mockEvent = {preventDefault() {}};
+        sinon.stub(element, 'save')
+            .returns(Promise.resolve({}));
+        sinon.stub(element, '_discardDraft')
+            .returns(Promise.resolve({}));
+        endStub = sinon.stub();
+        getTimerStub = sinon.stub(element.reporting, 'getTimer')
+            .returns({end: endStub});
+      });
+
+      test('create', () => {
+        element.comment = {};
+        return element._handleSave(mockEvent).then(() => {
+          assert.equal(element.shadowRoot.querySelector('gr-account-label').
+              shadowRoot.querySelector('span').innerText.trim(),
+          'Dhruv Srivastava');
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
+        });
+      });
+
+      test('update', () => {
+        element.comment = {id: 'abc_123'};
+        return element._handleSave(mockEvent).then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
+        });
+      });
+
+      test('discard', () => {
+        element.comment = {id: 'abc_123'};
+        sinon.stub(element, '_closeConfirmDiscardOverlay');
+        return element._handleConfirmDiscard(mockEvent).then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
+        });
+      });
+    });
+
+    test('edit reports interaction', () => {
+      const reportStub = sinon.stub(element.reporting,
+          'recordDraftInteraction');
+      element.draft = true;
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('discard reports interaction', () => {
+      const reportStub = sinon.stub(element.reporting,
+          'recordDraftInteraction');
+      element.draft = true;
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.discard'));
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('failed save draft request', done => {
+      element.draft = true;
+      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
+      const diffDraftStub =
+        sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
+            Promise.resolve({ok: false}));
+      element._saveDraft();
+      flush(() => {
+        let args = updateRequestStub.lastCall.args;
+        assert.deepEqual(args, [0, true]);
+        assert.equal(element._getSavingMessage(...args),
+            __testOnly_UNSAVED_MESSAGE);
+        assert.equal(element.shadowRoot.querySelector('.draftLabel').innerText,
+            'DRAFT(Failed to save)');
+        assert.isTrue(isVisible(element.shadowRoot
+            .querySelector('.save')), 'save is visible');
+        diffDraftStub.returns(
+            Promise.resolve({ok: true}));
+        element._saveDraft();
+        flush(() => {
+          args = updateRequestStub.lastCall.args;
+          assert.deepEqual(args, [0]);
+          assert.equal(element._getSavingMessage(...args),
+              'All changes saved');
+          assert.equal(element.shadowRoot.querySelector('.draftLabel')
+              .innerText, 'DRAFT');
+          assert.isFalse(isVisible(element.shadowRoot
+              .querySelector('.save')), 'save is not visible');
+          assert.isFalse(element._unableToSave);
+          done();
+        });
+      });
+    });
+  });
+
+  suite('gr-comment draft tests', () => {
+    let element;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getAccount() { return Promise.resolve(null); },
+        getConfig() { return Promise.resolve({}); },
+        saveDiffDraft() {
+          return Promise.resolve({
+            ok: true,
+            text() {
+              return Promise.resolve(
+                  ')]}\'\n{' +
+                  '"id": "baf0414d_40572e03",' +
+                  '"path": "/path/to/file",' +
+                  '"line": 5,' +
+                  '"updated": "2015-12-08 21:52:36.177000000",' +
+                  '"message": "saved!"' +
+                '}'
+              );
+            },
+          });
+        },
+        removeChangeReviewer() {
+          return Promise.resolve({ok: true});
+        },
+      });
+      stub('gr-storage', {
+        getDraftComment() { return null; },
+      });
+      element = draftFixture.instantiate();
+      element.changeNum = 42;
+      element.patchNum = 1;
+      element.editing = false;
+      element.comment = {
+        __commentSide: 'right',
+        __draft: true,
+        __draftID: 'temp_draft_id',
+        path: '/path/to/file',
+        line: 5,
+      };
+      element.commentSide = 'right';
+    });
+
+    test('button visibility states', () => {
+      element.showActions = false;
+      assert.isTrue(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      element.showActions = true;
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      element.draft = true;
+      flushAsynchronousOperations();
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.edit')), 'edit is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.discard')), 'discard is visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.resolve')), 'resolve is visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      element.editing = true;
+      flushAsynchronousOperations();
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.edit')), 'edit is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.discard')), 'discard not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.save')), 'save is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.resolve')), 'resolve is visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      element.draft = false;
+      element.editing = false;
+      flushAsynchronousOperations();
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.edit')), 'edit is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.discard')),
+      'discard is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is not visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      element.comment.id = 'foo';
+      element.draft = true;
+      element.editing = true;
+      flushAsynchronousOperations();
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.cancel')), 'cancel is visible');
+      assert.isFalse(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      // Delete button is not hidden by default
+      assert.isFalse(element.shadowRoot.querySelector('#deleteBtn').hidden);
+
+      element.isRobotComment = true;
+      element.draft = true;
+      assert.isTrue(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isFalse(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      // It is not expected to see Robot comment drafts, but if they appear,
+      // they will behave the same as non-drafts.
+      element.draft = false;
+      assert.isTrue(element.shadowRoot
+          .querySelector('.humanActions').hasAttribute('hidden'));
+      assert.isFalse(element.shadowRoot
+          .querySelector('.robotActions').hasAttribute('hidden'));
+
+      // A robot comment with run ID should display plain text.
+      element.set(['comment', 'robot_run_id'], 'text');
+      element.editing = false;
+      element.collapsed = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.shadowRoot
+          .querySelector('.robotRun.link').textContent === 'Run Details');
+
+      // A robot comment with run ID and url should display a link.
+      element.set(['comment', 'url'], '/path/to/run');
+      flushAsynchronousOperations();
+      assert.notEqual(getComputedStyle(element.shadowRoot
+          .querySelector('.robotRun.link')).display,
+      'none');
+
+      // Delete button is hidden for robot comments
+      assert.isTrue(element.shadowRoot.querySelector('#deleteBtn').hidden);
+    });
+
+    test('collapsible drafts', () => {
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
+
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is is not visible');
+
+      // When the edit button is pressed, should still see the actions
+      // and also textarea
+      element.draft = true;
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      flushAsynchronousOperations();
+      assert.isFalse(element.collapsed);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is not visible');
+
+      // When toggle again, everything should be hidden except for textarea
+      // and header middle content should be visible
+      MockInteractions.tap(element.$.header);
+      assert.isTrue(element.collapsed);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are not visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-textarea')),
+      'textarea is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is visible');
+
+      // When toggle again, textarea should remain open in the state it was
+      // before
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('gr-formatted-text')),
+      'gr-formatted-text is not visible');
+      assert.isTrue(isVisible(element.shadowRoot
+          .querySelector('.actions')),
+      'actions are visible');
+      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
+      assert.isFalse(isVisible(element.shadowRoot
+          .querySelector('.collapsedContent')),
+      'header middle content is not visible');
+    });
+
+    test('robot comment layout', done => {
+      const comment = {robot_id: 'happy_robot_id',
+        url: '/robot/comment',
+        author: {
+          name: 'Happy Robot',
+          display_name: 'Display name Robot',
+        }, ...element.comment};
+      element.comment = comment;
+      element.collapsed = false;
+      flush(() => {
+        let runIdMessage;
+        runIdMessage = element.shadowRoot
+            .querySelector('.runIdMessage');
+        assert.isFalse(runIdMessage.hidden);
+
+        const runDetailsLink = element.shadowRoot
+            .querySelector('.robotRunLink');
+        assert.isTrue(runDetailsLink.href.indexOf(element.comment.url) !== -1);
+
+        const robotServiceName = element.shadowRoot
+            .querySelector('gr-account-label').shadowRoot.querySelector('span');
+        assert.equal(robotServiceName.textContent.trim(), 'Display name Robot');
+
+        const authorName = element.shadowRoot
+            .querySelector('.robotId');
+        assert.isTrue(authorName.innerText === 'Happy Robot');
+
+        element.collapsed = true;
+        flushAsynchronousOperations();
+        runIdMessage = element.shadowRoot
+            .querySelector('.runIdMessage');
+        assert.isTrue(runIdMessage.hidden);
+        done();
+      });
+    });
+
+    test('author name fallback to email', done => {
+      const comment = {url: '/robot/comment',
+        author: {
+          email: 'test@test.com',
+        }, ...element.comment};
+      element.comment = comment;
+      element.collapsed = false;
+      flush(() => {
+        const authorName = element.shadowRoot
+            .querySelector('gr-account-label').shadowRoot.querySelector('span');
+        assert.equal(authorName.innerText.trim(), 'test@test.com');
+        done();
+      });
+    });
+
+    test('draft creation/cancellation', done => {
+      assert.isFalse(element.editing);
+      element.draft = true;
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      assert.isTrue(element.editing);
+
+      element._messageText = '';
+      const eraseMessageDraftSpy = sinon.spy(element, '_eraseDraftComment');
+
+      // Save should be disabled on an empty message.
+      let disabled = element.shadowRoot
+          .querySelector('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+      element._messageText = '     ';
+      disabled = element.shadowRoot
+          .querySelector('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+
+      const updateStub = sinon.stub();
+      element.addEventListener('comment-update', updateStub);
+
+      let numDiscardEvents = 0;
+      element.addEventListener('comment-discard', e => {
+        numDiscardEvents++;
+        assert.isFalse(eraseMessageDraftSpy.called);
+        if (numDiscardEvents === 2) {
+          assert.isFalse(updateStub.called);
+          done();
+        }
+      });
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.cancel'));
+      element.flushDebouncer('fire-update');
+      element._messageText = '';
+      flushAsynchronousOperations();
+      MockInteractions.pressAndReleaseKeyOn(element.textarea, 27); // esc
+    });
+
+    test('draft discard removes message from storage', done => {
+      element._messageText = '';
+      const eraseMessageDraftSpy = sinon.spy(element, '_eraseDraftComment');
+      sinon.stub(element, '_closeConfirmDiscardOverlay');
+
+      element.addEventListener('comment-discard', e => {
+        assert.isTrue(eraseMessageDraftSpy.called);
+        done();
+      });
+      element._handleConfirmDiscard({preventDefault: sinon.stub()});
+    });
+
+    test('storage is cleared only after save success', () => {
+      element._messageText = 'test';
+      const eraseStub = sinon.stub(element, '_eraseDraftComment');
+      sinon.stub(element.$.restAPI, 'getResponseObject')
+          .returns(Promise.resolve({}));
+
+      sinon.stub(element, '_saveDraft').returns(Promise.resolve({ok: false}));
+
+      const savePromise = element.save();
+      assert.isFalse(eraseStub.called);
+      return savePromise.then(() => {
+        assert.isFalse(eraseStub.called);
+
+        element._saveDraft.restore();
+        sinon.stub(element, '_saveDraft')
+            .returns(Promise.resolve({ok: true}));
+        return element.save().then(() => {
+          assert.isTrue(eraseStub.called);
+        });
+      });
+    });
+
+    test('_computeSaveDisabled', () => {
+      const comment = {unresolved: true};
+      const msgComment = {message: 'test', unresolved: true};
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+      assert.equal(element._computeSaveDisabled('test', comment, false), false);
+      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
+      assert.equal(
+          element._computeSaveDisabled('test', msgComment, false), false);
+      assert.equal(
+          element._computeSaveDisabled('test2', msgComment, false), false);
+      assert.equal(element._computeSaveDisabled('test', comment, true), false);
+      assert.equal(element._computeSaveDisabled('', comment, true), true);
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+    });
+
+    suite('confirm discard', () => {
+      let discardStub;
+      let overlayStub;
+      let mockEvent;
+
+      setup(() => {
+        discardStub = sinon.stub(element, '_discardDraft');
+        overlayStub = sinon.stub(element, '_openOverlay')
+            .returns(Promise.resolve());
+        mockEvent = {preventDefault: sinon.stub()};
+      });
+
+      test('confirms discard of comments with message text', () => {
+        element._messageText = 'test';
+        element._handleDiscard(mockEvent);
+        assert.isTrue(overlayStub.calledWith(element.confirmDiscardOverlay));
+        assert.isFalse(discardStub.called);
+      });
+
+      test('no confirmation for comments without message text', () => {
+        element._messageText = '';
+        element._handleDiscard(mockEvent);
+        assert.isFalse(overlayStub.called);
+        assert.isTrue(discardStub.calledOnce);
+      });
+    });
+
+    test('ctrl+s saves comment', done => {
+      const stub = sinon.stub(element, 'save').callsFake(() => {
+        assert.isTrue(stub.called);
+        stub.restore();
+        done();
+        return Promise.resolve();
+      });
+      element._messageText = 'is that the horse from horsing around??';
+      element.editing = true;
+      flushAsynchronousOperations();
+      MockInteractions.pressAndReleaseKeyOn(
+          element.textarea.$.textarea.textarea,
+          83, 'ctrl'); // 'ctrl + s'
+    });
+
+    test('draft saving/editing', done => {
+      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+      const cancelDebounce = sinon.stub(element, 'cancelDebouncer');
+
+      element.draft = true;
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+      assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-update');
+      assert.isTrue(dispatchEventStub.calledTwice);
+
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+      assert.isTrue(dispatchEventStub.calledTwice);
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
+
+      assert.isTrue(element.disabled,
+          'Element should be disabled when creating draft.');
+
+      element._xhrPromise.then(draft => {
+        assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-save');
+        assert(cancelDebounce.calledWith('store'));
+
+        assert.deepEqual(dispatchEventStub.lastCall.args[0].detail, {
+          comment: {
+            __commentSide: 'right',
+            __draft: true,
+            __draftID: 'temp_draft_id',
+            id: 'baf0414d_40572e03',
+            line: 5,
+            message: 'saved!',
+            path: '/path/to/file',
+            updated: '2015-12-08 21:52:36.177000000',
+          },
+          patchNum: 1,
+        });
+        assert.isFalse(element.disabled,
+            'Element should be enabled when done creating draft.');
+        assert.equal(draft.message, 'saved!');
+        assert.isFalse(element.editing);
+      }).then(() => {
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('.edit'));
+        element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
+            'a world where humans are killed on sight.';
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('.save'));
+        assert.isTrue(element.disabled,
+            'Element should be disabled when updating draft.');
+
+        element._xhrPromise.then(draft => {
+          assert.isFalse(element.disabled,
+              'Element should be enabled when done updating draft.');
+          assert.equal(draft.message, 'saved!');
+          assert.isFalse(element.editing);
+          dispatchEventStub.restore();
+          done();
+        });
+      });
+    });
+
+    test('draft prevent save when disabled', () => {
+      const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
+      element.showActions = true;
+      element.draft = true;
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.$.header);
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      element._messageText = 'good news, everyone!';
+      element.flushDebouncer('fire-update');
+      element.flushDebouncer('store');
+
+      element.disabled = true;
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
+      assert.isFalse(saveStub.called);
+
+      element.disabled = false;
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.save'));
+      assert.isTrue(saveStub.calledOnce);
+    });
+
+    test('proper event fires on resolve, comment is not saved', done => {
+      const save = sinon.stub(element, 'save');
+      element.addEventListener('comment-update', e => {
+        assert.isTrue(e.detail.comment.unresolved);
+        assert.isFalse(save.called);
+        done();
+      });
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.resolve input'));
+    });
+
+    test('resolved comment state indicated by checkbox', () => {
+      sinon.stub(element, 'save');
+      element.comment = {unresolved: false};
+      assert.isTrue(element.shadowRoot
+          .querySelector('.resolve input').checked);
+      element.comment = {unresolved: true};
+      assert.isFalse(element.shadowRoot
+          .querySelector('.resolve input').checked);
+    });
+
+    test('resolved checkbox saves with tap when !editing', () => {
+      element.editing = false;
+      const save = sinon.stub(element, 'save');
+
+      element.comment = {unresolved: false};
+      assert.isTrue(element.shadowRoot
+          .querySelector('.resolve input').checked);
+      element.comment = {unresolved: true};
+      assert.isFalse(element.shadowRoot
+          .querySelector('.resolve input').checked);
+      assert.isFalse(save.called);
+      MockInteractions.tap(element.$.resolvedCheckbox);
+      assert.isTrue(element.shadowRoot
+          .querySelector('.resolve input').checked);
+      assert.isTrue(save.called);
+    });
+
+    suite('draft saving messages', () => {
+      test('_getSavingMessage', () => {
+        assert.equal(element._getSavingMessage(0), 'All changes saved');
+        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
+        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
+        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
+      });
+
+      test('_show{Start,End}Request', () => {
+        const updateStub = sinon.stub(element, '_updateRequestToast');
+        element._numPendingDraftRequests.number = 1;
+
+        element._showStartRequest();
+        assert.isTrue(updateStub.calledOnce);
+        assert.equal(updateStub.lastCall.args[0], 2);
+        assert.equal(element._numPendingDraftRequests.number, 2);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledTwice);
+        assert.equal(updateStub.lastCall.args[0], 1);
+        assert.equal(element._numPendingDraftRequests.number, 1);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledThrice);
+        assert.equal(updateStub.lastCall.args[0], 0);
+        assert.equal(element._numPendingDraftRequests.number, 0);
+      });
+    });
+
+    test('cancelling an unsaved draft discards, persists in storage', () => {
+      const discardSpy = sinon.spy(element, '_fireDiscard');
+      const storeStub = sinon.stub(element.$.storage, 'setDraftComment');
+      const eraseStub = sinon.stub(element.$.storage, 'eraseDraftComment');
+      element._messageText = 'test text';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isTrue(storeStub.called);
+      assert.equal(storeStub.lastCall.args[1], 'test text');
+      element._handleCancel({preventDefault: () => {}});
+      assert.isTrue(discardSpy.called);
+      assert.isFalse(eraseStub.called);
+    });
+
+    test('cancelling edit on a saved draft does not store', () => {
+      element.comment.id = 'foo';
+      const discardSpy = sinon.spy(element, '_fireDiscard');
+      const storeStub = sinon.stub(element.$.storage, 'setDraftComment');
+      element._messageText = 'test text';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isFalse(storeStub.called);
+      element._handleCancel({preventDefault: () => {}});
+      assert.isTrue(discardSpy.called);
+    });
+
+    test('deleting text from saved draft and saving deletes the draft', () => {
+      element.comment = {id: 'foo', message: 'test'};
+      element._messageText = '';
+      const discardStub = sinon.stub(element, '_discardDraft');
+
+      element.save();
+      assert.isTrue(discardStub.called);
+    });
+
+    test('_handleFix fires create-fix event', done => {
+      element.addEventListener('create-fix-comment', e => {
+        assert.deepEqual(e.detail, element._getEventPayload());
+        done();
+      });
+      element.isRobotComment = true;
+      element.comments = [element.comment];
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.fix'));
+    });
+
+    test('do not show Please Fix button if human reply exists', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id',
+          robot_run_id: '5838406743490560',
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf',
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com',
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+              },
+            ],
+          },
+          patch_set: 1,
+          id: 'eb0d03fd_5e95904f',
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000',
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          __commentSide: 'right',
+          collapsed: false,
+        },
+        {
+          __draft: true,
+          __draftID: '0.wbrfbwj89sa',
+          __date: '2019-12-04T13:41:03.689Z',
+          path: 'Documentation/config-gerrit.txt',
+          patchNum: 1,
+          side: 'REVISION',
+          __commentSide: 'right',
+          line: 10,
+          in_reply_to: 'eb0d03fd_5e95904f',
+          message: '> This is a robot comment with a fix.\n\nPlease fix.',
+          unresolved: true,
+        },
+      ];
+      element.comment = element.comments[0];
+      flushAsynchronousOperations();
+      assert.isNull(element.shadowRoot
+          .querySelector('robotActions gr-button'));
+    });
+
+    test('show Please Fix if no human reply', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id',
+          robot_run_id: '5838406743490560',
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf',
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com',
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+              },
+            ],
+          },
+          patch_set: 1,
+          id: 'eb0d03fd_5e95904f',
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000',
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          __commentSide: 'right',
+          collapsed: false,
+        },
+      ];
+      element.comment = element.comments[0];
+      flushAsynchronousOperations();
+      assert.isNotNull(element.shadowRoot
+          .querySelector('.robotActions gr-button'));
+    });
+
+    test('_handleShowFix fires open-fix-preview event', done => {
+      element.addEventListener('open-fix-preview', e => {
+        assert.deepEqual(e.detail, element._getEventPayload());
+        done();
+      });
+      element.comment = {fix_suggestions: [{}]};
+      element.isRobotComment = true;
+      flushAsynchronousOperations();
+
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.show-fix'));
+    });
+  });
+
+  suite('respectful tips', () => {
+    let element;
+
+    let clock;
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getAccount() { return Promise.resolve(null); },
+      });
+      clock = sinon.useFakeTimers();
+    });
+
+    teardown(() => {
+      clock.restore();
+      sinon.restore();
+    });
+
+    test('show tip when no cached record', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility() { return respectfulSetStub(); },
+      });
+      respectfulGetStub.returns(null);
+      element = draftFixture.instantiate();
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isTrue(respectfulSetStub.called);
+        assert.isTrue(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+        done();
+      });
+    });
+
+    test('add 14-day delays once dismissed', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility(days) { return respectfulSetStub(days); },
+      });
+      respectfulGetStub.returns(null);
+      element = draftFixture.instantiate();
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isTrue(respectfulSetStub.called);
+        assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
+        assert.isTrue(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('.respectfulReviewTip .close'));
+        flushAsynchronousOperations();
+        assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
+        done();
+      });
+    });
+
+    test('do not show tip when fall out of probability', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility() { return respectfulSetStub(); },
+      });
+      respectfulGetStub.returns(null);
+      element = draftFixture.instantiate();
+      // fake random
+      element.getRandomNum = () => 3;
+      element.comment = {__editing: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isFalse(respectfulSetStub.called);
+        assert.isFalse(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+        done();
+      });
+    });
+
+    test('show tip when editing changed to true', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility() { return respectfulSetStub(); },
+      });
+      respectfulGetStub.returns(null);
+      element = draftFixture.instantiate();
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: false};
+      flush(() => {
+        assert.isFalse(respectfulGetStub.called);
+        assert.isFalse(respectfulSetStub.called);
+        assert.isFalse(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+
+        element.editing = true;
+        flush(() => {
+          assert.isTrue(respectfulGetStub.called);
+          assert.isTrue(respectfulSetStub.called);
+          assert.isTrue(
+              !!element.shadowRoot.querySelector('.respectfulReviewTip')
+          );
+          done();
+        });
+      });
+    });
+
+    test('no tip when cached record', done => {
+      // fake stub for storage
+      const respectfulGetStub = sinon.stub();
+      const respectfulSetStub = sinon.stub();
+      stub('gr-storage', {
+        getRespectfulTipVisibility() { return respectfulGetStub(); },
+        setRespectfulTipVisibility() { return respectfulSetStub(); },
+      });
+      respectfulGetStub.returns({});
+      element = draftFixture.instantiate();
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true};
+      flush(() => {
+        assert.isTrue(respectfulGetStub.called);
+        assert.isFalse(respectfulSetStub.called);
+        assert.isFalse(
+            !!element.shadowRoot.querySelector('.respectfulReviewTip')
+        );
+        done();
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
index b0f387b..f9ce12e 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
@@ -14,9 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-
-import '../../../scripts/bundled-polymer.js';
 import '../gr-dialog/gr-dialog.js';
 import '../../../styles/shared-styles.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
@@ -25,7 +22,7 @@
 import {htmlTemplate} from './gr-confirm-delete-comment-dialog_html.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrConfirmDeleteCommentDialog extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.js b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.js
deleted file mode 100644
index 2d0fa6f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.js
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    p {
-      margin-bottom: var(--spacing-l);
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Delete"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <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
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        placeholder="<Insert reasoning here>"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts
new file mode 100644
index 0000000..6876c1a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) {
+      opacity: 0.5;
+      pointer-events: none;
+    }
+    .main {
+      display: flex;
+      flex-direction: column;
+      width: 100%;
+    }
+    p {
+      margin-bottom: var(--spacing-l);
+    }
+    label {
+      cursor: pointer;
+      display: block;
+      width: 100%;
+    }
+    iron-autogrow-textarea {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      width: 73ch; /* Add a char to account for the border. */
+    }
+  </style>
+  <gr-dialog
+    confirm-label="Delete"
+    on-confirm="_handleConfirmTap"
+    on-cancel="_handleCancelTap"
+  >
+    <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
+        id="messageInput"
+        class="message"
+        autocomplete="on"
+        placeholder="<Insert reasoning here>"
+        bind-value="{{message}}"
+      ></iron-autogrow-textarea>
+    </div>
+  </gr-dialog>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
index 0f6168e..39c149f 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '../../../styles/shared-styles.js';
 import '../gr-button/gr-button.js';
@@ -28,7 +26,7 @@
 
 const COPY_TIMEOUT_MS = 1000;
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrCopyClipboard extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.js
deleted file mode 100644
index 8378de5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.js
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .text {
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-    }
-    .copyText {
-      flex-grow: 1;
-      margin-right: var(--spacing-s);
-    }
-    .hideInput {
-      display: none;
-    }
-    input#input {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      @apply --text-container-style;
-      width: 100%;
-    }
-    /*
-       * Typically icons are 20px, which is the normal line-height.
-       * The copy icon is too prominent at 20px, so we choose 16px
-       * here, but add 2x2px padding below, so the entire
-       * component should still fit nicely into a normal inline
-       * layout flow.
-       */
-    #icon {
-      height: 16px;
-      width: 16px;
-    }
-    gr-button {
-      --gr-button: {
-        padding: 2px;
-      }
-    }
-  </style>
-  <div class="text">
-    <iron-input
-      class="copyText"
-      type="text"
-      bind-value="[[text]]"
-      on-tap="_handleInputClick"
-      readonly=""
-    >
-      <input
-        id="input"
-        is="iron-input"
-        class$="[[_computeInputClass(hideInput)]]"
-        type="text"
-        bind-value="[[text]]"
-        on-click="_handleInputClick"
-        readonly=""
-      />
-    </iron-input>
-    <gr-button
-      id="button"
-      link=""
-      has-tooltip="[[hasTooltip]]"
-      class="copyToClipboard"
-      title="[[buttonTitle]]"
-      on-click="_copyToClipboard"
-    >
-      <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
-    </gr-button>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts
new file mode 100644
index 0000000..197c94c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .text {
+      align-items: center;
+      display: flex;
+      flex-wrap: wrap;
+    }
+    .copyText {
+      flex-grow: 1;
+      margin-right: var(--spacing-s);
+    }
+    .hideInput {
+      display: none;
+    }
+    input#input {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      @apply --text-container-style;
+      width: 100%;
+    }
+    /*
+       * Typically icons are 20px, which is the normal line-height.
+       * The copy icon is too prominent at 20px, so we choose 16px
+       * here, but add 2x2px padding below, so the entire
+       * component should still fit nicely into a normal inline
+       * layout flow.
+       */
+    #icon {
+      height: 16px;
+      width: 16px;
+    }
+    gr-button {
+      --gr-button: {
+        padding: 2px;
+      }
+    }
+  </style>
+  <div class="text">
+    <iron-input
+      class="copyText"
+      type="text"
+      bind-value="[[text]]"
+      on-tap="_handleInputClick"
+      readonly=""
+    >
+      <input
+        id="input"
+        is="iron-input"
+        class$="[[_computeInputClass(hideInput)]]"
+        type="text"
+        bind-value="[[text]]"
+        on-click="_handleInputClick"
+        readonly=""
+      />
+    </iron-input>
+    <gr-button
+      id="button"
+      link=""
+      has-tooltip="[[hasTooltip]]"
+      class="copyToClipboard"
+      title="[[buttonTitle]]"
+      on-click="_copyToClipboard"
+      aria-label="Click to copy to clipboard"
+    >
+      <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
+    </gr-button>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
deleted file mode 100644
index 398f7f0..0000000
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
+++ /dev/null
@@ -1,105 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-copy-clipboard</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-copy-clipboard></gr-copy-clipboard>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-copy-clipboard.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-copy-clipboard tests', () => {
-  let element;
-  let sandbox;
-
-  setup(done => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    element.text = `git fetch http://gerrit@localhost:8080/a/test-project
-        refs/changes/05/5/1 && git checkout FETCH_HEAD`;
-    flushAsynchronousOperations();
-    flush(done);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('copy to clipboard', () => {
-    const clipboardSpy = sandbox.spy(element, '_copyToClipboard');
-    const copyBtn = element.shadowRoot
-        .querySelector('.copyToClipboard');
-    MockInteractions.tap(copyBtn);
-    assert.isTrue(clipboardSpy.called);
-  });
-
-  test('focusOnCopy', () => {
-    element.focusOnCopy();
-    assert.deepEqual(dom(element.root).activeElement,
-        element.shadowRoot
-            .querySelector('.copyToClipboard'));
-  });
-
-  test('_handleInputClick', () => {
-    // iron-input as parent should never be hidden as copy won't work
-    // on nested hidden elements
-    const ironInputElement = element.shadowRoot.querySelector('iron-input');
-    assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
-
-    const inputElement = element.shadowRoot.querySelector('input');
-    MockInteractions.tap(inputElement);
-    assert.equal(inputElement.selectionStart, 0);
-    assert.equal(inputElement.selectionEnd, element.text.length - 1);
-  });
-
-  test('hideInput', () => {
-    // iron-input as parent should never be hidden as copy won't work
-    // on nested hidden elements
-    const ironInputElement = element.shadowRoot.querySelector('iron-input');
-    assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
-
-    assert.notEqual(getComputedStyle(element.$.input).display, 'none');
-    element.hideInput = true;
-    flushAsynchronousOperations();
-    assert.equal(getComputedStyle(element.$.input).display, 'none');
-  });
-
-  test('stop events propagation', () => {
-    const divParent = document.createElement('div');
-    divParent.appendChild(element);
-    const clickStub = sinon.stub();
-    divParent.addEventListener('click', clickStub);
-    element.stopPropagation = true;
-    const copyBtn = element.shadowRoot.querySelector('.copyToClipboard');
-    MockInteractions.tap(copyBtn);
-    assert.isFalse(clickStub.called);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
new file mode 100644
index 0000000..58c00da
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-copy-clipboard.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-copy-clipboard');
+
+suite('gr-copy-clipboard tests', () => {
+  let element;
+
+  setup(done => {
+    element = basicFixture.instantiate();
+    element.text = `git fetch http://gerrit@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git checkout FETCH_HEAD`;
+    flushAsynchronousOperations();
+    flush(done);
+  });
+
+  test('copy to clipboard', () => {
+    const clipboardSpy = sinon.spy(element, '_copyToClipboard');
+    const copyBtn = element.shadowRoot
+        .querySelector('.copyToClipboard');
+    MockInteractions.tap(copyBtn);
+    assert.isTrue(clipboardSpy.called);
+  });
+
+  test('focusOnCopy', () => {
+    element.focusOnCopy();
+    assert.deepEqual(dom(element.root).activeElement,
+        element.shadowRoot
+            .querySelector('.copyToClipboard'));
+  });
+
+  test('_handleInputClick', () => {
+    // iron-input as parent should never be hidden as copy won't work
+    // on nested hidden elements
+    const ironInputElement = element.shadowRoot.querySelector('iron-input');
+    assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
+
+    const inputElement = element.shadowRoot.querySelector('input');
+    MockInteractions.tap(inputElement);
+    assert.equal(inputElement.selectionStart, 0);
+    assert.equal(inputElement.selectionEnd, element.text.length - 1);
+  });
+
+  test('hideInput', () => {
+    // iron-input as parent should never be hidden as copy won't work
+    // on nested hidden elements
+    const ironInputElement = element.shadowRoot.querySelector('iron-input');
+    assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
+
+    assert.notEqual(getComputedStyle(element.$.input).display, 'none');
+    element.hideInput = true;
+    flushAsynchronousOperations();
+    assert.equal(getComputedStyle(element.$.input).display, 'none');
+  });
+
+  test('stop events propagation', () => {
+    const divParent = document.createElement('div');
+    divParent.appendChild(element);
+    const clickStub = sinon.stub();
+    divParent.addEventListener('click', clickStub);
+    element.stopPropagation = true;
+    const copyBtn = element.shadowRoot.querySelector('.copyToClipboard');
+    MockInteractions.tap(copyBtn);
+    assert.isFalse(clickStub.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js
deleted file mode 100644
index 1c3a689..0000000
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-export const GrCountStringFormatter = {
-  /**
-   * Returns a count plus string that is pluralized when necessary.
-   *
-   * @param {number} count
-   * @param {string} noun
-   * @return {string}
-   */
-  computePluralString(count, noun) {
-    return this.computeString(count, noun) + (count > 1 ? 's' : '');
-  },
-
-  /**
-   * Returns a count plus string that is not pluralized.
-   *
-   * @param {number} count
-   * @param {string} noun
-   * @return {string}
-   */
-  computeString(count, noun) {
-    if (count === 0) {
-      return '';
-    }
-    return count + ' ' + noun;
-  },
-
-  /**
-   * Returns a count plus arbitrary text.
-   *
-   * @param {number} count
-   * @param {string} text
-   * @return {string}
-   */
-  computeShortString(count, text) {
-    if (count === 0) {
-      return '';
-    }
-    return count + text;
-  },
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts
new file mode 100644
index 0000000..bbbce16
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export const GrCountStringFormatter = {
+  /**
+   * Returns a count plus string that is pluralized when necessary.
+   */
+  computePluralString(count: number, noun: string): string {
+    return this.computeString(count, noun) + (count > 1 ? 's' : '');
+  },
+
+  /**
+   * Returns a count plus string that is not pluralized.
+   */
+  computeString(count: number, noun: string): string {
+    if (count === 0) {
+      return '';
+    }
+    return `${count} ${noun}`;
+  },
+
+  /**
+   * Returns a count plus arbitrary text.
+   */
+  computeShortString(count: number, text: string): string {
+    if (count === 0) {
+      return '';
+    }
+    return `${count}${text}`;
+  },
+};
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
deleted file mode 100644
index 63435d2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
+++ /dev/null
@@ -1,58 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-count-string-formatter</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../../test/common-test-setup.js';
-import {GrCountStringFormatter} from './gr-count-string-formatter.js';
-
-suite('gr-count-string-formatter tests', () => {
-  test('computeString', () => {
-    const noun = 'unresolved';
-    assert.equal(GrCountStringFormatter.computeString(0, noun), '');
-    assert.equal(GrCountStringFormatter.computeString(1, noun),
-        '1 unresolved');
-    assert.equal(GrCountStringFormatter.computeString(2, noun),
-        '2 unresolved');
-  });
-
-  test('computeShortString', () => {
-    const noun = 'c';
-    assert.equal(GrCountStringFormatter.computeShortString(0, noun), '');
-    assert.equal(GrCountStringFormatter.computeShortString(1, noun), '1c');
-    assert.equal(GrCountStringFormatter.computeShortString(2, noun), '2c');
-  });
-
-  test('computePluralString', () => {
-    const noun = 'comment';
-    assert.equal(GrCountStringFormatter.computePluralString(0, noun), '');
-    assert.equal(GrCountStringFormatter.computePluralString(1, noun),
-        '1 comment');
-    assert.equal(GrCountStringFormatter.computePluralString(2, noun),
-        '2 comments');
-  });
-});
-</script>
-
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js
new file mode 100644
index 0000000..36637ec
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.js
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {GrCountStringFormatter} from './gr-count-string-formatter.js';
+
+suite('gr-count-string-formatter tests', () => {
+  test('computeString', () => {
+    const noun = 'unresolved';
+    assert.equal(GrCountStringFormatter.computeString(0, noun), '');
+    assert.equal(GrCountStringFormatter.computeString(1, noun),
+        '1 unresolved');
+    assert.equal(GrCountStringFormatter.computeString(2, noun),
+        '2 unresolved');
+  });
+
+  test('computeShortString', () => {
+    const noun = 'c';
+    assert.equal(GrCountStringFormatter.computeShortString(0, noun), '');
+    assert.equal(GrCountStringFormatter.computeShortString(1, noun), '1c');
+    assert.equal(GrCountStringFormatter.computeShortString(2, noun), '2c');
+  });
+
+  test('computePluralString', () => {
+    const noun = 'comment';
+    assert.equal(GrCountStringFormatter.computePluralString(0, noun), '');
+    assert.equal(GrCountStringFormatter.computePluralString(1, noun),
+        '1 comment');
+    assert.equal(GrCountStringFormatter.computePluralString(2, noun),
+        '2 comments');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index 222109e..de9dcc2 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -14,18 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-cursor-manager_html.js';
+import {ScrollMode} from '../../../constants/constants.js';
 
-const ScrollBehavior = {
-  NEVER: 'never',
-  KEEP_VISIBLE: 'keep-visible',
-};
+// Time in which pressing n key again after the toast navigates to next file
+const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrCursorManager extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -82,9 +80,9 @@
        *
        * @type {string|undefined}
        */
-      scrollBehavior: {
+      scrollMode: {
         type: String,
-        value: ScrollBehavior.NEVER,
+        value: ScrollMode.NEVER,
       },
 
       /**
@@ -124,11 +122,15 @@
    *    sometimes different, used by the diff cursor.
    * @param {boolean=} opt_clipToTop When none of the next indices match, move
    *     back to first instead of to last.
+   * @param {boolean=} opt_navigateToNextFile Navigate to next unreviewed file
+   *     if user presses next on the last diff chunk
    * @private
    */
 
-  next(opt_condition, opt_getTargetHeight, opt_clipToTop) {
-    this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop);
+  next(opt_condition, opt_getTargetHeight, opt_clipToTop,
+      opt_navigateToNextFile) {
+    this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop,
+        opt_navigateToNextFile);
   }
 
   previous(opt_condition) {
@@ -214,8 +216,8 @@
   setCursor(element, opt_noScroll) {
     let behavior;
     if (opt_noScroll) {
-      behavior = this.scrollBehavior;
-      this.scrollBehavior = ScrollBehavior.NEVER;
+      behavior = this.scrollMode;
+      this.scrollMode = ScrollMode.NEVER;
     }
 
     this.unsetCursor();
@@ -223,7 +225,7 @@
     this._updateIndex();
     this._decorateTarget();
 
-    if (opt_noScroll) { this.scrollBehavior = behavior; }
+    if (opt_noScroll) { this.scrollMode = behavior; }
   }
 
   unsetCursor() {
@@ -270,9 +272,12 @@
    *    sometimes different, used by the diff cursor.
    * @param {boolean=} opt_clipToTop When none of the next indices match, move
    *     back to first instead of to last.
+   * @param {boolean=} opt_navigateToNextFile Navigate to next unreviewed file
+   *     if user presses next on the last diff chunk
    * @private
    */
-  _moveCursor(delta, opt_condition, opt_getTargetHeight, opt_clipToTop) {
+  _moveCursor(delta, opt_condition, opt_getTargetHeight, opt_clipToTop,
+      opt_navigateToNextFile) {
     if (!this.stops.length) {
       this.unsetCursor();
       return;
@@ -287,6 +292,35 @@
       newTarget = this.stops[newIndex];
     }
 
+    /*
+     * If user presses n on the last diff chunk, show a toast informing user
+     * that pressing n again will navigate them to next unreviewed file.
+     * If click happens within the time limit, then navigate to next file
+     */
+    if (opt_navigateToNextFile && this.index === newIndex) {
+      if (newIndex === this.stops.length - 1) {
+        if (this._lastDisplayedNavigateToNextFileToast && (Date.now() -
+          this._lastDisplayedNavigateToNextFileToast <=
+            NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS)) {
+          // reset for next file
+          this._lastDisplayedNavigateToNextFileToast = null;
+          this.dispatchEvent(new CustomEvent(
+              'navigate-to-next-unreviewed-file', {
+                composed: true, bubbles: true,
+              }));
+          return;
+        }
+        this._lastDisplayedNavigateToNextFileToast = Date.now();
+        this.dispatchEvent(new CustomEvent('show-alert', {
+          detail: {
+            message: 'Press n again to navigate to next unreviewed file',
+          },
+          composed: true, bubbles: true,
+        }));
+        return;
+      }
+    }
+
     this.index = newIndex;
     this.target = newTarget;
 
@@ -391,7 +425,7 @@
    */
   _targetIsVisible(top) {
     const dims = this._getWindowDims();
-    return this.scrollBehavior === ScrollBehavior.KEEP_VISIBLE &&
+    return this.scrollMode === ScrollMode.KEEP_VISIBLE &&
         top > (dims.pageYOffset + this.scrollTopMargin) &&
         top < dims.pageYOffset + dims.innerHeight;
   }
@@ -403,7 +437,7 @@
   }
 
   _scrollToTarget() {
-    if (!this.target || this.scrollBehavior === ScrollBehavior.NEVER) {
+    if (!this.target || this.scrollMode === ScrollMode.NEVER) {
       return;
     }
 
@@ -415,8 +449,8 @@
 
     if (this._targetIsVisible(top)) {
       // Don't scroll if either the bottom is visible or if the position that
-      // would get scrolled to is higher up than the current position. this
-      // woulld cause less of the target content to be displayed than is
+      // would get scrolled to is higher up than the current position. This
+      // would cause less of the target content to be displayed than is
       // already.
       if (bottomIsVisible || scrollToValue < dims.scrollY) {
         return;
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.js
deleted file mode 100644
index 3ed33d1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.ts
new file mode 100644
index 0000000..1489006
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_html.ts
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
deleted file mode 100644
index 98a7d24..0000000
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
+++ /dev/null
@@ -1,303 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-cursor-manager</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
-    <ul>
-      <li>A</li>
-      <li>B</li>
-      <li>C</li>
-      <li>D</li>
-    </ul>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-cursor-manager.js';
-suite('gr-cursor-manager tests', () => {
-  let sandbox;
-  let element;
-  let list;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    const fixtureElements = fixture('basic');
-    element = fixtureElements[0];
-    list = fixtureElements[1];
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('core cursor functionality', () => {
-    // The element is initialized into the proper state.
-    assert.isArray(element.stops);
-    assert.equal(element.stops.length, 0);
-    assert.equal(element.index, -1);
-    assert.isNotOk(element.target);
-
-    // Initialize the cursor with its stops.
-    element.stops = list.querySelectorAll('li');
-
-    // It should have the stops but it should not be targeting any of them.
-    assert.isNotNull(element.stops);
-    assert.equal(element.stops.length, 4);
-    assert.equal(element.index, -1);
-    assert.isNotOk(element.target);
-
-    // Select the third stop.
-    element.setCursor(list.children[2]);
-
-    // It should update its internal state and update the element's class.
-    assert.equal(element.index, 2);
-    assert.equal(element.target, list.children[2]);
-    assert.isTrue(list.children[2].classList.contains('targeted'));
-    assert.isFalse(element.isAtStart());
-    assert.isFalse(element.isAtEnd());
-
-    // Progress the cursor.
-    element.next();
-
-    // Confirm that the next stop is selected and that the previous stop is
-    // unselected.
-    assert.equal(element.index, 3);
-    assert.equal(element.target, list.children[3]);
-    assert.isTrue(element.isAtEnd());
-    assert.isFalse(list.children[2].classList.contains('targeted'));
-    assert.isTrue(list.children[3].classList.contains('targeted'));
-
-    // Progress the cursor.
-    element.next();
-
-    // We should still be at the end.
-    assert.equal(element.index, 3);
-    assert.equal(element.target, list.children[3]);
-    assert.isTrue(element.isAtEnd());
-
-    // Wind the cursor all the way back to the first stop.
-    element.previous();
-    element.previous();
-    element.previous();
-
-    // The element state should reflect the end of the list.
-    assert.equal(element.index, 0);
-    assert.equal(element.target, list.children[0]);
-    assert.isTrue(element.isAtStart());
-    assert.isTrue(list.children[0].classList.contains('targeted'));
-
-    const newLi = document.createElement('li');
-    newLi.textContent = 'Z';
-    list.insertBefore(newLi, list.children[0]);
-    element.stops = list.querySelectorAll('li');
-
-    assert.equal(element.index, 1);
-
-    // De-select all targets.
-    element.unsetCursor();
-
-    // There should now be no cursor target.
-    assert.isFalse(list.children[1].classList.contains('targeted'));
-    assert.isNotOk(element.target);
-    assert.equal(element.index, -1);
-  });
-
-  test('next() goes to first element when no cursor is set', () => {
-    element.stops = list.querySelectorAll('li');
-    element.next();
-
-    assert.equal(element.index, 0);
-    assert.equal(element.target, list.children[0]);
-    assert.isTrue(list.children[0].classList.contains('targeted'));
-    assert.isTrue(element.isAtStart());
-    assert.isFalse(element.isAtEnd());
-  });
-
-  test('next() goes to first element when no cursor is set', () => {
-    element.stops = list.querySelectorAll('li');
-    element.previous();
-
-    const lastIndex = list.children.length - 1;
-    assert.equal(element.index, lastIndex);
-    assert.equal(element.target, list.children[lastIndex]);
-    assert.isTrue(list.children[lastIndex].classList.contains('targeted'));
-    assert.isFalse(element.isAtStart());
-    assert.isTrue(element.isAtEnd());
-  });
-
-  test('_moveCursor', () => {
-    // Initialize the cursor with its stops.
-    element.stops = list.querySelectorAll('li');
-    // Select the first stop.
-    element.setCursor(list.children[0]);
-    const getTargetHeight = sinon.stub();
-
-    // Move the cursor without an optional get target height function.
-    element._moveCursor(1);
-    assert.isFalse(getTargetHeight.called);
-
-    // Move the cursor with an optional get target height function.
-    element._moveCursor(1, null, getTargetHeight);
-    assert.isTrue(getTargetHeight.called);
-  });
-
-  test('_moveCursor from for invalid index does not check height', () => {
-    element.stops = [];
-    const getTargetHeight = sinon.stub();
-    element._moveCursor(1, () => false, getTargetHeight);
-    assert.isFalse(getTargetHeight.called);
-  });
-
-  test('opt_noScroll', () => {
-    sandbox.stub(element, '_targetIsVisible', () => false);
-    const scrollStub = sandbox.stub(window, 'scrollTo');
-    element.stops = list.querySelectorAll('li');
-    element.scrollBehavior = 'keep-visible';
-
-    element.setCursorAtIndex(1, true);
-    assert.isFalse(scrollStub.called);
-
-    element.setCursorAtIndex(2);
-    assert.isTrue(scrollStub.called);
-  });
-
-  test('_getNextindex', () => {
-    const isLetterB = function(row) {
-      return row.textContent === 'B';
-    };
-    element.stops = list.querySelectorAll('li');
-    // Start cursor at the first stop.
-    element.setCursor(list.children[0]);
-
-    // Move forward to meet the next condition.
-    assert.equal(element._getNextindex(1, isLetterB), 1);
-    element.index = 1;
-
-    // Nothing else meets the condition, should be at last stop.
-    assert.equal(element._getNextindex(1, isLetterB), 3);
-    element.index = 3;
-
-    // Should stay at last stop if try to proceed.
-    assert.equal(element._getNextindex(1, isLetterB), 3);
-
-    // Go back to the previous condition met. Should be back at.
-    // stop 1.
-    assert.equal(element._getNextindex(-1, isLetterB), 1);
-    element.index = 1;
-
-    // Go back. No more meet the condition. Should be at stop 0.
-    assert.equal(element._getNextindex(-1, isLetterB), 0);
-  });
-
-  test('focusOnMove prop', () => {
-    const listEls = list.querySelectorAll('li');
-    for (let i = 0; i < listEls.length; i++) {
-      sandbox.spy(listEls[i], 'focus');
-    }
-    element.stops = listEls;
-    element.setCursor(list.children[0]);
-
-    element.focusOnMove = false;
-    element.next();
-    assert.isFalse(element.target.focus.called);
-
-    element.focusOnMove = true;
-    element.next();
-    assert.isTrue(element.target.focus.called);
-  });
-
-  suite('_scrollToTarget', () => {
-    let scrollStub;
-    setup(() => {
-      element.stops = list.querySelectorAll('li');
-      element.scrollBehavior = 'keep-visible';
-
-      // There is a target which has a targetNext
-      element.setCursor(list.children[0]);
-      element._moveCursor(1);
-      scrollStub = sandbox.stub(window, 'scrollTo');
-      window.innerHeight = 60;
-    });
-
-    test('Called when top and bottom not visible', () => {
-      sandbox.stub(element, '_targetIsVisible').returns(false);
-      element._scrollToTarget();
-      assert.isTrue(scrollStub.called);
-    });
-
-    test('Not called when top and bottom visible', () => {
-      sandbox.stub(element, '_targetIsVisible').returns(true);
-      element._scrollToTarget();
-      assert.isFalse(scrollStub.called);
-    });
-
-    test('Called when top is visible, bottom is not, scroll is lower', () => {
-      const visibleStub = sandbox.stub(element, '_targetIsVisible',
-          () => visibleStub.callCount === 2);
-      sandbox.stub(element, '_getWindowDims').returns({
-        scrollX: 123,
-        scrollY: 15,
-        innerHeight: 1000,
-        pageYOffset: 0,
-      });
-      sandbox.stub(element, '_calculateScrollToValue').returns(20);
-      element._scrollToTarget();
-      assert.isTrue(scrollStub.called);
-      assert.isTrue(scrollStub.calledWithExactly(123, 20));
-      assert.equal(visibleStub.callCount, 2);
-    });
-
-    test('Called when top is visible, bottom not, scroll is higher', () => {
-      const visibleStub = sandbox.stub(element, '_targetIsVisible',
-          () => visibleStub.callCount === 2);
-      sandbox.stub(element, '_getWindowDims').returns({
-        scrollX: 123,
-        scrollY: 25,
-        innerHeight: 1000,
-        pageYOffset: 0,
-      });
-      sandbox.stub(element, '_calculateScrollToValue').returns(20);
-      element._scrollToTarget();
-      assert.isFalse(scrollStub.called);
-      assert.equal(visibleStub.callCount, 2);
-    });
-
-    test('_calculateScrollToValue', () => {
-      sandbox.stub(element, '_getWindowDims').returns({
-        scrollX: 123,
-        scrollY: 25,
-        innerHeight: 300,
-        pageYOffset: 0,
-      });
-      assert.equal(element._calculateScrollToValue(1000, {offsetHeight: 10}),
-          905);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
new file mode 100644
index 0000000..bc07d84
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -0,0 +1,285 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-cursor-manager.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicTestFixutre = fixtureFromTemplate(html`
+    <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
+    <ul>
+      <li>A</li>
+      <li>B</li>
+      <li>C</li>
+      <li>D</li>
+    </ul>
+`);
+
+suite('gr-cursor-manager tests', () => {
+  let element;
+  let list;
+
+  setup(() => {
+    const fixtureElements = basicTestFixutre.instantiate();
+    element = fixtureElements[0];
+    list = fixtureElements[1];
+  });
+
+  test('core cursor functionality', () => {
+    // The element is initialized into the proper state.
+    assert.isArray(element.stops);
+    assert.equal(element.stops.length, 0);
+    assert.equal(element.index, -1);
+    assert.isNotOk(element.target);
+
+    // Initialize the cursor with its stops.
+    element.stops = list.querySelectorAll('li');
+
+    // It should have the stops but it should not be targeting any of them.
+    assert.isNotNull(element.stops);
+    assert.equal(element.stops.length, 4);
+    assert.equal(element.index, -1);
+    assert.isNotOk(element.target);
+
+    // Select the third stop.
+    element.setCursor(list.children[2]);
+
+    // It should update its internal state and update the element's class.
+    assert.equal(element.index, 2);
+    assert.equal(element.target, list.children[2]);
+    assert.isTrue(list.children[2].classList.contains('targeted'));
+    assert.isFalse(element.isAtStart());
+    assert.isFalse(element.isAtEnd());
+
+    // Progress the cursor.
+    element.next();
+
+    // Confirm that the next stop is selected and that the previous stop is
+    // unselected.
+    assert.equal(element.index, 3);
+    assert.equal(element.target, list.children[3]);
+    assert.isTrue(element.isAtEnd());
+    assert.isFalse(list.children[2].classList.contains('targeted'));
+    assert.isTrue(list.children[3].classList.contains('targeted'));
+
+    // Progress the cursor.
+    element.next();
+
+    // We should still be at the end.
+    assert.equal(element.index, 3);
+    assert.equal(element.target, list.children[3]);
+    assert.isTrue(element.isAtEnd());
+
+    // Wind the cursor all the way back to the first stop.
+    element.previous();
+    element.previous();
+    element.previous();
+
+    // The element state should reflect the end of the list.
+    assert.equal(element.index, 0);
+    assert.equal(element.target, list.children[0]);
+    assert.isTrue(element.isAtStart());
+    assert.isTrue(list.children[0].classList.contains('targeted'));
+
+    const newLi = document.createElement('li');
+    newLi.textContent = 'Z';
+    list.insertBefore(newLi, list.children[0]);
+    element.stops = list.querySelectorAll('li');
+
+    assert.equal(element.index, 1);
+
+    // De-select all targets.
+    element.unsetCursor();
+
+    // There should now be no cursor target.
+    assert.isFalse(list.children[1].classList.contains('targeted'));
+    assert.isNotOk(element.target);
+    assert.equal(element.index, -1);
+  });
+
+  test('next() goes to first element when no cursor is set', () => {
+    element.stops = list.querySelectorAll('li');
+    element.next();
+
+    assert.equal(element.index, 0);
+    assert.equal(element.target, list.children[0]);
+    assert.isTrue(list.children[0].classList.contains('targeted'));
+    assert.isTrue(element.isAtStart());
+    assert.isFalse(element.isAtEnd());
+  });
+
+  test('next() goes to first element when no cursor is set', () => {
+    element.stops = list.querySelectorAll('li');
+    element.previous();
+
+    const lastIndex = list.children.length - 1;
+    assert.equal(element.index, lastIndex);
+    assert.equal(element.target, list.children[lastIndex]);
+    assert.isTrue(list.children[lastIndex].classList.contains('targeted'));
+    assert.isFalse(element.isAtStart());
+    assert.isTrue(element.isAtEnd());
+  });
+
+  test('_moveCursor', () => {
+    // Initialize the cursor with its stops.
+    element.stops = list.querySelectorAll('li');
+    // Select the first stop.
+    element.setCursor(list.children[0]);
+    const getTargetHeight = sinon.stub();
+
+    // Move the cursor without an optional get target height function.
+    element._moveCursor(1);
+    assert.isFalse(getTargetHeight.called);
+
+    // Move the cursor with an optional get target height function.
+    element._moveCursor(1, null, getTargetHeight);
+    assert.isTrue(getTargetHeight.called);
+  });
+
+  test('_moveCursor from for invalid index does not check height', () => {
+    element.stops = [];
+    const getTargetHeight = sinon.stub();
+    element._moveCursor(1, () => false, getTargetHeight);
+    assert.isFalse(getTargetHeight.called);
+  });
+
+  test('opt_noScroll', () => {
+    sinon.stub(element, '_targetIsVisible').callsFake(() => false);
+    const scrollStub = sinon.stub(window, 'scrollTo');
+    element.stops = list.querySelectorAll('li');
+    element.scrollMode = 'keep-visible';
+
+    element.setCursorAtIndex(1, true);
+    assert.isFalse(scrollStub.called);
+
+    element.setCursorAtIndex(2);
+    assert.isTrue(scrollStub.called);
+  });
+
+  test('_getNextindex', () => {
+    const isLetterB = function(row) {
+      return row.textContent === 'B';
+    };
+    element.stops = list.querySelectorAll('li');
+    // Start cursor at the first stop.
+    element.setCursor(list.children[0]);
+
+    // Move forward to meet the next condition.
+    assert.equal(element._getNextindex(1, isLetterB), 1);
+    element.index = 1;
+
+    // Nothing else meets the condition, should be at last stop.
+    assert.equal(element._getNextindex(1, isLetterB), 3);
+    element.index = 3;
+
+    // Should stay at last stop if try to proceed.
+    assert.equal(element._getNextindex(1, isLetterB), 3);
+
+    // Go back to the previous condition met. Should be back at.
+    // stop 1.
+    assert.equal(element._getNextindex(-1, isLetterB), 1);
+    element.index = 1;
+
+    // Go back. No more meet the condition. Should be at stop 0.
+    assert.equal(element._getNextindex(-1, isLetterB), 0);
+  });
+
+  test('focusOnMove prop', () => {
+    const listEls = list.querySelectorAll('li');
+    for (let i = 0; i < listEls.length; i++) {
+      sinon.spy(listEls[i], 'focus');
+    }
+    element.stops = listEls;
+    element.setCursor(list.children[0]);
+
+    element.focusOnMove = false;
+    element.next();
+    assert.isFalse(element.target.focus.called);
+
+    element.focusOnMove = true;
+    element.next();
+    assert.isTrue(element.target.focus.called);
+  });
+
+  suite('_scrollToTarget', () => {
+    let scrollStub;
+    setup(() => {
+      element.stops = list.querySelectorAll('li');
+      element.scrollMode = 'keep-visible';
+
+      // There is a target which has a targetNext
+      element.setCursor(list.children[0]);
+      element._moveCursor(1);
+      scrollStub = sinon.stub(window, 'scrollTo');
+      window.innerHeight = 60;
+    });
+
+    test('Called when top and bottom not visible', () => {
+      sinon.stub(element, '_targetIsVisible').returns(false);
+      element._scrollToTarget();
+      assert.isTrue(scrollStub.called);
+    });
+
+    test('Not called when top and bottom visible', () => {
+      sinon.stub(element, '_targetIsVisible').returns(true);
+      element._scrollToTarget();
+      assert.isFalse(scrollStub.called);
+    });
+
+    test('Called when top is visible, bottom is not, scroll is lower', () => {
+      const visibleStub = sinon.stub(element, '_targetIsVisible').callsFake(
+          () => visibleStub.callCount === 2);
+      sinon.stub(element, '_getWindowDims').returns({
+        scrollX: 123,
+        scrollY: 15,
+        innerHeight: 1000,
+        pageYOffset: 0,
+      });
+      sinon.stub(element, '_calculateScrollToValue').returns(20);
+      element._scrollToTarget();
+      assert.isTrue(scrollStub.called);
+      assert.isTrue(scrollStub.calledWithExactly(123, 20));
+      assert.equal(visibleStub.callCount, 2);
+    });
+
+    test('Called when top is visible, bottom not, scroll is higher', () => {
+      const visibleStub = sinon.stub(element, '_targetIsVisible').callsFake(
+          () => visibleStub.callCount === 2);
+      sinon.stub(element, '_getWindowDims').returns({
+        scrollX: 123,
+        scrollY: 25,
+        innerHeight: 1000,
+        pageYOffset: 0,
+      });
+      sinon.stub(element, '_calculateScrollToValue').returns(20);
+      element._scrollToTarget();
+      assert.isFalse(scrollStub.called);
+      assert.equal(visibleStub.callCount, 2);
+    });
+
+    test('_calculateScrollToValue', () => {
+      sinon.stub(element, '_getWindowDims').returns({
+        scrollX: 123,
+        scrollY: 25,
+        innerHeight: 300,
+        pageYOffset: 0,
+      });
+      assert.equal(element._calculateScrollToValue(1000, {offsetHeight: 10}),
+          905);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
index 6f19587..682b7f7 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
@@ -14,23 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../../styles/shared-styles.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-date-formatter_html.js';
-import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
-import {util} from '../../../scripts/util.js';
-import moment from 'moment/src/moment.js';
-
-const Duration = {
-  HOUR: 1000 * 60 * 60,
-  DAY: 1000 * 60 * 60 * 24,
-};
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin.js';
+import {parseDate, fromNow, isValidDate, isWithinDay, isWithinHalfYear, formatDate, utcOffsetString} from '../../../utils/date-util.js';
 
 const TimeFormats = {
   TIME_12: 'h:mm A', // 2:14 PM
@@ -63,13 +54,11 @@
 };
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrDateFormatter extends mixinBehaviors( [
-  TooltipBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrDateFormatter extends TooltipMixin(
+    GestureEventListeners(
+        LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-date-formatter'; }
@@ -108,6 +97,10 @@
     };
   }
 
+  constructor() {
+    super();
+  }
+
   /** @override */
   attached() {
     super.attached();
@@ -115,7 +108,7 @@
   }
 
   _getUtcOffsetString() {
-    return ' UTC' + moment().format('Z');
+    return utcOffsetString();
   }
 
   _loadPreferences() {
@@ -192,50 +185,28 @@
     return this.$.restAPI.getPreferences();
   }
 
-  /**
-   * Return true if date is within 24 hours and on the same day.
-   */
-  _isWithinDay(now, date) {
-    const diff = -date.diff(now);
-    return diff < Duration.DAY && date.day() === now.getDay();
-  }
-
-  /**
-   * Returns true if date is from one to six months.
-   */
-  _isWithinHalfYear(now, date) {
-    const diff = -date.diff(now);
-    return (date.day() !== now.getDay() || diff >= Duration.DAY) &&
-      diff < 180 * Duration.DAY;
-  }
-
   _computeDateStr(
       dateStr, timeFormat, dateFormat, relative, showDateAndTime
   ) {
     if (!dateStr || !timeFormat || !dateFormat) { return ''; }
-    const date = moment(util.parseDate(dateStr));
-    if (!date.isValid()) { return ''; }
+    const date = parseDate(dateStr);
+    if (!isValidDate(date)) { return ''; }
     if (relative) {
-      const dateFromNow = date.fromNow();
-      if (dateFromNow === 'a few seconds ago') {
-        return 'just now';
-      } else {
-        return dateFromNow;
-      }
+      return fromNow(date);
     }
     const now = new Date();
     let format = dateFormat.full;
-    if (this._isWithinDay(now, date)) {
+    if (isWithinDay(now, date)) {
       format = timeFormat;
     } else {
-      if (this._isWithinHalfYear(now, date)) {
+      if (isWithinHalfYear(now, date)) {
         format = dateFormat.short;
       }
       if (this.showDateAndTime) {
         format = `${format} ${timeFormat}`;
       }
     }
-    return date.format(format);
+    return formatDate(date, format);
   }
 
   _timeToSecondsFormat(timeFormat) {
@@ -250,16 +221,16 @@
       dateStr,
       timeFormat,
       dateFormat,
-    ].some(arg => arg === undefined)) {
+    ].includes(undefined)) {
       return undefined;
     }
 
     if (!dateStr) { return ''; }
-    const date = moment(util.parseDate(dateStr));
-    if (!date.isValid()) { return ''; }
+    const date = parseDate(dateStr);
+    if (!isValidDate(date)) { return ''; }
     let format = dateFormat.full + ', ';
     format += this._timeToSecondsFormat(timeFormat);
-    return date.format(format) + this._getUtcOffsetString();
+    return formatDate(date, format) + this._getUtcOffsetString();
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.js
deleted file mode 100644
index 2571065..0000000
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.js
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      color: inherit;
-      display: inline;
-    }
-  </style>
-  <span>
-    [[_computeDateStr(dateStr, _timeFormat, _dateFormat, _relative,
-    showDateAndTime)]]
-  </span>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
new file mode 100644
index 0000000..a5dd6d0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      color: inherit;
+      display: inline;
+    }
+  </style>
+  <span>
+    [[_computeDateStr(dateStr, _timeFormat, _dateFormat, _relative,
+    showDateAndTime)]]
+  </span>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
deleted file mode 100644
index 7169ef27..0000000
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
+++ /dev/null
@@ -1,448 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-date-formatter</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-date-formatter date-str="2015-09-24 23:30:17.033000000"></gr-date-formatter>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-date-formatter.js';
-import {util} from '../../../scripts/util.js';
-suite('gr-date-formatter tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  /**
-   * Parse server-formatter date and normalize into current timezone.
-   */
-  function normalizedDate(dateStr) {
-    const d = util.parseDate(dateStr);
-    d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
-    return d;
-  }
-
-  function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
-      expectedTooltip, done) {
-    // Normalize and convert the date to mimic server response.
-    dateStr = normalizedDate(dateStr)
-        .toJSON()
-        .replace('T', ' ')
-        .slice(0, -1);
-    sandbox.useFakeTimers(normalizedDate(nowStr).getTime());
-    element.dateStr = dateStr;
-    flush(() => {
-      const span = element.shadowRoot
-          .querySelector('span');
-      assert.equal(span.textContent.trim(), expected);
-      assert.equal(element.title, expectedTooltip);
-      element.showDateAndTime = true;
-      flushAsynchronousOperations();
-      assert.equal(span.textContent.trim(), expectedWithDateAndTime);
-      done();
-    });
-  }
-
-  function stubRestAPI(preferences) {
-    const loggedInPromise = Promise.resolve(preferences !== null);
-    const preferencesPromise = Promise.resolve(preferences);
-    stub('gr-rest-api-interface', {
-      getLoggedIn: sinon.stub().returns(loggedInPromise),
-      getPreferences: sinon.stub().returns(preferencesPromise),
-    });
-    return Promise.all([loggedInPromise, preferencesPromise]);
-  }
-
-  suite('STD + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'STD',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = fixture('basic');
-      sandbox.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
-
-    test('invalid dates are quietly rejected', () => {
-      assert.notOk((new Date('foo')).valueOf());
-      assert.equal(element._computeDateStr('foo', 'h:mm A'), '');
-    });
-
-    test('Within 24 hours on same day', done => {
-      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', done);
-    });
-
-    test('Within 24 hours on different days', done => {
-      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', done);
-    });
-
-    test('More than 24 hours but less than six months', done => {
-      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', done);
-    });
-
-    test('More than six months', done => {
-      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', done);
-    });
-  });
-
-  suite('US + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'US',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = fixture('basic');
-      sandbox.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
-
-    test('Within 24 hours on same day', done => {
-      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', done);
-    });
-
-    test('Within 24 hours on different days', done => {
-      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', done);
-    });
-
-    test('More than 24 hours but less than six months', done => {
-      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', done);
-    });
-  });
-
-  suite('ISO + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'ISO',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = fixture('basic');
-      sandbox.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
-
-    test('Within 24 hours on same day', done => {
-      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', done);
-    });
-
-    test('Within 24 hours on different days', done => {
-      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', done);
-    });
-
-    test('More than 24 hours but less than six months', done => {
-      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', done);
-    });
-  });
-
-  suite('EURO + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'EURO',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = fixture('basic');
-      sandbox.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
-
-    test('Within 24 hours on same day', done => {
-      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', done);
-    });
-
-    test('Within 24 hours on different days', done => {
-      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', done);
-    });
-
-    test('More than 24 hours but less than six months', done => {
-      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', done);
-    });
-  });
-
-  suite('UK + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'UK',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = fixture('basic');
-      sandbox.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
-
-    test('Within 24 hours on same day', done => {
-      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', done);
-    });
-
-    test('Within 24 hours on different days', done => {
-      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', done);
-    });
-
-    test('More than 24 hours but less than six months', done => {
-      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', done);
-    });
-  });
-
-  suite('STD + 12 hours time format preference', () => {
-    setup(() =>
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'STD'}
-      ).then(() => {
-        element = fixture('basic');
-        sandbox.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
-
-    test('Within 24 hours on same day', done => {
-      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', done);
-    });
-  });
-
-  suite('US + 12 hours time format preference', () => {
-    setup(() =>
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'US'}
-      ).then(() => {
-        element = fixture('basic');
-        sandbox.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
-
-    test('Within 24 hours on same day', done => {
-      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', done);
-    });
-  });
-
-  suite('ISO + 12 hours time format preference', () => {
-    setup(() =>
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'ISO'}
-      ).then(() => {
-        element = fixture('basic');
-        sandbox.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
-
-    test('Within 24 hours on same day', done => {
-      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', done);
-    });
-  });
-
-  suite('EURO + 12 hours time format preference', () => {
-    setup(() =>
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'EURO'}
-      ).then(() => {
-        element = fixture('basic');
-        sandbox.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
-
-    test('Within 24 hours on same day', done => {
-      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', done);
-    });
-  });
-
-  suite('UK + 12 hours time format preference', () => {
-    setup(() =>
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'UK'}
-      ).then(() => {
-        element = fixture('basic');
-        sandbox.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
-
-    test('Within 24 hours on same day', done => {
-      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', done);
-    });
-  });
-
-  suite('relative date preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_12',
-      date_format: 'STD',
-      relative_date_in_change_table: true,
-    }).then(() => {
-      element = fixture('basic');
-      sandbox.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
-
-    test('Within 24 hours on same day', done => {
-      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', done);
-    });
-
-    test('More than six months', done => {
-      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', done);
-    });
-  });
-
-  suite('logged in', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_12',
-      date_format: 'US',
-      relative_date_in_change_table: true,
-    }).then(() => {
-      element = fixture('basic');
-      return 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(() => stubRestAPI(null).then(() => {
-      element = fixture('basic');
-      return 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);
-    });
-  });
-});
-</script>
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
new file mode 100644
index 0000000..804c3b7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
@@ -0,0 +1,432 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-date-formatter.js';
+import {parseDate} from '../../../utils/date-util.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-date-formatter date-str="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;
+  }
+
+  function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
+      expectedTooltip, done) {
+    // 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;
+    flush(() => {
+      const span = element.shadowRoot
+          .querySelector('span');
+      assert.equal(span.textContent.trim(), expected);
+      assert.equal(element.title, expectedTooltip);
+      element.showDateAndTime = true;
+      flushAsynchronousOperations();
+      assert.equal(span.textContent.trim(), expectedWithDateAndTime);
+      done();
+    });
+  }
+
+  function stubRestAPI(preferences) {
+    const loggedInPromise = Promise.resolve(preferences !== null);
+    const preferencesPromise = Promise.resolve(preferences);
+    stub('gr-rest-api-interface', {
+      getLoggedIn: sinon.stub().returns(loggedInPromise),
+      getPreferences: sinon.stub().returns(preferencesPromise),
+    });
+    return Promise.all([loggedInPromise, preferencesPromise]);
+  }
+
+  suite('STD + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'STD',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = basicFixture.instantiate();
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('invalid dates are quietly rejected', () => {
+      assert.notOk((new Date('foo')).valueOf());
+      assert.equal(element._computeDateStr('foo', 'h:mm A'), '');
+    });
+
+    test('Within 24 hours on same day', done => {
+      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', done);
+    });
+
+    test('Within 24 hours on different days', done => {
+      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', done);
+    });
+
+    test('More than 24 hours but less than six months', done => {
+      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', done);
+    });
+
+    test('More than six months', done => {
+      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', done);
+    });
+  });
+
+  suite('US + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'US',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = basicFixture.instantiate();
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', done => {
+      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', done);
+    });
+
+    test('Within 24 hours on different days', done => {
+      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', done);
+    });
+
+    test('More than 24 hours but less than six months', done => {
+      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', done);
+    });
+  });
+
+  suite('ISO + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'ISO',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = basicFixture.instantiate();
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', done => {
+      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', done);
+    });
+
+    test('Within 24 hours on different days', done => {
+      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', done);
+    });
+
+    test('More than 24 hours but less than six months', done => {
+      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', done);
+    });
+  });
+
+  suite('EURO + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'EURO',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = basicFixture.instantiate();
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', done => {
+      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', done);
+    });
+
+    test('Within 24 hours on different days', done => {
+      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', done);
+    });
+
+    test('More than 24 hours but less than six months', done => {
+      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', done);
+    });
+  });
+
+  suite('UK + 24 hours time format preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_24',
+      date_format: 'UK',
+      relative_date_in_change_table: false,
+    }).then(() => {
+      element = basicFixture.instantiate();
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', done => {
+      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', done);
+    });
+
+    test('Within 24 hours on different days', done => {
+      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', done);
+    });
+
+    test('More than 24 hours but less than six months', done => {
+      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', done);
+    });
+  });
+
+  suite('STD + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'STD'}
+      ).then(() => {
+        element = basicFixture.instantiate();
+        sinon.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', done => {
+      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', done);
+    });
+  });
+
+  suite('US + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'US'}
+      ).then(() => {
+        element = basicFixture.instantiate();
+        sinon.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', done => {
+      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', done);
+    });
+  });
+
+  suite('ISO + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'ISO'}
+      ).then(() => {
+        element = basicFixture.instantiate();
+        sinon.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', done => {
+      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', done);
+    });
+  });
+
+  suite('EURO + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'EURO'}
+      ).then(() => {
+        element = basicFixture.instantiate();
+        sinon.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', done => {
+      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', done);
+    });
+  });
+
+  suite('UK + 12 hours time format preference', () => {
+    setup(() =>
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI(
+          {time_format: 'HHMM_12', date_format: 'UK'}
+      ).then(() => {
+        element = basicFixture.instantiate();
+        sinon.stub(element, '_getUtcOffsetString').returns('');
+        return element._loadPreferences();
+      })
+    );
+
+    test('Within 24 hours on same day', done => {
+      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', done);
+    });
+  });
+
+  suite('relative date preference', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_12',
+      date_format: 'STD',
+      relative_date_in_change_table: true,
+    }).then(() => {
+      element = basicFixture.instantiate();
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      return element._loadPreferences();
+    }));
+
+    test('Within 24 hours on same day', done => {
+      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', done);
+    });
+
+    test('More than six months', done => {
+      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', done);
+    });
+  });
+
+  suite('logged in', () => {
+    setup(() => stubRestAPI({
+      time_format: 'HHMM_12',
+      date_format: 'US',
+      relative_date_in_change_table: true,
+    }).then(() => {
+      element = basicFixture.instantiate();
+      return 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(() => stubRestAPI(null).then(() => {
+      element = basicFixture.instantiate();
+      return 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);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
index db64661..2292ae7 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../gr-button/gr-button.js';
 import '../../../styles/shared-styles.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
@@ -24,7 +22,7 @@
 import {htmlTemplate} from './gr-dialog_html.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrDialog extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js
deleted file mode 100644
index b32f871..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.js
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      color: var(--primary-text-color);
-      display: block;
-      max-height: 90vh;
-      overflow: auto;
-    }
-    .container {
-      display: flex;
-      flex-direction: column;
-      max-height: 90vh;
-      padding: var(--spacing-xl);
-    }
-    header {
-      flex-shrink: 0;
-      padding-bottom: var(--spacing-xl);
-    }
-    main {
-      display: flex;
-      flex-shrink: 1;
-      width: 100%;
-      flex: 1;
-      /* IMPORTANT: required for firefox */
-      min-height: 0px;
-    }
-    main .overflow-container {
-      flex: 1;
-      overflow: auto;
-    }
-    footer {
-      display: flex;
-      flex-shrink: 0;
-      justify-content: flex-end;
-      padding-top: var(--spacing-xl);
-    }
-    gr-button {
-      margin-left: var(--spacing-l);
-    }
-    .hidden {
-      display: none;
-    }
-  </style>
-  <div class="container" on-keydown="_handleKeydown">
-    <header class="font-h3"><slot name="header"></slot></header>
-    <main>
-      <div class="overflow-container">
-        <slot name="main"></slot>
-      </div>
-    </main>
-    <footer>
-      <slot name="footer"></slot>
-      <gr-button
-        id="cancel"
-        class$="[[_computeCancelClass(cancelLabel)]]"
-        link=""
-        on-click="_handleCancelTap"
-      >
-        [[cancelLabel]]
-      </gr-button>
-      <gr-button
-        id="confirm"
-        link=""
-        primary=""
-        on-click="_handleConfirm"
-        disabled="[[disabled]]"
-        title$="[[confirmTooltip]]"
-      >
-        [[confirmLabel]]
-      </gr-button>
-    </footer>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.ts
new file mode 100644
index 0000000..f8ddcfd
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.ts
@@ -0,0 +1,91 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      color: var(--primary-text-color);
+      display: block;
+      max-height: 90vh;
+      overflow: auto;
+    }
+    .container {
+      display: flex;
+      flex-direction: column;
+      max-height: 90vh;
+      padding: var(--spacing-xl);
+    }
+    header {
+      flex-shrink: 0;
+      padding-bottom: var(--spacing-xl);
+    }
+    main {
+      display: flex;
+      flex-shrink: 1;
+      width: 100%;
+      flex: 1;
+      /* IMPORTANT: required for firefox */
+      min-height: 0px;
+    }
+    main .overflow-container {
+      flex: 1;
+      overflow: auto;
+    }
+    footer {
+      display: flex;
+      flex-shrink: 0;
+      justify-content: flex-end;
+      padding-top: var(--spacing-xl);
+    }
+    gr-button {
+      margin-left: var(--spacing-l);
+    }
+    .hidden {
+      display: none;
+    }
+  </style>
+  <div class="container" on-keydown="_handleKeydown">
+    <header class="heading-3"><slot name="header"></slot></header>
+    <main>
+      <div class="overflow-container">
+        <slot name="main"></slot>
+      </div>
+    </main>
+    <footer>
+      <slot name="footer"></slot>
+      <gr-button
+        id="cancel"
+        class$="[[_computeCancelClass(cancelLabel)]]"
+        link=""
+        on-click="_handleCancelTap"
+      >
+        [[cancelLabel]]
+      </gr-button>
+      <gr-button
+        id="confirm"
+        link=""
+        primary=""
+        on-click="_handleConfirm"
+        disabled="[[disabled]]"
+        title$="[[confirmTooltip]]"
+      >
+        [[confirmLabel]]
+      </gr-button>
+    </footer>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
deleted file mode 100644
index 1060e82..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.html
+++ /dev/null
@@ -1,111 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-dialog</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-dialog></gr-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-dialog.js';
-import {isHidden} from '../../../test/test-utils.js';
-suite('gr-dialog tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('events', done => {
-    let numEvents = 0;
-    function handler() { if (++numEvents == 2) { done(); } }
-
-    element.addEventListener('confirm', handler);
-    element.addEventListener('cancel', handler);
-
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button[primary]'));
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button:not([primary])'));
-  });
-
-  test('confirmOnEnter', () => {
-    element.confirmOnEnter = false;
-    const handleConfirmStub = sandbox.stub(element, '_handleConfirm');
-    const handleKeydownSpy = sandbox.spy(element, '_handleKeydown');
-    MockInteractions.pressAndReleaseKeyOn(element.shadowRoot
-        .querySelector('main'),
-    13, null, 'enter');
-    flushAsynchronousOperations();
-
-    assert.isTrue(handleKeydownSpy.called);
-    assert.isFalse(handleConfirmStub.called);
-
-    element.confirmOnEnter = true;
-    MockInteractions.pressAndReleaseKeyOn(element.shadowRoot
-        .querySelector('main'),
-    13, null, 'enter');
-    flushAsynchronousOperations();
-
-    assert.isTrue(handleConfirmStub.called);
-  });
-
-  test('resetFocus', () => {
-    const focusStub = sandbox.stub(element.$.confirm, 'focus');
-    element.resetFocus();
-    assert.isTrue(focusStub.calledOnce);
-  });
-
-  suite('tooltip', () => {
-    test('tooltip not added by default', () => {
-      assert.isNull(element.$.confirm.getAttribute('has-tooltip'));
-    });
-
-    test('tooltip added if confirm tooltip is passed', done => {
-      element.confirmTooltip = 'confirm tooltip';
-      flush(() => {
-        assert(element.$.confirm.getAttribute('has-tooltip'));
-        done();
-      });
-    });
-  });
-
-  test('empty cancel label hides cancel btn', () => {
-    assert.isFalse(isHidden(element.$.cancel));
-    element.cancelLabel = '';
-    flushAsynchronousOperations();
-
-    assert.isTrue(isHidden(element.$.cancel));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js
new file mode 100644
index 0000000..ce36d7b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js
@@ -0,0 +1,93 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-dialog.js';
+import {isHidden} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-dialog');
+
+suite('gr-dialog tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('events', done => {
+    let numEvents = 0;
+    function handler() { if (++numEvents == 2) { done(); } }
+
+    element.addEventListener('confirm', handler);
+    element.addEventListener('cancel', handler);
+
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button[primary]'));
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button:not([primary])'));
+  });
+
+  test('confirmOnEnter', () => {
+    element.confirmOnEnter = false;
+    const handleConfirmStub = sinon.stub(element, '_handleConfirm');
+    const handleKeydownSpy = sinon.spy(element, '_handleKeydown');
+    MockInteractions.pressAndReleaseKeyOn(element.shadowRoot
+        .querySelector('main'),
+    13, null, 'enter');
+    flushAsynchronousOperations();
+
+    assert.isTrue(handleKeydownSpy.called);
+    assert.isFalse(handleConfirmStub.called);
+
+    element.confirmOnEnter = true;
+    MockInteractions.pressAndReleaseKeyOn(element.shadowRoot
+        .querySelector('main'),
+    13, null, 'enter');
+    flushAsynchronousOperations();
+
+    assert.isTrue(handleConfirmStub.called);
+  });
+
+  test('resetFocus', () => {
+    const focusStub = sinon.stub(element.$.confirm, 'focus');
+    element.resetFocus();
+    assert.isTrue(focusStub.calledOnce);
+  });
+
+  suite('tooltip', () => {
+    test('tooltip not added by default', () => {
+      assert.isNull(element.$.confirm.getAttribute('has-tooltip'));
+    });
+
+    test('tooltip added if confirm tooltip is passed', done => {
+      element.confirmTooltip = 'confirm tooltip';
+      flush(() => {
+        assert(element.$.confirm.getAttribute('has-tooltip'));
+        done();
+      });
+    });
+  });
+
+  test('empty cancel label hides cancel btn', () => {
+    assert.isFalse(isHidden(element.$.cancel));
+    element.cancelLabel = '';
+    flushAsynchronousOperations();
+
+    assert.isTrue(isHidden(element.$.cancel));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
index 00f9078..1d00941 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '../../../styles/shared-styles.js';
 import '../gr-button/gr-button.js';
@@ -26,7 +24,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-diff-preferences_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrDiffPreferences extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.js
deleted file mode 100644
index 3ea40d9..0000000
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.js
+++ /dev/null
@@ -1,195 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div id="diffPreferences" class="gr-form-styles">
-    <section>
-      <span class="title">Context</span>
-      <span class="value">
-        <gr-select id="contextSelect" bind-value="{{diffPrefs.context}}">
-          <select
-            on-keypress="_handleDiffPrefsChanged"
-            on-change="_handleDiffPrefsChanged"
-          >
-            <option value="3">3 lines</option>
-            <option value="10">10 lines</option>
-            <option value="25">25 lines</option>
-            <option value="50">50 lines</option>
-            <option value="75">75 lines</option>
-            <option value="100">100 lines</option>
-            <option value="-1">Whole file</option>
-          </select>
-        </gr-select>
-      </span>
-    </section>
-    <section>
-      <span class="title">Fit to screen</span>
-      <span class="value">
-        <input
-          id="lineWrappingInput"
-          type="checkbox"
-          checked$="[[diffPrefs.line_wrapping]]"
-          on-change="_handleLineWrappingTap"
-        />
-      </span>
-    </section>
-    <section>
-      <span class="title">Diff width</span>
-      <span class="value">
-        <iron-input
-          type="number"
-          prevent-invalid-input=""
-          allowed-pattern="[0-9]"
-          bind-value="{{diffPrefs.line_length}}"
-          on-keypress="_handleDiffPrefsChanged"
-          on-change="_handleDiffPrefsChanged"
-        >
-          <input
-            is="iron-input"
-            type="number"
-            id="columnsInput"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{diffPrefs.line_length}}"
-            on-keypress="_handleDiffPrefsChanged"
-            on-change="_handleDiffPrefsChanged"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <span class="title">Tab width</span>
-      <span class="value">
-        <iron-input
-          type="number"
-          prevent-invalid-input=""
-          allowed-pattern="[0-9]"
-          bind-value="{{diffPrefs.tab_size}}"
-          on-keypress="_handleDiffPrefsChanged"
-          on-change="_handleDiffPrefsChanged"
-        >
-          <input
-            is="iron-input"
-            type="number"
-            id="tabSizeInput"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{diffPrefs.tab_size}}"
-            on-keypress="_handleDiffPrefsChanged"
-            on-change="_handleDiffPrefsChanged"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section hidden$="[[!diffPrefs.font_size]]">
-      <span class="title">Font size</span>
-      <span class="value">
-        <iron-input
-          type="number"
-          prevent-invalid-input=""
-          allowed-pattern="[0-9]"
-          bind-value="{{diffPrefs.font_size}}"
-          on-keypress="_handleDiffPrefsChanged"
-          on-change="_handleDiffPrefsChanged"
-        >
-          <input
-            is="iron-input"
-            type="number"
-            id="fontSizeInput"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{diffPrefs.font_size}}"
-            on-keypress="_handleDiffPrefsChanged"
-            on-change="_handleDiffPrefsChanged"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <span class="title">Show tabs</span>
-      <span class="value">
-        <input
-          id="showTabsInput"
-          type="checkbox"
-          checked$="[[diffPrefs.show_tabs]]"
-          on-change="_handleShowTabsTap"
-        />
-      </span>
-    </section>
-    <section>
-      <span class="title">Show trailing whitespace</span>
-      <span class="value">
-        <input
-          id="showTrailingWhitespaceInput"
-          type="checkbox"
-          checked$="[[diffPrefs.show_whitespace_errors]]"
-          on-change="_handleShowTrailingWhitespaceTap"
-        />
-      </span>
-    </section>
-    <section>
-      <span class="title">Syntax highlighting</span>
-      <span class="value">
-        <input
-          id="syntaxHighlightInput"
-          type="checkbox"
-          checked$="[[diffPrefs.syntax_highlighting]]"
-          on-change="_handleSyntaxHighlightTap"
-        />
-      </span>
-    </section>
-    <section>
-      <span class="title">Automatically mark viewed files reviewed</span>
-      <span class="value">
-        <input
-          id="automaticReviewInput"
-          type="checkbox"
-          checked$="[[!diffPrefs.manual_review]]"
-          on-change="_handleAutomaticReviewTap"
-        />
-      </span>
-    </section>
-    <section>
-      <div class="pref">
-        <span class="title">Ignore Whitespace</span>
-        <span class="value">
-          <gr-select bind-value="{{diffPrefs.ignore_whitespace}}">
-            <select
-              on-keypress="_handleDiffPrefsChanged"
-              on-change="_handleDiffPrefsChanged"
-            >
-              <option value="IGNORE_NONE">None</option>
-              <option value="IGNORE_TRAILING">Trailing</option>
-              <option value="IGNORE_LEADING_AND_TRAILING"
-                >Leading &amp; trailing</option
-              >
-              <option value="IGNORE_ALL">All</option>
-            </select>
-          </gr-select>
-        </span>
-      </div>
-    </section>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
new file mode 100644
index 0000000..54022ba
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
@@ -0,0 +1,195 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="gr-form-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <div id="diffPreferences" class="gr-form-styles">
+    <section>
+      <span class="title">Context</span>
+      <span class="value">
+        <gr-select id="contextSelect" bind-value="{{diffPrefs.context}}">
+          <select
+            on-keypress="_handleDiffPrefsChanged"
+            on-change="_handleDiffPrefsChanged"
+          >
+            <option value="3">3 lines</option>
+            <option value="10">10 lines</option>
+            <option value="25">25 lines</option>
+            <option value="50">50 lines</option>
+            <option value="75">75 lines</option>
+            <option value="100">100 lines</option>
+            <option value="-1">Whole file</option>
+          </select>
+        </gr-select>
+      </span>
+    </section>
+    <section>
+      <span class="title">Fit to screen</span>
+      <span class="value">
+        <input
+          id="lineWrappingInput"
+          type="checkbox"
+          checked="[[diffPrefs.line_wrapping]]"
+          on-change="_handleLineWrappingTap"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Diff width</span>
+      <span class="value">
+        <iron-input
+          type="number"
+          prevent-invalid-input=""
+          allowed-pattern="[0-9]"
+          bind-value="{{diffPrefs.line_length}}"
+          on-keypress="_handleDiffPrefsChanged"
+          on-change="_handleDiffPrefsChanged"
+        >
+          <input
+            is="iron-input"
+            type="number"
+            id="columnsInput"
+            prevent-invalid-input=""
+            allowed-pattern="[0-9]"
+            bind-value="{{diffPrefs.line_length}}"
+            on-keypress="_handleDiffPrefsChanged"
+            on-change="_handleDiffPrefsChanged"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Tab width</span>
+      <span class="value">
+        <iron-input
+          type="number"
+          prevent-invalid-input=""
+          allowed-pattern="[0-9]"
+          bind-value="{{diffPrefs.tab_size}}"
+          on-keypress="_handleDiffPrefsChanged"
+          on-change="_handleDiffPrefsChanged"
+        >
+          <input
+            is="iron-input"
+            type="number"
+            id="tabSizeInput"
+            prevent-invalid-input=""
+            allowed-pattern="[0-9]"
+            bind-value="{{diffPrefs.tab_size}}"
+            on-keypress="_handleDiffPrefsChanged"
+            on-change="_handleDiffPrefsChanged"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section hidden$="[[!diffPrefs.font_size]]">
+      <span class="title">Font size</span>
+      <span class="value">
+        <iron-input
+          type="number"
+          prevent-invalid-input=""
+          allowed-pattern="[0-9]"
+          bind-value="{{diffPrefs.font_size}}"
+          on-keypress="_handleDiffPrefsChanged"
+          on-change="_handleDiffPrefsChanged"
+        >
+          <input
+            is="iron-input"
+            type="number"
+            id="fontSizeInput"
+            prevent-invalid-input=""
+            allowed-pattern="[0-9]"
+            bind-value="{{diffPrefs.font_size}}"
+            on-keypress="_handleDiffPrefsChanged"
+            on-change="_handleDiffPrefsChanged"
+          />
+        </iron-input>
+      </span>
+    </section>
+    <section>
+      <span class="title">Show tabs</span>
+      <span class="value">
+        <input
+          id="showTabsInput"
+          type="checkbox"
+          checked="[[diffPrefs.show_tabs]]"
+          on-change="_handleShowTabsTap"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Show trailing whitespace</span>
+      <span class="value">
+        <input
+          id="showTrailingWhitespaceInput"
+          type="checkbox"
+          checked="[[diffPrefs.show_whitespace_errors]]"
+          on-change="_handleShowTrailingWhitespaceTap"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Syntax highlighting</span>
+      <span class="value">
+        <input
+          id="syntaxHighlightInput"
+          type="checkbox"
+          checked="[[diffPrefs.syntax_highlighting]]"
+          on-change="_handleSyntaxHighlightTap"
+        />
+      </span>
+    </section>
+    <section>
+      <span class="title">Automatically mark viewed files reviewed</span>
+      <span class="value">
+        <input
+          id="automaticReviewInput"
+          type="checkbox"
+          checked="[[!diffPrefs.manual_review]]"
+          on-change="_handleAutomaticReviewTap"
+        />
+      </span>
+    </section>
+    <section>
+      <div class="pref">
+        <span class="title">Ignore Whitespace</span>
+        <span class="value">
+          <gr-select bind-value="{{diffPrefs.ignore_whitespace}}">
+            <select
+              on-keypress="_handleDiffPrefsChanged"
+              on-change="_handleDiffPrefsChanged"
+            >
+              <option value="IGNORE_NONE">None</option>
+              <option value="IGNORE_TRAILING">Trailing</option>
+              <option value="IGNORE_LEADING_AND_TRAILING"
+                >Leading &amp; trailing</option
+              >
+              <option value="IGNORE_ALL">All</option>
+            </select>
+          </gr-select>
+        </span>
+      </div>
+    </section>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
deleted file mode 100644
index 2750d67..0000000
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.html
+++ /dev/null
@@ -1,124 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-diff-preferences</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-preferences></gr-diff-preferences>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-diff-preferences.js';
-suite('gr-diff-preferences tests', () => {
-  let element;
-  let sandbox;
-  let diffPreferences;
-
-  function valueOf(title, fieldsetid) {
-    const sections = element.$[fieldsetid].querySelectorAll('section');
-    let titleEl;
-    for (let i = 0; i < sections.length; i++) {
-      titleEl = sections[i].querySelector('.title');
-      if (titleEl.textContent.trim() === title) {
-        return sections[i].querySelector('.value');
-      }
-    }
-  }
-
-  setup(() => {
-    diffPreferences = {
-      context: 10,
-      line_wrapping: false,
-      line_length: 100,
-      tab_size: 8,
-      font_size: 12,
-      show_tabs: true,
-      show_whitespace_errors: true,
-      syntax_highlighting: true,
-      manual_review: false,
-      ignore_whitespace: 'IGNORE_NONE',
-    };
-
-    stub('gr-rest-api-interface', {
-      getDiffPreferences() {
-        return Promise.resolve(diffPreferences);
-      },
-    });
-
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    return element.loadData();
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('renders', () => {
-    // Rendered with the expected preferences selected.
-    assert.equal(valueOf('Context', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.context);
-    assert.equal(valueOf('Fit to screen', 'diffPreferences')
-        .firstElementChild.checked, diffPreferences.line_wrapping);
-    assert.equal(valueOf('Diff width', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.line_length);
-    assert.equal(valueOf('Tab width', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.tab_size);
-    assert.equal(valueOf('Font size', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.font_size);
-    assert.equal(valueOf('Show tabs', 'diffPreferences')
-        .firstElementChild.checked, diffPreferences.show_tabs);
-    assert.equal(valueOf('Show trailing whitespace', 'diffPreferences')
-        .firstElementChild.checked, diffPreferences.show_whitespace_errors);
-    assert.equal(valueOf('Syntax highlighting', 'diffPreferences')
-        .firstElementChild.checked, diffPreferences.syntax_highlighting);
-    assert.equal(
-        valueOf('Automatically mark viewed files reviewed', 'diffPreferences')
-            .firstElementChild.checked, !diffPreferences.manual_review);
-    assert.equal(valueOf('Ignore Whitespace', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.ignore_whitespace);
-
-    assert.isFalse(element.hasUnsavedChanges);
-  });
-
-  test('save changes', () => {
-    sandbox.stub(element.$.restAPI, 'saveDiffPreferences')
-        .returns(Promise.resolve());
-    const showTrailingWhitespaceCheckbox =
-        valueOf('Show trailing whitespace', 'diffPreferences')
-            .firstElementChild;
-    showTrailingWhitespaceCheckbox.checked = false;
-    element._handleShowTrailingWhitespaceTap();
-
-    assert.isTrue(element.hasUnsavedChanges);
-
-    // Save the change.
-    return element.save().then(() => {
-      assert.isFalse(element.hasUnsavedChanges);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js
new file mode 100644
index 0000000..ced8c6c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js
@@ -0,0 +1,108 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-diff-preferences.js';
+
+const basicFixture = fixtureFromElement('gr-diff-preferences');
+
+suite('gr-diff-preferences tests', () => {
+  let element;
+
+  let diffPreferences;
+
+  function valueOf(title, fieldsetid) {
+    const sections = element.$[fieldsetid].querySelectorAll('section');
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl.textContent.trim() === title) {
+        return sections[i].querySelector('.value');
+      }
+    }
+  }
+
+  setup(() => {
+    diffPreferences = {
+      context: 10,
+      line_wrapping: false,
+      line_length: 100,
+      tab_size: 8,
+      font_size: 12,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      manual_review: false,
+      ignore_whitespace: 'IGNORE_NONE',
+    };
+
+    stub('gr-rest-api-interface', {
+      getDiffPreferences() {
+        return Promise.resolve(diffPreferences);
+      },
+    });
+
+    element = basicFixture.instantiate();
+
+    return element.loadData();
+  });
+
+  test('renders', () => {
+    // Rendered with the expected preferences selected.
+    assert.equal(valueOf('Context', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.context);
+    assert.equal(valueOf('Fit to screen', 'diffPreferences')
+        .firstElementChild.checked, diffPreferences.line_wrapping);
+    assert.equal(valueOf('Diff width', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.line_length);
+    assert.equal(valueOf('Tab width', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.tab_size);
+    assert.equal(valueOf('Font size', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.font_size);
+    assert.equal(valueOf('Show tabs', 'diffPreferences')
+        .firstElementChild.checked, diffPreferences.show_tabs);
+    assert.equal(valueOf('Show trailing whitespace', 'diffPreferences')
+        .firstElementChild.checked, diffPreferences.show_whitespace_errors);
+    assert.equal(valueOf('Syntax highlighting', 'diffPreferences')
+        .firstElementChild.checked, diffPreferences.syntax_highlighting);
+    assert.equal(
+        valueOf('Automatically mark viewed files reviewed', 'diffPreferences')
+            .firstElementChild.checked, !diffPreferences.manual_review);
+    assert.equal(valueOf('Ignore Whitespace', 'diffPreferences')
+        .firstElementChild.bindValue, diffPreferences.ignore_whitespace);
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('save changes', () => {
+    sinon.stub(element.$.restAPI, 'saveDiffPreferences')
+        .returns(Promise.resolve());
+    const showTrailingWhitespaceCheckbox =
+        valueOf('Show trailing whitespace', 'diffPreferences')
+            .firstElementChild;
+    showTrailingWhitespaceCheckbox.checked = false;
+    element._handleShowTrailingWhitespaceTap();
+
+    assert.isTrue(element.hasUnsavedChanges);
+
+    // Save the change.
+    return element.save().then(() => {
+      assert.isFalse(element.hasUnsavedChanges);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
index fcc09c4..e3ae8e2 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
@@ -14,27 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/paper-tabs/paper-tabs.js';
 import '../gr-shell-command/gr-shell-command.js';
 import '../gr-rest-api-interface/gr-rest-api-interface.js';
 import '../../../styles/shared-styles.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-download-commands_html.js';
-import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrDownloadCommands extends mixinBehaviors( [
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrDownloadCommands extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-download-commands'; }
@@ -100,6 +93,11 @@
   _computeShowTabs(schemes) {
     return schemes.length > 1 ? '' : 'hidden';
   }
+
+  _computeClass(title) {
+    // Only retain [a-z] chars, so "Cherry Pick" becomes "cherrypick".
+    return '_label_' + title.replace(/[^a-z]+/gi, '').toLowerCase();
+  }
 }
 
 customElements.define(GrDownloadCommands.is, GrDownloadCommands);
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.js
deleted file mode 100644
index 7248e65..0000000
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.js
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    paper-tabs {
-      height: 3rem;
-      margin-bottom: var(--spacing-m);
-      --paper-tabs-selection-bar-color: var(--link-color);
-    }
-    paper-tab {
-      max-width: 15rem;
-      text-transform: uppercase;
-      --paper-tab-ink: var(--link-color);
-    }
-    label,
-    input {
-      display: block;
-    }
-    label {
-      font-weight: var(--font-weight-bold);
-    }
-    .schemes {
-      display: flex;
-      justify-content: space-between;
-    }
-    .commands {
-      display: flex;
-      flex-direction: column;
-    }
-    gr-shell-command {
-      width: 60em;
-      margin-bottom: var(--spacing-m);
-    }
-    .hidden {
-      display: none;
-    }
-  </style>
-  <div class="schemes">
-    <paper-tabs
-      id="downloadTabs"
-      class$="[[_computeShowTabs(schemes)]]"
-      selected="[[_computeSelected(schemes, selectedScheme)]]"
-      on-selected-changed="_handleTabChange"
-    >
-      <template is="dom-repeat" items="[[schemes]]" as="scheme">
-        <paper-tab data-scheme$="[[scheme]]">[[scheme]]</paper-tab>
-      </template>
-    </paper-tabs>
-  </div>
-  <div class="commands" hidden$="[[!schemes.length]]" hidden="">
-    <template is="dom-repeat" items="[[commands]]" as="command">
-      <gr-shell-command
-        label="[[command.title]]"
-        command="[[command.command]]"
-      ></gr-shell-command>
-    </template>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
new file mode 100644
index 0000000..35385fa
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    paper-tabs {
+      height: 3rem;
+      margin-bottom: var(--spacing-m);
+      --paper-tabs-selection-bar-color: var(--link-color);
+    }
+    paper-tab {
+      max-width: 15rem;
+      text-transform: uppercase;
+      --paper-tab-ink: var(--link-color);
+    }
+    label,
+    input {
+      display: block;
+    }
+    label {
+      font-weight: var(--font-weight-bold);
+    }
+    .schemes {
+      display: flex;
+      justify-content: space-between;
+    }
+    .commands {
+      display: flex;
+      flex-direction: column;
+    }
+    gr-shell-command {
+      width: 60em;
+      margin-bottom: var(--spacing-m);
+    }
+    .hidden {
+      display: none;
+    }
+  </style>
+  <div class="schemes">
+    <paper-tabs
+      id="downloadTabs"
+      class$="[[_computeShowTabs(schemes)]]"
+      selected="[[_computeSelected(schemes, selectedScheme)]]"
+      on-selected-changed="_handleTabChange"
+    >
+      <template is="dom-repeat" items="[[schemes]]" as="scheme">
+        <paper-tab data-scheme$="[[scheme]]">[[scheme]]</paper-tab>
+      </template>
+    </paper-tabs>
+  </div>
+  <div class="commands" hidden$="[[!schemes.length]]" hidden="">
+    <template is="dom-repeat" items="[[commands]]" as="command">
+      <gr-shell-command
+        class$="[[_computeClass(command.title)]]"
+        label="[[command.title]]"
+        command="[[command.command]]"
+      ></gr-shell-command>
+    </template>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
deleted file mode 100644
index 237fbe0..0000000
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
+++ /dev/null
@@ -1,155 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-download-commands</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-download-commands></gr-download-commands>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-download-commands.js';
-import {isHidden} from '../../../test/test-utils.js';
-suite('gr-download-commands', () => {
-  let element;
-  let sandbox;
-  const SCHEMES = ['http', 'repo', 'ssh'];
-  const COMMANDS = [{
-    title: 'Checkout',
-    command: `git fetch http://andybons@localhost:8080/a/test-project
-        refs/changes/05/5/1 && git checkout FETCH_HEAD`,
-  }, {
-    title: 'Cherry Pick',
-    command: `git fetch http://andybons@localhost:8080/a/test-project
-        refs/changes/05/5/1 && git cherry-pick FETCH_HEAD`,
-  }, {
-    title: 'Format Patch',
-    command: `git fetch http://andybons@localhost:8080/a/test-project
-        refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD`,
-  }, {
-    title: 'Pull',
-    command: `git pull http://andybons@localhost:8080/a/test-project
-        refs/changes/05/5/1`,
-  }];
-  const SELECTED_SCHEME = 'http';
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('unauthenticated', () => {
-    setup(done => {
-      element = fixture('basic');
-      element.schemes = SCHEMES;
-      element.commands = COMMANDS;
-      element.selectedScheme = SELECTED_SCHEME;
-      flushAsynchronousOperations();
-      flush(done);
-    });
-
-    test('focusOnCopy', () => {
-      const focusStub = sandbox.stub(element.shadowRoot
-          .querySelector('gr-shell-command'),
-      'focusOnCopy');
-      element.focusOnCopy();
-      assert.isTrue(focusStub.called);
-    });
-
-    test('element visibility', () => {
-      assert.isFalse(isHidden(element.shadowRoot
-          .querySelector('paper-tabs')));
-      assert.isFalse(isHidden(element.shadowRoot
-          .querySelector('.commands')));
-
-      element.schemes = [];
-      assert.isTrue(isHidden(element.shadowRoot
-          .querySelector('paper-tabs')));
-      assert.isTrue(isHidden(element.shadowRoot
-          .querySelector('.commands')));
-    });
-
-    test('tab selection', done => {
-      assert.equal(element.$.downloadTabs.selected, '0');
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('[data-scheme="ssh"]'));
-      flushAsynchronousOperations();
-      assert.equal(element.selectedScheme, 'ssh');
-      assert.equal(element.$.downloadTabs.selected, '2');
-      done();
-    });
-
-    test('loads scheme from preferences', done => {
-      stub('gr-rest-api-interface', {
-        getPreferences() {
-          return Promise.resolve({download_scheme: 'repo'});
-        },
-      });
-      element._loggedIn = true;
-      assert.isTrue(element.$.restAPI.getPreferences.called);
-      element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
-        assert.equal(element.selectedScheme, 'repo');
-        done();
-      });
-    });
-
-    test('normalize scheme from preferences', done => {
-      stub('gr-rest-api-interface', {
-        getPreferences() {
-          return Promise.resolve({download_scheme: 'REPO'});
-        },
-      });
-      element._loggedIn = true;
-      element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
-        assert.equal(element.selectedScheme, 'repo');
-        done();
-      });
-    });
-
-    test('saves scheme to preferences', () => {
-      element._loggedIn = true;
-      const savePrefsStub = sandbox.stub(element.$.restAPI, 'savePreferences',
-          () => Promise.resolve());
-
-      flushAsynchronousOperations();
-
-      const repoTab = element.shadowRoot
-          .querySelector('paper-tab[data-scheme="repo"]');
-
-      MockInteractions.tap(repoTab);
-
-      assert.isTrue(savePrefsStub.called);
-      assert.equal(savePrefsStub.lastCall.args[0].download_scheme,
-          repoTab.getAttribute('data-scheme'));
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
new file mode 100644
index 0000000..0f8b97d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
@@ -0,0 +1,137 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-download-commands.js';
+import {isHidden} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-download-commands');
+
+suite('gr-download-commands', () => {
+  let element;
+
+  const SCHEMES = ['http', 'repo', 'ssh'];
+  const COMMANDS = [{
+    title: 'Checkout',
+    command: `git fetch http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git checkout FETCH_HEAD`,
+  }, {
+    title: 'Cherry Pick',
+    command: `git fetch http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git cherry-pick FETCH_HEAD`,
+  }, {
+    title: 'Format Patch',
+    command: `git fetch http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD`,
+  }, {
+    title: 'Pull',
+    command: `git pull http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1`,
+  }];
+  const SELECTED_SCHEME = 'http';
+
+  setup(() => {
+
+  });
+
+  suite('unauthenticated', () => {
+    setup(done => {
+      element = basicFixture.instantiate();
+      element.schemes = SCHEMES;
+      element.commands = COMMANDS;
+      element.selectedScheme = SELECTED_SCHEME;
+      flushAsynchronousOperations();
+      flush(done);
+    });
+
+    test('focusOnCopy', () => {
+      const focusStub = sinon.stub(element.shadowRoot
+          .querySelector('gr-shell-command'),
+      'focusOnCopy');
+      element.focusOnCopy();
+      assert.isTrue(focusStub.called);
+    });
+
+    test('element visibility', () => {
+      assert.isFalse(isHidden(element.shadowRoot
+          .querySelector('paper-tabs')));
+      assert.isFalse(isHidden(element.shadowRoot
+          .querySelector('.commands')));
+
+      element.schemes = [];
+      assert.isTrue(isHidden(element.shadowRoot
+          .querySelector('paper-tabs')));
+      assert.isTrue(isHidden(element.shadowRoot
+          .querySelector('.commands')));
+    });
+
+    test('tab selection', done => {
+      assert.equal(element.$.downloadTabs.selected, '0');
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('[data-scheme="ssh"]'));
+      flushAsynchronousOperations();
+      assert.equal(element.selectedScheme, 'ssh');
+      assert.equal(element.$.downloadTabs.selected, '2');
+      done();
+    });
+
+    test('loads scheme from preferences', done => {
+      stub('gr-rest-api-interface', {
+        getPreferences() {
+          return Promise.resolve({download_scheme: 'repo'});
+        },
+      });
+      element._loggedIn = true;
+      assert.isTrue(element.$.restAPI.getPreferences.called);
+      element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
+        assert.equal(element.selectedScheme, 'repo');
+        done();
+      });
+    });
+
+    test('normalize scheme from preferences', done => {
+      stub('gr-rest-api-interface', {
+        getPreferences() {
+          return Promise.resolve({download_scheme: 'REPO'});
+        },
+      });
+      element._loggedIn = true;
+      element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
+        assert.equal(element.selectedScheme, 'repo');
+        done();
+      });
+    });
+
+    test('saves scheme to preferences', () => {
+      element._loggedIn = true;
+      const savePrefsStub = sinon.stub(element.$.restAPI, 'savePreferences')
+          .callsFake(() => Promise.resolve());
+
+      flushAsynchronousOperations();
+
+      const repoTab = element.shadowRoot
+          .querySelector('paper-tab[data-scheme="repo"]');
+
+      MockInteractions.tap(repoTab);
+
+      assert.isTrue(savePrefsStub.called);
+      assert.equal(savePrefsStub.lastCall.args[0].download_scheme,
+          repoTab.getAttribute('data-scheme'));
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
index 6b250de..f84ef4a 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-dropdown/iron-dropdown.js';
 import '@polymer/paper-item/paper-item.js';
 import '@polymer/paper-listbox/paper-listbox.js';
@@ -56,7 +54,7 @@
  */
 Defs.item;
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrDropdownList extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.js
deleted file mode 100644
index 0f80af2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.js
+++ /dev/null
@@ -1,174 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: inline-block;
-    }
-    #triggerText {
-      -moz-user-select: text;
-      -ms-user-select: text;
-      -webkit-user-select: text;
-      user-select: text;
-    }
-    .dropdown-trigger {
-      cursor: pointer;
-      padding: 0;
-    }
-    .dropdown-content {
-      background-color: var(--dropdown-background-color);
-      box-shadow: var(--elevation-level-2);
-      max-height: 70vh;
-      margin-top: var(--spacing-xxl);
-      min-width: 266px;
-      @apply --dropdown-content-style;
-    }
-    paper-listbox {
-      --paper-listbox: {
-        padding: 0;
-      }
-    }
-    paper-item {
-      cursor: pointer;
-      flex-direction: column;
-      font-size: inherit;
-      /* This variable was introduced in Dec 2019. We keep both min-height
-         * rules around, because --paper-item-min-height is not yet upstreamed.
-         */
-      --paper-item-min-height: 0;
-      --paper-item: {
-        min-height: 0;
-        padding: 10px 16px;
-      }
-      --paper-item-focused-before: {
-        background-color: var(--selection-background-color);
-      }
-      --paper-item-focused: {
-        background-color: var(--selection-background-color);
-      }
-    }
-    paper-item:hover {
-      background-color: var(--hover-background-color);
-    }
-    paper-item:not(:last-of-type) {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .bottomContent {
-      color: var(--deemphasized-text-color);
-    }
-    .bottomContent,
-    .topContent {
-      display: flex;
-      justify-content: space-between;
-      flex-direction: row;
-      width: 100%;
-    }
-    gr-button {
-      --gr-button: {
-        @apply --trigger-style;
-      }
-    }
-    gr-date-formatter {
-      color: var(--deemphasized-text-color);
-      margin-left: var(--spacing-xxl);
-      white-space: nowrap;
-    }
-    gr-select {
-      display: none;
-    }
-    /* Because the iron dropdown 'area' includes the trigger, and the entire
-       width of the dropdown, we want to treat tapping the area above the
-       dropdown content as if it is tapping whatever content is underneath it.
-       The next two styles allow this to happen. */
-    iron-dropdown {
-      max-width: none;
-      pointer-events: none;
-    }
-    paper-listbox {
-      pointer-events: auto;
-    }
-    @media only screen and (max-width: 50em) {
-      gr-select {
-        display: inline;
-        @apply --gr-select-style;
-      }
-      gr-button,
-      iron-dropdown {
-        display: none;
-      }
-      select {
-        @apply --native-select-style;
-      }
-    }
-  </style>
-  <gr-button
-    disabled="[[disabled]]"
-    down-arrow=""
-    link=""
-    id="trigger"
-    class="dropdown-trigger"
-    on-click="_showDropdownTapHandler"
-    slot="dropdown-trigger"
-  >
-    <span id="triggerText">[[text]]</span>
-  </gr-button>
-  <iron-dropdown
-    id="dropdown"
-    vertical-align="top"
-    allow-outside-scroll="true"
-    on-click="_handleDropdownClick"
-  >
-    <paper-listbox
-      class="dropdown-content"
-      slot="dropdown-content"
-      attr-for-selected="data-value"
-      selected="{{value}}"
-      on-tap="_handleDropdownTap"
-    >
-      <template
-        is="dom-repeat"
-        items="[[items]]"
-        initial-count="[[initialCount]]"
-      >
-        <paper-item disabled="[[item.disabled]]" data-value$="[[item.value]]">
-          <div class="topContent">
-            <div>[[item.text]]</div>
-            <template is="dom-if" if="[[item.date]]">
-              <gr-date-formatter date-str="[[item.date]]"></gr-date-formatter>
-            </template>
-          </div>
-          <template is="dom-if" if="[[item.bottomText]]">
-            <div class="bottomContent">
-              <div>[[item.bottomText]]</div>
-            </div>
-          </template>
-        </paper-item>
-      </template>
-    </paper-listbox>
-  </iron-dropdown>
-  <gr-select bind-value="{{value}}">
-    <select>
-      <template is="dom-repeat" items="[[items]]">
-        <option disabled$="[[item.disabled]]" value="[[item.value]]">
-          [[_computeMobileText(item)]]
-        </option>
-      </template>
-    </select>
-  </gr-select>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
new file mode 100644
index 0000000..629b0ad
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
@@ -0,0 +1,174 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: inline-block;
+    }
+    #triggerText {
+      -moz-user-select: text;
+      -ms-user-select: text;
+      -webkit-user-select: text;
+      user-select: text;
+    }
+    .dropdown-trigger {
+      cursor: pointer;
+      padding: 0;
+    }
+    .dropdown-content {
+      background-color: var(--dropdown-background-color);
+      box-shadow: var(--elevation-level-2);
+      max-height: 70vh;
+      margin-top: var(--spacing-xxl);
+      min-width: 266px;
+      @apply --dropdown-content-style;
+    }
+    paper-listbox {
+      --paper-listbox: {
+        padding: 0;
+      }
+    }
+    paper-item {
+      cursor: pointer;
+      flex-direction: column;
+      font-size: inherit;
+      /* This variable was introduced in Dec 2019. We keep both min-height
+         * rules around, because --paper-item-min-height is not yet upstreamed.
+         */
+      --paper-item-min-height: 0;
+      --paper-item: {
+        min-height: 0;
+        padding: 10px 16px;
+      }
+      --paper-item-focused-before: {
+        background-color: var(--selection-background-color);
+      }
+      --paper-item-focused: {
+        background-color: var(--selection-background-color);
+      }
+    }
+    paper-item:hover {
+      background-color: var(--hover-background-color);
+    }
+    paper-item:not(:last-of-type) {
+      border-bottom: 1px solid var(--border-color);
+    }
+    .bottomContent {
+      color: var(--deemphasized-text-color);
+    }
+    .bottomContent,
+    .topContent {
+      display: flex;
+      justify-content: space-between;
+      flex-direction: row;
+      width: 100%;
+    }
+    gr-button {
+      --gr-button: {
+        @apply --trigger-style;
+      }
+    }
+    gr-date-formatter {
+      color: var(--deemphasized-text-color);
+      margin-left: var(--spacing-xxl);
+      white-space: nowrap;
+    }
+    gr-select {
+      display: none;
+    }
+    /* Because the iron dropdown 'area' includes the trigger, and the entire
+       width of the dropdown, we want to treat tapping the area above the
+       dropdown content as if it is tapping whatever content is underneath it.
+       The next two styles allow this to happen. */
+    iron-dropdown {
+      max-width: none;
+      pointer-events: none;
+    }
+    paper-listbox {
+      pointer-events: auto;
+    }
+    @media only screen and (max-width: 50em) {
+      gr-select {
+        display: inline;
+        @apply --gr-select-style;
+      }
+      gr-button,
+      iron-dropdown {
+        display: none;
+      }
+      select {
+        @apply --native-select-style;
+      }
+    }
+  </style>
+  <gr-button
+    disabled="[[disabled]]"
+    down-arrow=""
+    link=""
+    id="trigger"
+    class="dropdown-trigger"
+    on-click="_showDropdownTapHandler"
+    slot="dropdown-trigger"
+  >
+    <span id="triggerText">[[text]]</span>
+  </gr-button>
+  <iron-dropdown
+    id="dropdown"
+    vertical-align="top"
+    allow-outside-scroll="true"
+    on-click="_handleDropdownClick"
+  >
+    <paper-listbox
+      class="dropdown-content"
+      slot="dropdown-content"
+      attr-for-selected="data-value"
+      selected="{{value}}"
+      on-tap="_handleDropdownTap"
+    >
+      <template
+        is="dom-repeat"
+        items="[[items]]"
+        initial-count="[[initialCount]]"
+      >
+        <paper-item disabled="[[item.disabled]]" data-value$="[[item.value]]">
+          <div class="topContent">
+            <div>[[item.text]]</div>
+            <template is="dom-if" if="[[item.date]]">
+              <gr-date-formatter date-str="[[item.date]]"></gr-date-formatter>
+            </template>
+          </div>
+          <template is="dom-if" if="[[item.bottomText]]">
+            <div class="bottomContent">
+              <div>[[item.bottomText]]</div>
+            </div>
+          </template>
+        </paper-item>
+      </template>
+    </paper-listbox>
+  </iron-dropdown>
+  <gr-select bind-value="{{value}}">
+    <select>
+      <template is="dom-repeat" items="[[items]]">
+        <option disabled$="[[item.disabled]]" value="[[item.value]]">
+          [[_computeMobileText(item)]]
+        </option>
+      </template>
+    </select>
+  </gr-select>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
deleted file mode 100644
index b64d5f7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
+++ /dev/null
@@ -1,172 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-dropdown-list</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-dropdown-list></gr-dropdown-list>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-dropdown-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-dropdown-list tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('tap on trigger opens menu', () => {
-    sandbox.stub(element, '_open', () => { element.$.dropdown.open(); });
-    assert.isFalse(element.$.dropdown.opened);
-    MockInteractions.tap(element.$.trigger);
-    assert.isTrue(element.$.dropdown.opened);
-  });
-
-  test('_computeMobileText', () => {
-    const item = {
-      value: 1,
-      text: 'text',
-    };
-    assert.equal(element._computeMobileText(item), item.text);
-    item.mobileText = 'mobile text';
-    assert.equal(element._computeMobileText(item), item.mobileText);
-  });
-
-  test('options are selected and laid out correctly', done => {
-    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',
-        text: 'Top Text 3',
-        mobileText: 'Mobile Text 3',
-      },
-    ];
-    assert.equal(element.shadowRoot
-        .querySelector('paper-listbox').selected, element.value);
-    assert.equal(element.text, 'Button Text 2');
-    flush(() => {
-      const items = dom(element.root).querySelectorAll('paper-item');
-      const mobileItems = dom(element.root).querySelectorAll('option');
-      assert.equal(items.length, 3);
-      assert.equal(mobileItems.length, 3);
-
-      // First Item
-      // The first item should be disabled, has no bottom text, and no date.
-      assert.isFalse(!!items[0].disabled);
-      assert.isFalse(mobileItems[0].disabled);
-      assert.isFalse(items[0].classList.contains('iron-selected'));
-      assert.isFalse(mobileItems[0].selected);
-
-      assert.isNotOk(dom(items[0]).querySelector('gr-date-formatter'));
-      assert.isNotOk(dom(items[0]).querySelector('.bottomContent'));
-      assert.equal(items[0].dataset.value, element.items[0].value);
-      assert.equal(mobileItems[0].value, element.items[0].value);
-      assert.equal(dom(items[0]).querySelector('.topContent div')
-          .innerText, element.items[0].text);
-
-      // Since no mobile specific text, it should fall back to text.
-      assert.equal(mobileItems[0].text, element.items[0].text);
-
-      // Second Item
-      // The second item should have top text, bottom text, and no date.
-      assert.isFalse(!!items[1].disabled);
-      assert.isFalse(mobileItems[1].disabled);
-      assert.isTrue(items[1].classList.contains('iron-selected'));
-      assert.isTrue(mobileItems[1].selected);
-
-      assert.isNotOk(dom(items[1]).querySelector('gr-date-formatter'));
-      assert.isOk(dom(items[1]).querySelector('.bottomContent'));
-      assert.equal(items[1].dataset.value, element.items[1].value);
-      assert.equal(mobileItems[1].value, element.items[1].value);
-      assert.equal(dom(items[1]).querySelector('.topContent div')
-          .innerText, element.items[1].text);
-
-      // Since there is mobile specific text, it should that.
-      assert.equal(mobileItems[1].text, element.items[1].mobileText);
-
-      // Since this item is selected, and it has triggerText defined, that
-      // should be used.
-      assert.equal(element.text, element.items[1].triggerText);
-
-      // Third item
-      // The third item should be disabled, and have a date, and bottom content.
-      assert.isTrue(!!items[2].disabled);
-      assert.isTrue(mobileItems[2].disabled);
-      assert.isFalse(items[2].classList.contains('iron-selected'));
-      assert.isFalse(mobileItems[2].selected);
-
-      assert.isOk(dom(items[2]).querySelector('gr-date-formatter'));
-      assert.isOk(dom(items[2]).querySelector('.bottomContent'));
-      assert.equal(items[2].dataset.value, element.items[2].value);
-      assert.equal(mobileItems[2].value, element.items[2].value);
-      assert.equal(dom(items[2]).querySelector('.topContent div')
-          .innerText, element.items[2].text);
-
-      // Since there is mobile specific text, it should that.
-      assert.equal(mobileItems[2].text, element.items[2].mobileText);
-
-      // Select a new item.
-      MockInteractions.tap(items[0]);
-      flushAsynchronousOperations();
-      assert.equal(element.value, 1);
-      assert.isTrue(items[0].classList.contains('iron-selected'));
-      assert.isTrue(mobileItems[0].selected);
-
-      // Since no triggerText, the fallback is used.
-      assert.equal(element.text, element.items[0].text);
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
new file mode 100644
index 0000000..8d7de0e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
@@ -0,0 +1,153 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-dropdown-list.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-dropdown-list');
+
+suite('gr-dropdown-list tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('tap on trigger opens menu', () => {
+    sinon.stub(element, '_open')
+        .callsFake(() => { element.$.dropdown.open(); });
+    assert.isFalse(element.$.dropdown.opened);
+    MockInteractions.tap(element.$.trigger);
+    assert.isTrue(element.$.dropdown.opened);
+  });
+
+  test('_computeMobileText', () => {
+    const item = {
+      value: 1,
+      text: 'text',
+    };
+    assert.equal(element._computeMobileText(item), item.text);
+    item.mobileText = 'mobile text';
+    assert.equal(element._computeMobileText(item), item.mobileText);
+  });
+
+  test('options are selected and laid out correctly', done => {
+    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',
+        text: 'Top Text 3',
+        mobileText: 'Mobile Text 3',
+      },
+    ];
+    assert.equal(element.shadowRoot
+        .querySelector('paper-listbox').selected, element.value);
+    assert.equal(element.text, 'Button Text 2');
+    flush(() => {
+      const items = dom(element.root).querySelectorAll('paper-item');
+      const mobileItems = dom(element.root).querySelectorAll('option');
+      assert.equal(items.length, 3);
+      assert.equal(mobileItems.length, 3);
+
+      // First Item
+      // The first item should be disabled, has no bottom text, and no date.
+      assert.isFalse(!!items[0].disabled);
+      assert.isFalse(mobileItems[0].disabled);
+      assert.isFalse(items[0].classList.contains('iron-selected'));
+      assert.isFalse(mobileItems[0].selected);
+
+      assert.isNotOk(dom(items[0]).querySelector('gr-date-formatter'));
+      assert.isNotOk(dom(items[0]).querySelector('.bottomContent'));
+      assert.equal(items[0].dataset.value, element.items[0].value);
+      assert.equal(mobileItems[0].value, element.items[0].value);
+      assert.equal(dom(items[0]).querySelector('.topContent div')
+          .innerText, element.items[0].text);
+
+      // Since no mobile specific text, it should fall back to text.
+      assert.equal(mobileItems[0].text, element.items[0].text);
+
+      // Second Item
+      // The second item should have top text, bottom text, and no date.
+      assert.isFalse(!!items[1].disabled);
+      assert.isFalse(mobileItems[1].disabled);
+      assert.isTrue(items[1].classList.contains('iron-selected'));
+      assert.isTrue(mobileItems[1].selected);
+
+      assert.isNotOk(dom(items[1]).querySelector('gr-date-formatter'));
+      assert.isOk(dom(items[1]).querySelector('.bottomContent'));
+      assert.equal(items[1].dataset.value, element.items[1].value);
+      assert.equal(mobileItems[1].value, element.items[1].value);
+      assert.equal(dom(items[1]).querySelector('.topContent div')
+          .innerText, element.items[1].text);
+
+      // Since there is mobile specific text, it should that.
+      assert.equal(mobileItems[1].text, element.items[1].mobileText);
+
+      // Since this item is selected, and it has triggerText defined, that
+      // should be used.
+      assert.equal(element.text, element.items[1].triggerText);
+
+      // Third item
+      // The third item should be disabled, and have a date, and bottom content.
+      assert.isTrue(!!items[2].disabled);
+      assert.isTrue(mobileItems[2].disabled);
+      assert.isFalse(items[2].classList.contains('iron-selected'));
+      assert.isFalse(mobileItems[2].selected);
+
+      assert.isOk(dom(items[2]).querySelector('gr-date-formatter'));
+      assert.isOk(dom(items[2]).querySelector('.bottomContent'));
+      assert.equal(items[2].dataset.value, element.items[2].value);
+      assert.equal(mobileItems[2].value, element.items[2].value);
+      assert.equal(dom(items[2]).querySelector('.topContent div')
+          .innerText, element.items[2].text);
+
+      // Since there is mobile specific text, it should that.
+      assert.equal(mobileItems[2].text, element.items[2].mobileText);
+
+      // Select a new item.
+      MockInteractions.tap(items[0]);
+      flushAsynchronousOperations();
+      assert.equal(element.value, 1);
+      assert.isTrue(items[0].classList.contains('iron-selected'));
+      assert.isTrue(mobileItems[0].selected);
+
+      // Since no triggerText, the fallback is used.
+      assert.equal(element.text, element.items[0].text);
+      done();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index 12a2025..0f3d566 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import '../../../scripts/bundled-polymer.js';
 import '@polymer/iron-dropdown/iron-dropdown.js';
 import '../gr-button/gr-button.js';
 import '../gr-cursor-manager/gr-cursor-manager.js';
@@ -23,26 +21,21 @@
 import '../gr-tooltip-content/gr-tooltip-content.js';
 import '../../../styles/shared-styles.js';
 import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-dropdown_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 
 const REL_NOOPENER = 'noopener';
 const REL_EXTERNAL = 'external';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrDropdown extends mixinBehaviors( [
-  BaseUrlBehavior,
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrDropdown extends KeyboardShortcutMixin(GestureEventListeners(
+    LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-dropdown'; }
@@ -184,7 +177,7 @@
   }
 
   /**
-   * Hanlde a click on the button to open the dropdown.
+   * Handle a click on the button to open the dropdown.
    *
    * @param {!Event} e
    */
@@ -235,8 +228,8 @@
    * @return {!string} The scheme-relative URL.
    */
   _computeURLHelper(host, path) {
-    const base = path.startsWith(this.getBaseUrl()) ?
-      '' : this.getBaseUrl();
+    const base = path.startsWith(getBaseUrl()) ?
+      '' : getBaseUrl();
     return '//' + host + base + path;
   }
 
@@ -294,7 +287,8 @@
     const item = this.items.find(item => item.id === id);
     if (id && !this.disabledIds.includes(id)) {
       if (item) {
-        this.dispatchEvent(new CustomEvent('tap-item', {detail: item}));
+        this.dispatchEvent(new CustomEvent('tap-item',
+            {detail: item, bubbles: true, composed: true}));
       }
       this.dispatchEvent(new CustomEvent('tap-item-' + id));
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.js
deleted file mode 100644
index d0b0d09..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.js
+++ /dev/null
@@ -1,169 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: inline-block;
-    }
-    .dropdown-trigger {
-      text-decoration: none;
-      width: 100%;
-    }
-    .dropdown-content {
-      background-color: var(--dropdown-background-color);
-      box-shadow: var(--elevation-level-2);
-    }
-    gr-button {
-      @apply --gr-button;
-    }
-    gr-avatar {
-      height: 2em;
-      width: 2em;
-      vertical-align: middle;
-    }
-    gr-button[link]:focus {
-      outline: 5px auto -webkit-focus-ring-color;
-    }
-    ul {
-      list-style: none;
-    }
-    .topContent,
-    li {
-      border-bottom: 1px solid var(--border-color);
-    }
-    li:last-of-type {
-      border: none;
-    }
-    li .itemAction {
-      cursor: pointer;
-      display: block;
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    li .itemAction {
-      @apply --gr-dropdown-item;
-    }
-    li .itemAction.disabled {
-      color: var(--deemphasized-text-color);
-      cursor: default;
-    }
-    li .itemAction:link,
-    li .itemAction:visited {
-      text-decoration: none;
-    }
-    li .itemAction:not(.disabled):hover {
-      background-color: var(--hover-background-color);
-    }
-    li:focus,
-    li.selected {
-      background-color: var(--selection-background-color);
-      outline: none;
-    }
-    li:focus .itemAction,
-    li.selected .itemAction {
-      background-color: transparent;
-    }
-    .topContent {
-      display: block;
-      padding: var(--spacing-m) var(--spacing-l);
-      @apply --gr-dropdown-item;
-    }
-    .bold-text {
-      font-weight: var(--font-weight-bold);
-    }
-  </style>
-  <gr-button
-    link="[[link]]"
-    class="dropdown-trigger"
-    id="trigger"
-    down-arrow="[[downArrow]]"
-    on-click="_dropdownTriggerTapHandler"
-  >
-    <slot></slot>
-  </gr-button>
-  <iron-dropdown
-    id="dropdown"
-    vertical-align="top"
-    vertical-offset="[[verticalOffset]]"
-    allow-outside-scroll="true"
-    horizontal-align="[[horizontalAlign]]"
-    on-click="_handleDropdownClick"
-  >
-    <div class="dropdown-content" slot="dropdown-content">
-      <ul>
-        <template is="dom-if" if="[[topContent]]">
-          <div class="topContent">
-            <template
-              is="dom-repeat"
-              items="[[topContent]]"
-              as="item"
-              initial-count="75"
-            >
-              <div
-                class$="[[_getClassIfBold(item.bold)]] top-item"
-                tabindex="-1"
-              >
-                [[item.text]]
-              </div>
-            </template>
-          </div>
-        </template>
-        <template
-          is="dom-repeat"
-          items="[[items]]"
-          as="link"
-          initial-count="75"
-        >
-          <li tabindex="-1">
-            <gr-tooltip-content
-              has-tooltip="[[_computeHasTooltip(link.tooltip)]]"
-              title$="[[link.tooltip]]"
-            >
-              <span
-                class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
-                data-id$="[[link.id]]"
-                on-click="_handleItemTap"
-                hidden$="[[link.url]]"
-                tabindex="-1"
-                >[[link.name]]</span
-              >
-              <a
-                class="itemAction"
-                href$="[[_computeLinkURL(link)]]"
-                download$="[[_computeIsDownload(link)]]"
-                rel$="[[_computeLinkRel(link)]]"
-                target$="[[link.target]]"
-                hidden$="[[!link.url]]"
-                tabindex="-1"
-                >[[link.name]]</a
-              >
-            </gr-tooltip-content>
-          </li>
-        </template>
-      </ul>
-    </div>
-  </iron-dropdown>
-  <gr-cursor-manager
-    id="cursor"
-    cursor-target-class="selected"
-    scroll-behavior="never"
-    focus-on-move=""
-    stops="[[_listElements]]"
-  ></gr-cursor-manager>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts
new file mode 100644
index 0000000..8ea0d21
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts
@@ -0,0 +1,169 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: inline-block;
+    }
+    .dropdown-trigger {
+      text-decoration: none;
+      width: 100%;
+    }
+    .dropdown-content {
+      background-color: var(--dropdown-background-color);
+      box-shadow: var(--elevation-level-2);
+    }
+    gr-button {
+      @apply --gr-button;
+    }
+    gr-avatar {
+      height: 2em;
+      width: 2em;
+      vertical-align: middle;
+    }
+    gr-button[link]:focus {
+      outline: 5px auto -webkit-focus-ring-color;
+    }
+    ul {
+      list-style: none;
+    }
+    .topContent,
+    li {
+      border-bottom: 1px solid var(--border-color);
+    }
+    li:last-of-type {
+      border: none;
+    }
+    li .itemAction {
+      cursor: pointer;
+      display: block;
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    li .itemAction {
+      @apply --gr-dropdown-item;
+    }
+    li .itemAction.disabled {
+      color: var(--deemphasized-text-color);
+      cursor: default;
+    }
+    li .itemAction:link,
+    li .itemAction:visited {
+      text-decoration: none;
+    }
+    li .itemAction:not(.disabled):hover {
+      background-color: var(--hover-background-color);
+    }
+    li:focus,
+    li.selected {
+      background-color: var(--selection-background-color);
+      outline: none;
+    }
+    li:focus .itemAction,
+    li.selected .itemAction {
+      background-color: transparent;
+    }
+    .topContent {
+      display: block;
+      padding: var(--spacing-m) var(--spacing-l);
+      @apply --gr-dropdown-item;
+    }
+    .bold-text {
+      font-weight: var(--font-weight-bold);
+    }
+  </style>
+  <gr-button
+    link="[[link]]"
+    class="dropdown-trigger"
+    id="trigger"
+    down-arrow="[[downArrow]]"
+    on-click="_dropdownTriggerTapHandler"
+  >
+    <slot></slot>
+  </gr-button>
+  <iron-dropdown
+    id="dropdown"
+    vertical-align="top"
+    vertical-offset="[[verticalOffset]]"
+    allow-outside-scroll="true"
+    horizontal-align="[[horizontalAlign]]"
+    on-click="_handleDropdownClick"
+  >
+    <div class="dropdown-content" slot="dropdown-content">
+      <ul>
+        <template is="dom-if" if="[[topContent]]">
+          <div class="topContent">
+            <template
+              is="dom-repeat"
+              items="[[topContent]]"
+              as="item"
+              initial-count="75"
+            >
+              <div
+                class$="[[_getClassIfBold(item.bold)]] top-item"
+                tabindex="-1"
+              >
+                [[item.text]]
+              </div>
+            </template>
+          </div>
+        </template>
+        <template
+          is="dom-repeat"
+          items="[[items]]"
+          as="link"
+          initial-count="75"
+        >
+          <li tabindex="-1">
+            <gr-tooltip-content
+              has-tooltip="[[_computeHasTooltip(link.tooltip)]]"
+              title$="[[link.tooltip]]"
+            >
+              <span
+                class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
+                data-id$="[[link.id]]"
+                on-click="_handleItemTap"
+                hidden$="[[link.url]]"
+                tabindex="-1"
+                >[[link.name]]</span
+              >
+              <a
+                class="itemAction"
+                href$="[[_computeLinkURL(link)]]"
+                download$="[[_computeIsDownload(link)]]"
+                rel$="[[_computeLinkRel(link)]]"
+                target$="[[link.target]]"
+                hidden$="[[!link.url]]"
+                tabindex="-1"
+                >[[link.name]]</a
+              >
+            </gr-tooltip-content>
+          </li>
+        </template>
+      </ul>
+    </div>
+  </iron-dropdown>
+  <gr-cursor-manager
+    id="cursor"
+    cursor-target-class="selected"
+    scroll-mode="never"
+    focus-on-move=""
+    stops="[[_listElements]]"
+  ></gr-cursor-manager>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
deleted file mode 100644
index d17cc1a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ /dev/null
@@ -1,208 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-dropdown</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-dropdown></gr-dropdown>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-dropdown.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-dropdown tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_computeIsDownload', () => {
-    assert.isTrue(element._computeIsDownload({download: true}));
-    assert.isFalse(element._computeIsDownload({download: false}));
-  });
-
-  test('tap on trigger opens menu, then closes', () => {
-    sandbox.stub(element, '_open', () => { element.$.dropdown.open(); });
-    sandbox.stub(element, '_close', () => { element.$.dropdown.close(); });
-    assert.isFalse(element.$.dropdown.opened);
-    MockInteractions.tap(element.$.trigger);
-    assert.isTrue(element.$.dropdown.opened);
-    MockInteractions.tap(element.$.trigger);
-    assert.isFalse(element.$.dropdown.opened);
-  });
-
-  test('_computeURLHelper', () => {
-    const path = '/test';
-    const host = 'http://www.testsite.com';
-    const computedPath = element._computeURLHelper(host, path);
-    assert.equal(computedPath, '//http://www.testsite.com/test');
-  });
-
-  test('link URLs', () => {
-    assert.equal(
-        element._computeLinkURL({url: 'http://example.com/test'}),
-        'http://example.com/test');
-    assert.equal(
-        element._computeLinkURL({url: 'https://example.com/test'}),
-        'https://example.com/test');
-    assert.equal(
-        element._computeLinkURL({url: '/test'}),
-        '//' + window.location.host + '/test');
-    assert.equal(
-        element._computeLinkURL({url: '/test', target: '_blank'}),
-        '/test');
-  });
-
-  test('link rel', () => {
-    let link = {url: '/test'};
-    assert.isNull(element._computeLinkRel(link));
-
-    link = {url: '/test', target: '_blank'};
-    assert.equal(element._computeLinkRel(link), 'noopener');
-
-    link = {url: '/test', external: true};
-    assert.equal(element._computeLinkRel(link), 'external');
-
-    link = {url: '/test', target: '_blank', external: true};
-    assert.equal(element._computeLinkRel(link), 'noopener');
-  });
-
-  test('_getClassIfBold', () => {
-    let bold = true;
-    assert.equal(element._getClassIfBold(bold), 'bold-text');
-
-    bold = false;
-    assert.equal(element._getClassIfBold(bold), '');
-  });
-
-  test('Top text exists and is bolded correctly', () => {
-    element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
-    flushAsynchronousOperations();
-    const topItems = dom(element.root).querySelectorAll('.top-item');
-    assert.equal(topItems.length, 2);
-    assert.isTrue(topItems[0].classList.contains('bold-text'));
-    assert.isFalse(topItems[1].classList.contains('bold-text'));
-  });
-
-  test('non link items', () => {
-    const item0 = {name: 'item one', id: 'foo'};
-    element.items = [item0, {name: 'item two', id: 'bar'}];
-    const fooTapped = sandbox.stub();
-    const tapped = sandbox.stub();
-    element.addEventListener('tap-item-foo', fooTapped);
-    element.addEventListener('tap-item', tapped);
-    flushAsynchronousOperations();
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.itemAction'));
-    assert.isTrue(fooTapped.called);
-    assert.isTrue(tapped.called);
-    assert.deepEqual(tapped.lastCall.args[0].detail, item0);
-  });
-
-  test('disabled non link item', () => {
-    element.items = [{name: 'item one', id: 'foo'}];
-    element.disabledIds = ['foo'];
-
-    const stub = sandbox.stub();
-    const tapped = sandbox.stub();
-    element.addEventListener('tap-item-foo', stub);
-    element.addEventListener('tap-item', tapped);
-    flushAsynchronousOperations();
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.itemAction'));
-    assert.isFalse(stub.called);
-    assert.isFalse(tapped.called);
-  });
-
-  test('properly sets tooltips', () => {
-    element.items = [
-      {name: 'item one', id: 'foo', tooltip: 'hello'},
-      {name: 'item two', id: 'bar'},
-    ];
-    element.disabledIds = [];
-    flushAsynchronousOperations();
-    const tooltipContents = dom(element.root)
-        .querySelectorAll('iron-dropdown li gr-tooltip-content');
-    assert.equal(tooltipContents.length, 2);
-    assert.isTrue(tooltipContents[0].hasTooltip);
-    assert.equal(tooltipContents[0].getAttribute('title'), 'hello');
-    assert.isFalse(tooltipContents[1].hasTooltip);
-  });
-
-  suite('keyboard navigation', () => {
-    setup(() => {
-      element.items = [
-        {name: 'item one', id: 'foo'},
-        {name: 'item two', id: 'bar'},
-      ];
-      flushAsynchronousOperations();
-    });
-
-    test('down', () => {
-      const stub = sandbox.stub(element.$.cursor, 'next');
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 40);
-      assert.isTrue(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 40);
-      assert.isTrue(stub.called);
-    });
-
-    test('up', () => {
-      const stub = sandbox.stub(element.$.cursor, 'previous');
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 38);
-      assert.isTrue(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 38);
-      assert.isTrue(stub.called);
-    });
-
-    test('enter/space', () => {
-      // Because enter and space are handled by the same fn, we need only to
-      // test one.
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
-      assert.isTrue(element.$.dropdown.opened);
-
-      const el = element.$.cursor.target.querySelector(':not([hidden]) a');
-      const stub = sandbox.stub(el, 'click');
-      MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
-      assert.isTrue(stub.called);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
new file mode 100644
index 0000000..d1d9164
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
@@ -0,0 +1,190 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-dropdown.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
+const basicFixture = fixtureFromElement('gr-dropdown');
+
+suite('gr-dropdown tests', () => {
+  let element;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  test('_computeIsDownload', () => {
+    assert.isTrue(element._computeIsDownload({download: true}));
+    assert.isFalse(element._computeIsDownload({download: false}));
+  });
+
+  test('tap on trigger opens menu, then closes', () => {
+    sinon.stub(element, '_open')
+        .callsFake(() => { element.$.dropdown.open(); });
+    sinon.stub(element, '_close')
+        .callsFake(() => { element.$.dropdown.close(); });
+    assert.isFalse(element.$.dropdown.opened);
+    MockInteractions.tap(element.$.trigger);
+    assert.isTrue(element.$.dropdown.opened);
+    MockInteractions.tap(element.$.trigger);
+    assert.isFalse(element.$.dropdown.opened);
+  });
+
+  test('_computeURLHelper', () => {
+    const path = '/test';
+    const host = 'http://www.testsite.com';
+    const computedPath = element._computeURLHelper(host, path);
+    assert.equal(computedPath, '//http://www.testsite.com/test');
+  });
+
+  test('link URLs', () => {
+    assert.equal(
+        element._computeLinkURL({url: 'http://example.com/test'}),
+        'http://example.com/test');
+    assert.equal(
+        element._computeLinkURL({url: 'https://example.com/test'}),
+        'https://example.com/test');
+    assert.equal(
+        element._computeLinkURL({url: '/test'}),
+        '//' + window.location.host + '/test');
+    assert.equal(
+        element._computeLinkURL({url: '/test', target: '_blank'}),
+        '/test');
+  });
+
+  test('link rel', () => {
+    let link = {url: '/test'};
+    assert.isNull(element._computeLinkRel(link));
+
+    link = {url: '/test', target: '_blank'};
+    assert.equal(element._computeLinkRel(link), 'noopener');
+
+    link = {url: '/test', external: true};
+    assert.equal(element._computeLinkRel(link), 'external');
+
+    link = {url: '/test', target: '_blank', external: true};
+    assert.equal(element._computeLinkRel(link), 'noopener');
+  });
+
+  test('_getClassIfBold', () => {
+    let bold = true;
+    assert.equal(element._getClassIfBold(bold), 'bold-text');
+
+    bold = false;
+    assert.equal(element._getClassIfBold(bold), '');
+  });
+
+  test('Top text exists and is bolded correctly', () => {
+    element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
+    flushAsynchronousOperations();
+    const topItems = dom(element.root).querySelectorAll('.top-item');
+    assert.equal(topItems.length, 2);
+    assert.isTrue(topItems[0].classList.contains('bold-text'));
+    assert.isFalse(topItems[1].classList.contains('bold-text'));
+  });
+
+  test('non link items', () => {
+    const item0 = {name: 'item one', id: 'foo'};
+    element.items = [item0, {name: 'item two', id: 'bar'}];
+    const fooTapped = sinon.stub();
+    const tapped = sinon.stub();
+    element.addEventListener('tap-item-foo', fooTapped);
+    element.addEventListener('tap-item', tapped);
+    flushAsynchronousOperations();
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.itemAction'));
+    assert.isTrue(fooTapped.called);
+    assert.isTrue(tapped.called);
+    assert.deepEqual(tapped.lastCall.args[0].detail, item0);
+  });
+
+  test('disabled non link item', () => {
+    element.items = [{name: 'item one', id: 'foo'}];
+    element.disabledIds = ['foo'];
+
+    const stub = sinon.stub();
+    const tapped = sinon.stub();
+    element.addEventListener('tap-item-foo', stub);
+    element.addEventListener('tap-item', tapped);
+    flushAsynchronousOperations();
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('.itemAction'));
+    assert.isFalse(stub.called);
+    assert.isFalse(tapped.called);
+  });
+
+  test('properly sets tooltips', () => {
+    element.items = [
+      {name: 'item one', id: 'foo', tooltip: 'hello'},
+      {name: 'item two', id: 'bar'},
+    ];
+    element.disabledIds = [];
+    flushAsynchronousOperations();
+    const tooltipContents = dom(element.root)
+        .querySelectorAll('iron-dropdown li gr-tooltip-content');
+    assert.equal(tooltipContents.length, 2);
+    assert.isTrue(tooltipContents[0].hasTooltip);
+    assert.equal(tooltipContents[0].getAttribute('title'), 'hello');
+    assert.isFalse(tooltipContents[1].hasTooltip);
+  });
+
+  suite('keyboard navigation', () => {
+    setup(() => {
+      element.items = [
+        {name: 'item one', id: 'foo'},
+        {name: 'item two', id: 'bar'},
+      ];
+      flushAsynchronousOperations();
+    });
+
+    test('down', () => {
+      const stub = sinon.stub(element.$.cursor, 'next');
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      assert.isTrue(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      assert.isTrue(stub.called);
+    });
+
+    test('up', () => {
+      const stub = sinon.stub(element.$.cursor, 'previous');
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      assert.isTrue(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      assert.isTrue(stub.called);
+    });
+
+    test('enter/space', () => {
+      // Because enter and space are handled by the same fn, we need only to
+      // test one.
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+      assert.isTrue(element.$.dropdown.opened);
+
+      const el = element.$.cursor.target.querySelector(':not([hidden]) a');
+      const stub = sinon.stub(el, 'click');
+      MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+      assert.isTrue(stub.called);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
index 804eb16..b2bcda9 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 import '../../../styles/shared-styles.js';
 import '../gr-storage/gr-storage.js';
@@ -29,7 +27,7 @@
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrEditableContent extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.js
deleted file mode 100644
index 24eb0b0..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.js
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) iron-autogrow-textarea {
-      opacity: 0.5;
-    }
-    .viewer {
-      background-color: var(--view-background-color);
-      border: 1px solid var(--view-background-color);
-      border-radius: var(--border-radius);
-      box-shadow: var(--elevation-level-1);
-      padding: var(--spacing-m);
-    }
-    :host([collapsed]) .viewer {
-      max-height: 36em;
-      overflow: hidden;
-    }
-    .editor iron-autogrow-textarea {
-      background-color: var(--view-background-color);
-      width: 100%;
-
-      /* You have to also repeat everything from shared-styles here, because
-           you can only *replace* --iron-autogrow-textarea vars as a whole. */
-      --iron-autogrow-textarea: {
-        box-sizing: border-box;
-        padding: var(--spacing-m);
-        overflow-y: hidden;
-        white-space: pre;
-      }
-    }
-    .editButtons {
-      display: flex;
-      justify-content: space-between;
-    }
-  </style>
-  <div class="viewer" hidden$="[[editing]]">
-    <slot></slot>
-  </div>
-  <div class="editor" hidden$="[[!editing]]">
-    <iron-autogrow-textarea
-      autocomplete="on"
-      bind-value="{{_newContent}}"
-      disabled="[[disabled]]"
-    ></iron-autogrow-textarea>
-    <div class="editButtons">
-      <gr-button primary="" on-click="_handleSave" disabled="[[_saveDisabled]]"
-        >Save</gr-button
-      >
-      <gr-button on-click="_handleCancel" disabled="[[disabled]]"
-        >Cancel</gr-button
-      >
-    </div>
-  </div>
-  <gr-storage id="storage"></gr-storage>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
new file mode 100644
index 0000000..81a2c2f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([disabled]) iron-autogrow-textarea {
+      opacity: 0.5;
+    }
+    .viewer {
+      background-color: var(--view-background-color);
+      border: 1px solid var(--view-background-color);
+      border-radius: var(--border-radius);
+      box-shadow: var(--elevation-level-1);
+      padding: var(--spacing-m);
+    }
+    :host([collapsed]) .viewer {
+      max-height: 36em;
+      overflow: hidden;
+    }
+    .editor iron-autogrow-textarea {
+      background-color: var(--view-background-color);
+      width: 100%;
+
+      /* You have to also repeat everything from shared-styles here, because
+           you can only *replace* --iron-autogrow-textarea vars as a whole. */
+      --iron-autogrow-textarea: {
+        box-sizing: border-box;
+        padding: var(--spacing-m);
+        overflow-y: hidden;
+        white-space: pre;
+      }
+    }
+    .editButtons {
+      display: flex;
+      justify-content: space-between;
+    }
+  </style>
+  <div class="viewer" hidden$="[[editing]]">
+    <slot></slot>
+  </div>
+  <div class="editor" hidden$="[[!editing]]">
+    <iron-autogrow-textarea
+      autocomplete="on"
+      bind-value="{{_newContent}}"
+      disabled="[[disabled]]"
+    ></iron-autogrow-textarea>
+    <div class="editButtons">
+      <gr-button primary="" on-click="_handleSave" disabled="[[_saveDisabled]]"
+        >Save</gr-button
+      >
+      <gr-button on-click="_handleCancel" disabled="[[disabled]]"
+        >Cancel</gr-button
+      >
+    </div>
+  </div>
+  <gr-storage id="storage"></gr-storage>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
deleted file mode 100644
index c50920e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
+++ /dev/null
@@ -1,166 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-editable-content</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<!-- Can't use absolute path below for mock-interaction.js.
-Web component tester(wct) has a built-in http server and it serves "/components" directory (which is
-actually /node_modules directory). Also, wct patches some files to load modules from /components.
-With the absolute path, browser tries to load dom-module from 2 different places (/component/... and
-/node_modules/...) though this is actually the same file. This leads to a run-time error.
--->
-<script src="../../../node_modules/iron-test-helpers/mock-interactions.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-editable-content></gr-editable-content>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-editable-content.js';
-suite('gr-editable-content tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('save event', done => {
-    element.content = '';
-    element._newContent = 'foo';
-    element.addEventListener('editable-content-save', e => {
-      assert.equal(e.detail.content, 'foo');
-      done();
-    });
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button[primary]'));
-  });
-
-  test('cancel event', done => {
-    element.addEventListener('editable-content-cancel', () => {
-      done();
-    });
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button:not([primary])'));
-  });
-
-  test('enabling editing keeps old content', () => {
-    element.content = 'current content';
-    element._newContent = 'old content';
-    element.editing = true;
-    assert.equal(element._newContent, 'old content');
-  });
-
-  test('disabling editing does not update edit field contents', () => {
-    element.content = 'current content';
-    element.editing = true;
-    element._newContent = 'stale content';
-    element.editing = false;
-    assert.equal(element._newContent, 'stale content');
-  });
-
-  test('zero width spaces are removed properly', () => {
-    element.removeZeroWidthSpace = true;
-    element.content = 'R=\u200Btest@google.com';
-    element.editing = true;
-    assert.equal(element._newContent, 'R=test@google.com');
-  });
-
-  suite('editing', () => {
-    setup(() => {
-      element.content = 'current content';
-      element.editing = true;
-    });
-
-    test('save button is disabled initially', () => {
-      assert.isTrue(element.shadowRoot
-          .querySelector('gr-button[primary]').disabled);
-    });
-
-    test('save button is enabled when content changes', () => {
-      element._newContent = 'new content';
-      assert.isFalse(element.shadowRoot
-          .querySelector('gr-button[primary]').disabled);
-    });
-  });
-
-  suite('storageKey and related behavior', () => {
-    let dispatchSpy;
-    setup(() => {
-      element.content = 'current content';
-      element.storageKey = 'test';
-      dispatchSpy = sandbox.spy(element, 'dispatchEvent');
-    });
-
-    test('editing toggled to true, has stored data', () => {
-      sandbox.stub(element.$.storage, 'getEditableContentItem')
-          .returns({message: 'stored content'});
-      element.editing = true;
-
-      assert.equal(element._newContent, 'stored content');
-      assert.isTrue(dispatchSpy.called);
-      assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
-    });
-
-    test('editing toggled to true, has no stored data', () => {
-      sandbox.stub(element.$.storage, 'getEditableContentItem')
-          .returns({});
-      element.editing = true;
-
-      assert.equal(element._newContent, 'current content');
-      assert.isFalse(dispatchSpy.called);
-    });
-
-    test('edits are cached', () => {
-      const storeStub =
-          sandbox.stub(element.$.storage, 'setEditableContentItem');
-      const eraseStub =
-          sandbox.stub(element.$.storage, 'eraseEditableContentItem');
-      element.editing = true;
-
-      element._newContent = 'new content';
-      flushAsynchronousOperations();
-      element.flushDebouncer('store');
-
-      assert.isTrue(storeStub.called);
-      assert.deepEqual(
-          [element.storageKey, element._newContent],
-          storeStub.lastCall.args);
-
-      element._newContent = '';
-      flushAsynchronousOperations();
-      element.flushDebouncer('store');
-
-      assert.isTrue(eraseStub.called);
-      assert.deepEqual([element.storageKey], eraseStub.lastCall.args);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
new file mode 100644
index 0000000..0a9dd79
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
@@ -0,0 +1,141 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-editable-content.js';
+
+const basicFixture = fixtureFromElement('gr-editable-content');
+
+suite('gr-editable-content tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('save event', done => {
+    element.content = '';
+    element._newContent = 'foo';
+    element.addEventListener('editable-content-save', e => {
+      assert.equal(e.detail.content, 'foo');
+      done();
+    });
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button[primary]'));
+  });
+
+  test('cancel event', done => {
+    element.addEventListener('editable-content-cancel', () => {
+      done();
+    });
+    MockInteractions.tap(element.shadowRoot
+        .querySelector('gr-button:not([primary])'));
+  });
+
+  test('enabling editing keeps old content', () => {
+    element.content = 'current content';
+    element._newContent = 'old content';
+    element.editing = true;
+    assert.equal(element._newContent, 'old content');
+  });
+
+  test('disabling editing does not update edit field contents', () => {
+    element.content = 'current content';
+    element.editing = true;
+    element._newContent = 'stale content';
+    element.editing = false;
+    assert.equal(element._newContent, 'stale content');
+  });
+
+  test('zero width spaces are removed properly', () => {
+    element.removeZeroWidthSpace = true;
+    element.content = 'R=\u200Btest@google.com';
+    element.editing = true;
+    assert.equal(element._newContent, 'R=test@google.com');
+  });
+
+  suite('editing', () => {
+    setup(() => {
+      element.content = 'current content';
+      element.editing = true;
+    });
+
+    test('save button is disabled initially', () => {
+      assert.isTrue(element.shadowRoot
+          .querySelector('gr-button[primary]').disabled);
+    });
+
+    test('save button is enabled when content changes', () => {
+      element._newContent = 'new content';
+      assert.isFalse(element.shadowRoot
+          .querySelector('gr-button[primary]').disabled);
+    });
+  });
+
+  suite('storageKey and related behavior', () => {
+    let dispatchSpy;
+    setup(() => {
+      element.content = 'current content';
+      element.storageKey = 'test';
+      dispatchSpy = sinon.spy(element, 'dispatchEvent');
+    });
+
+    test('editing toggled to true, has stored data', () => {
+      sinon.stub(element.$.storage, 'getEditableContentItem')
+          .returns({message: 'stored content'});
+      element.editing = true;
+
+      assert.equal(element._newContent, 'stored content');
+      assert.isTrue(dispatchSpy.called);
+      assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
+    });
+
+    test('editing toggled to true, has no stored data', () => {
+      sinon.stub(element.$.storage, 'getEditableContentItem')
+          .returns({});
+      element.editing = true;
+
+      assert.equal(element._newContent, 'current content');
+      assert.isFalse(dispatchSpy.called);
+    });
+
+    test('edits are cached', () => {
+      const storeStub =
+          sinon.stub(element.$.storage, 'setEditableContentItem');
+      const eraseStub =
+          sinon.stub(element.$.storage, 'eraseEditableContentItem');
+      element.editing = true;
+
+      element._newContent = 'new content';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isTrue(storeStub.called);
+      assert.deepEqual(
+          [element.storageKey, element._newContent],
+          storeStub.lastCall.args);
+
+      element._newContent = '';
+      flushAsynchronousOperations();
+      element.flushDebouncer('store');
+
+      assert.isTrue(eraseStub.called);
+      assert.deepEqual([element.storageKey], eraseStub.lastCall.args);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
index 8669f03..a323528 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
@@ -14,32 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import {IronOverlayBehaviorImpl} from '@polymer/iron-overlay-behavior/iron-overlay-behavior.js';
 import '@polymer/iron-dropdown/iron-dropdown.js';
 import '@polymer/paper-input/paper-input.js';
 import '../../../styles/shared-styles.js';
 import '../gr-button/gr-button.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-editable-label_html.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrEditableLabel extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrEditableLabel extends KeyboardShortcutMixin(GestureEventListeners(
+    LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-editable-label'; }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.js
deleted file mode 100644
index a226e30..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      align-items: center;
-      display: inline-flex;
-    }
-    :host([uppercase]) label {
-      text-transform: uppercase;
-    }
-    input,
-    label {
-      width: 100%;
-    }
-    label {
-      color: var(--deemphasized-text-color);
-      display: inline-block;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-      @apply --label-style;
-    }
-    label.editable {
-      color: var(--link-color);
-      cursor: pointer;
-    }
-    #dropdown {
-      box-shadow: var(--elevation-level-2);
-    }
-    .inputContainer {
-      background-color: var(--dialog-background-color);
-      padding: var(--spacing-m);
-      @apply --input-style;
-    }
-    .buttons {
-      display: flex;
-      justify-content: flex-end;
-      padding-top: var(--spacing-l);
-      width: 100%;
-    }
-    .buttons gr-button {
-      margin-left: var(--spacing-m);
-    }
-    paper-input {
-      --paper-input-container: {
-        padding: 0;
-        min-width: 15em;
-      }
-      --paper-input-container-input: {
-        font-size: inherit;
-      }
-      --paper-input-container-focus-color: var(--link-color);
-    }
-  </style>
-  <label
-    class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
-    title$="[[_computeLabel(value, placeholder)]]"
-    on-click="_showDropdown"
-    >[[_computeLabel(value, placeholder)]]</label
-  >
-  <iron-dropdown
-    id="dropdown"
-    vertical-align="auto"
-    horizontal-align="auto"
-    vertical-offset="[[_verticalOffset]]"
-    allow-outside-scroll="true"
-    on-iron-overlay-canceled="_cancel"
-  >
-    <div class="dropdown-content" slot="dropdown-content">
-      <div class="inputContainer">
-        <paper-input
-          id="input"
-          label="[[labelText]]"
-          maxlength="[[maxLength]]"
-          value="{{_inputText}}"
-        ></paper-input>
-        <div class="buttons">
-          <gr-button link="" id="cancelBtn" on-click="_cancel"
-            >cancel</gr-button
-          >
-          <gr-button link="" id="saveBtn" on-click="_save">save</gr-button>
-        </div>
-      </div>
-    </div>
-  </iron-dropdown>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
new file mode 100644
index 0000000..5e36166
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      align-items: center;
+      display: inline-flex;
+    }
+    :host([uppercase]) label {
+      text-transform: uppercase;
+    }
+    input,
+    label {
+      width: 100%;
+    }
+    label {
+      color: var(--deemphasized-text-color);
+      display: inline-block;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      @apply --label-style;
+    }
+    label.editable {
+      color: var(--link-color);
+      cursor: pointer;
+    }
+    #dropdown {
+      box-shadow: var(--elevation-level-2);
+    }
+    .inputContainer {
+      background-color: var(--dialog-background-color);
+      padding: var(--spacing-m);
+      @apply --input-style;
+    }
+    .buttons {
+      display: flex;
+      justify-content: flex-end;
+      padding-top: var(--spacing-l);
+      width: 100%;
+    }
+    .buttons gr-button {
+      margin-left: var(--spacing-m);
+    }
+    paper-input {
+      --paper-input-container: {
+        padding: 0;
+        min-width: 15em;
+      }
+      --paper-input-container-input: {
+        font-size: inherit;
+      }
+      --paper-input-container-focus-color: var(--link-color);
+    }
+  </style>
+  <label
+    class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
+    title$="[[_computeLabel(value, placeholder)]]"
+    on-click="_showDropdown"
+    >[[_computeLabel(value, placeholder)]]</label
+  >
+  <iron-dropdown
+    id="dropdown"
+    vertical-align="auto"
+    horizontal-align="auto"
+    vertical-offset="[[_verticalOffset]]"
+    allow-outside-scroll="true"
+    on-iron-overlay-canceled="_cancel"
+  >
+    <div class="dropdown-content" slot="dropdown-content">
+      <div class="inputContainer">
+        <paper-input
+          id="input"
+          label="[[labelText]]"
+          maxlength="[[maxLength]]"
+          value="{{_inputText}}"
+        ></paper-input>
+        <div class="buttons">
+          <gr-button link="" id="cancelBtn" on-click="_cancel"
+            >cancel</gr-button
+          >
+          <gr-button link="" id="saveBtn" on-click="_save">save</gr-button>
+        </div>
+      </div>
+    </div>
+  </iron-dropdown>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
deleted file mode 100644
index 5673194..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
+++ /dev/null
@@ -1,253 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-editable-label</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<!-- Can't use absolute path below for mock-interaction.js.
-Web component tester(wct) has a built-in http server and it serves "/components" directory (which is
-actually /node_modules directory). Also, wct patches some files to load modules from /components.
-With the absolute path, browser tries to load dom-module from 2 different places (/component/... and
-/node_modules/...) though this is actually the same file. This leads to a run-time error.
--->
-<script src="../../../node_modules/iron-test-helpers/mock-interactions.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-editable-label
-        value="value text"
-        placeholder="label text"></gr-editable-label>
-  </template>
-</test-fixture>
-
-<test-fixture id="no-placeholder">
-  <template>
-    <gr-editable-label value=""></gr-editable-label>
-  </template>
-</test-fixture>
-
-<test-fixture id="read-only">
-  <template>
-    <gr-editable-label
-        read-only
-        value="value text"
-        placeholder="label text"></gr-editable-label>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-editable-label.js';
-import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-editable-label tests', () => {
-  let element;
-  let elementNoPlaceholder;
-  let input;
-  let label;
-  let sandbox;
-
-  setup(done => {
-    element = fixture('basic');
-    elementNoPlaceholder = fixture('no-placeholder');
-
-    label = element.shadowRoot
-        .querySelector('label');
-    sandbox = sinon.sandbox.create();
-    flush(() => {
-      // In Polymer 2 inputElement isn't nativeInput anymore
-      input = element.$.input.$.nativeInput || element.$.input.inputElement;
-      done();
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('element render', () => {
-    // The dropdown is closed and the label is visible:
-    assert.isFalse(element.$.dropdown.opened);
-    assert.isTrue(label.classList.contains('editable'));
-    assert.equal(label.textContent, 'value text');
-    const focusSpy = sandbox.spy(input, 'focus');
-    const showSpy = sandbox.spy(element, '_showDropdown');
-
-    MockInteractions.tap(label);
-
-    return showSpy.lastCall.returnValue.then(() => {
-      // The dropdown is open (which covers up the label):
-      assert.isTrue(element.$.dropdown.opened);
-      assert.isTrue(focusSpy.called);
-      assert.equal(input.value, 'value text');
-    });
-  });
-
-  test('title with placeholder', done => {
-    assert.equal(element.title, 'value text');
-    element.value = '';
-
-    element.async(() => {
-      assert.equal(element.title, 'label text');
-      done();
-    });
-  });
-
-  test('title without placeholder', done => {
-    assert.equal(elementNoPlaceholder.title, '');
-    element.value = 'value text';
-
-    element.async(() => {
-      assert.equal(element.title, 'value text');
-      done();
-    });
-  });
-
-  test('edit value', done => {
-    const editedStub = sandbox.stub();
-    element.addEventListener('changed', editedStub);
-    assert.isFalse(element.editing);
-
-    MockInteractions.tap(label);
-
-    flush$0();
-
-    assert.isTrue(element.editing);
-    element._inputText = 'new text';
-
-    assert.isFalse(editedStub.called);
-
-    element.async(() => {
-      assert.isTrue(editedStub.called);
-      assert.equal(input.value, 'new text');
-      assert.isFalse(element.editing);
-      done();
-    });
-
-    // Press enter:
-    MockInteractions.keyDownOn(input, 13);
-  });
-
-  test('save button', done => {
-    const editedStub = sandbox.stub();
-    element.addEventListener('changed', editedStub);
-    assert.isFalse(element.editing);
-
-    MockInteractions.tap(label);
-
-    flush$0();
-
-    assert.isTrue(element.editing);
-    element._inputText = 'new text';
-
-    assert.isFalse(editedStub.called);
-
-    element.async(() => {
-      assert.isTrue(editedStub.called);
-      assert.equal(input.value, 'new text');
-      assert.isFalse(element.editing);
-      done();
-    });
-
-    // Press enter:
-    MockInteractions.tap(element.$.saveBtn, 13);
-  });
-
-  test('edit and then escape key', done => {
-    const editedStub = sandbox.stub();
-    element.addEventListener('changed', editedStub);
-    assert.isFalse(element.editing);
-
-    MockInteractions.tap(label);
-
-    flush$0();
-
-    assert.isTrue(element.editing);
-    element._inputText = 'new text';
-
-    assert.isFalse(editedStub.called);
-
-    element.async(() => {
-      assert.isFalse(editedStub.called);
-      // Text changes sould be discarded.
-      assert.equal(input.value, 'value text');
-      assert.isFalse(element.editing);
-      done();
-    });
-
-    // Press escape:
-    MockInteractions.keyDownOn(input, 27);
-  });
-
-  test('cancel button', done => {
-    const editedStub = sandbox.stub();
-    element.addEventListener('changed', editedStub);
-    assert.isFalse(element.editing);
-
-    MockInteractions.tap(label);
-
-    flush$0();
-
-    assert.isTrue(element.editing);
-    element._inputText = 'new text';
-
-    assert.isFalse(editedStub.called);
-
-    element.async(() => {
-      assert.isFalse(editedStub.called);
-      // Text changes sould be discarded.
-      assert.equal(input.value, 'value text');
-      assert.isFalse(element.editing);
-      done();
-    });
-
-    // Press escape:
-    MockInteractions.tap(element.$.cancelBtn);
-  });
-
-  suite('gr-editable-label read-only tests', () => {
-    let element;
-    let label;
-
-    setup(() => {
-      element = fixture('read-only');
-      label = element.shadowRoot
-          .querySelector('label');
-    });
-
-    test('disallows edit when read-only', () => {
-      // The dropdown is closed.
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.tap(label);
-
-      flush$0();
-
-      // The dropdown is still closed.
-      assert.isFalse(element.$.dropdown.opened);
-    });
-
-    test('label is not marked as editable', () => {
-      assert.isFalse(label.classList.contains('editable'));
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
new file mode 100644
index 0000000..8c04aed
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
@@ -0,0 +1,226 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-editable-label.js';
+import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-editable-label
+        value="value text"
+        placeholder="label text"></gr-editable-label>
+`);
+
+const noPlaceholderFixture = fixtureFromTemplate(html`
+<gr-editable-label value=""></gr-editable-label>
+`);
+
+const readOnlyFixture = fixtureFromTemplate(html`
+<gr-editable-label
+        read-only
+        value="value text"
+        placeholder="label text"></gr-editable-label>
+`);
+
+suite('gr-editable-label tests', () => {
+  let element;
+  let elementNoPlaceholder;
+  let input;
+  let label;
+
+  setup(done => {
+    element = basicFixture.instantiate();
+    elementNoPlaceholder = noPlaceholderFixture.instantiate();
+
+    label = element.shadowRoot
+        .querySelector('label');
+
+    flush(() => {
+      // In Polymer 2 inputElement isn't nativeInput anymore
+      input = element.$.input.$.nativeInput || element.$.input.inputElement;
+      done();
+    });
+  });
+
+  test('element render', () => {
+    // The dropdown is closed and the label is visible:
+    assert.isFalse(element.$.dropdown.opened);
+    assert.isTrue(label.classList.contains('editable'));
+    assert.equal(label.textContent, 'value text');
+    const focusSpy = sinon.spy(input, 'focus');
+    const showSpy = sinon.spy(element, '_showDropdown');
+
+    MockInteractions.tap(label);
+
+    return showSpy.lastCall.returnValue.then(() => {
+      // The dropdown is open (which covers up the label):
+      assert.isTrue(element.$.dropdown.opened);
+      assert.isTrue(focusSpy.called);
+      assert.equal(input.value, 'value text');
+    });
+  });
+
+  test('title with placeholder', done => {
+    assert.equal(element.title, 'value text');
+    element.value = '';
+
+    element.async(() => {
+      assert.equal(element.title, 'label text');
+      done();
+    });
+  });
+
+  test('title without placeholder', done => {
+    assert.equal(elementNoPlaceholder.title, '');
+    element.value = 'value text';
+
+    element.async(() => {
+      assert.equal(element.title, 'value text');
+      done();
+    });
+  });
+
+  test('edit value', done => {
+    const editedStub = sinon.stub();
+    element.addEventListener('changed', editedStub);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+
+    flush$0();
+
+    assert.isTrue(element.editing);
+    element._inputText = 'new text';
+
+    assert.isFalse(editedStub.called);
+
+    element.async(() => {
+      assert.isTrue(editedStub.called);
+      assert.equal(input.value, 'new text');
+      assert.isFalse(element.editing);
+      done();
+    });
+
+    // Press enter:
+    MockInteractions.keyDownOn(input, 13);
+  });
+
+  test('save button', done => {
+    const editedStub = sinon.stub();
+    element.addEventListener('changed', editedStub);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+
+    flush$0();
+
+    assert.isTrue(element.editing);
+    element._inputText = 'new text';
+
+    assert.isFalse(editedStub.called);
+
+    element.async(() => {
+      assert.isTrue(editedStub.called);
+      assert.equal(input.value, 'new text');
+      assert.isFalse(element.editing);
+      done();
+    });
+
+    // Press enter:
+    MockInteractions.tap(element.$.saveBtn, 13);
+  });
+
+  test('edit and then escape key', done => {
+    const editedStub = sinon.stub();
+    element.addEventListener('changed', editedStub);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+
+    flush$0();
+
+    assert.isTrue(element.editing);
+    element._inputText = 'new text';
+
+    assert.isFalse(editedStub.called);
+
+    element.async(() => {
+      assert.isFalse(editedStub.called);
+      // Text changes should be discarded.
+      assert.equal(input.value, 'value text');
+      assert.isFalse(element.editing);
+      done();
+    });
+
+    // Press escape:
+    MockInteractions.keyDownOn(input, 27);
+  });
+
+  test('cancel button', done => {
+    const editedStub = sinon.stub();
+    element.addEventListener('changed', editedStub);
+    assert.isFalse(element.editing);
+
+    MockInteractions.tap(label);
+
+    flush$0();
+
+    assert.isTrue(element.editing);
+    element._inputText = 'new text';
+
+    assert.isFalse(editedStub.called);
+
+    element.async(() => {
+      assert.isFalse(editedStub.called);
+      // Text changes should be discarded.
+      assert.equal(input.value, 'value text');
+      assert.isFalse(element.editing);
+      done();
+    });
+
+    // Press escape:
+    MockInteractions.tap(element.$.cancelBtn);
+  });
+
+  suite('gr-editable-label read-only tests', () => {
+    let element;
+    let label;
+
+    setup(() => {
+      element = readOnlyFixture.instantiate();
+      label = element.shadowRoot
+          .querySelector('label');
+    });
+
+    test('disallows edit when read-only', () => {
+      // The dropdown is closed.
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.tap(label);
+
+      flush$0();
+
+      // The dropdown is still closed.
+      assert.isFalse(element.$.dropdown.opened);
+    });
+
+    test('label is not marked as editable', () => {
+      assert.isFalse(label.classList.contains('editable'));
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-event-emitter/gr-event-emitter.js b/polygerrit-ui/app/elements/shared/gr-event-emitter/gr-event-emitter.js
deleted file mode 100644
index cfe4c4f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-event-emitter/gr-event-emitter.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {EventEmitter} from '../gr-event-interface/gr-event-interface.js';
-
-// TODO(dmfilippov): move to appContext
-export const gerritEventEmitter = new EventEmitter();
diff --git a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface.js b/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface.js
deleted file mode 100644
index 7705874..0000000
--- a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface.js
+++ /dev/null
@@ -1,134 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * An lite implementation of
- * https://nodejs.org/api/events.html#events_class_eventemitter.
- *
- * This is unrelated to the native DOM events, you should use it when you want
- * to enable EventEmitter interface on any class.
- *
- * @example
- *
- * class YourClass extends EventEmitter {
- *   // now all instance of YourClass will have this EventEmitter interface
- * }
- *
- */
-export class EventEmitter {
-  constructor() {
-    /**
-     * Shared events map from name to the listeners.
-     *
-     * @type {!Object<string, Array<eventCallback>>}
-     */
-    this._listenersMap = new Map();
-  }
-
-  /**
-   * Register an event listener to an event.
-   *
-   * @param {string} eventName
-   * @param {eventCallback} cb
-   * @returns {Function} Unsubscribe method
-   */
-  addListener(eventName, cb) {
-    if (!eventName || !cb) {
-      console.warn('A valid eventname and callback is required!');
-      return;
-    }
-
-    const listeners = this._listenersMap.get(eventName) || [];
-    listeners.push(cb);
-    this._listenersMap.set(eventName, listeners);
-
-    return () => {
-      this.off(eventName, cb);
-    };
-  }
-
-  // Alias for addListener.
-  on(eventName, cb) {
-    return this.addListener(eventName, cb);
-  }
-
-  // Attach event handler only once. Automatically removed.
-  once(eventName, cb) {
-    const onceWrapper = (...args) => {
-      cb(...args);
-      this.off(eventName, onceWrapper);
-    };
-    return this.on(eventName, onceWrapper);
-  }
-
-  /**
-   * De-register an event listener to an event.
-   *
-   * @param {string} eventName
-   * @param {eventCallback} cb
-   */
-  removeListener(eventName, cb) {
-    let listeners = this._listenersMap.get(eventName) || [];
-    listeners = listeners.filter(listener => listener !== cb);
-    this._listenersMap.set(eventName, listeners);
-  }
-
-  // Alias to removeListener
-  off(eventName, cb) {
-    this.removeListener(eventName, cb);
-  }
-
-  /**
-   * Synchronously calls each of the listeners registered for
-   * the event named eventName, in the order they were registered,
-   * passing the supplied detail to each.
-   *
-   * Returns true if the event had listeners, false otherwise.
-   *
-   * @param {string} eventName
-   * @param {*} detail
-   */
-  emit(eventName, detail) {
-    const listeners = this._listenersMap.get(eventName) || [];
-    for (const listener of listeners) {
-      try {
-        listener(detail);
-      } catch (e) {
-        console.error(e);
-      }
-    }
-    return listeners.length !== 0;
-  }
-
-  // Alias to emit.
-  dispatch(eventName, detail) {
-    return this.emit(eventName, detail);
-  }
-
-  /**
-   * Remove listeners for a specific event or all.
-   *
-   * @param {string} eventName if not provided, will remove all
-   */
-  removeAllListeners(eventName) {
-    if (eventName) {
-      this._listenersMap.set(eventName, []);
-    } else {
-      this._listenersMap = new Map();
-    }
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html b/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html
deleted file mode 100644
index 74936ad..0000000
--- a/polygerrit-ui/app/elements/shared/gr-event-interface/gr-event-interface_test.html
+++ /dev/null
@@ -1,152 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-api-interface</title>
-
-<script src="../../../node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-js-api-interface></gr-js-api-interface>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../gr-js-api-interface/gr-js-api-interface.js';
-import {EventEmitter} from './gr-event-interface.js';
-import {_testOnly_initGerritPluginApi} from '../gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-event-interface tests', () => {
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('test on Gerrit', () => {
-    setup(() => {
-      fixture('basic');
-      pluginApi.removeAllListeners();
-    });
-
-    test('communicate between plugin and Gerrit', done => {
-      const eventName = 'test-plugin-event';
-      let p;
-      pluginApi.on(eventName, e => {
-        assert.equal(e.value, 'test');
-        assert.equal(e.plugin, p);
-        done();
-      });
-      pluginApi.install(plugin => {
-        p = plugin;
-        pluginApi.emit(eventName, {value: 'test', plugin});
-      }, '0.1',
-      'http://test.com/plugins/testplugin/static/test.js');
-    });
-
-    test('listen on events from core', done => {
-      const eventName = 'test-plugin-event';
-      pluginApi.on(eventName, e => {
-        assert.equal(e.value, 'test');
-        done();
-      });
-
-      pluginApi.emit(eventName, {value: 'test'});
-    });
-
-    test('communicate across plugins', done => {
-      const eventName = 'test-plugin-event';
-      pluginApi.install(plugin => {
-        pluginApi.on(eventName, e => {
-          assert.equal(e.plugin.getPluginName(), 'testB');
-          done();
-        });
-      }, '0.1',
-      'http://test.com/plugins/testA/static/testA.js');
-
-      pluginApi.install(plugin => {
-        pluginApi.emit(eventName, {plugin});
-      }, '0.1',
-      'http://test.com/plugins/testB/static/testB.js');
-    });
-  });
-
-  suite('test on interfaces', () => {
-    let testObj;
-
-    class TestClass extends EventEmitter {
-    }
-
-    setup(() => {
-      testObj = new TestClass();
-    });
-
-    test('on', () => {
-      const cbStub = sinon.stub();
-      testObj.on('test', cbStub);
-      testObj.emit('test');
-      testObj.emit('test');
-      assert.isTrue(cbStub.calledTwice);
-    });
-
-    test('once', () => {
-      const cbStub = sinon.stub();
-      testObj.once('test', cbStub);
-      testObj.emit('test');
-      testObj.emit('test');
-      assert.isTrue(cbStub.calledOnce);
-    });
-
-    test('unsubscribe', () => {
-      const cbStub = sinon.stub();
-      const unsubscribe = testObj.on('test', cbStub);
-      testObj.emit('test');
-      unsubscribe();
-      testObj.emit('test');
-      assert.isTrue(cbStub.calledOnce);
-    });
-
-    test('off', () => {
-      const cbStub = sinon.stub();
-      testObj.on('test', cbStub);
-      testObj.emit('test');
-      testObj.off('test', cbStub);
-      testObj.emit('test');
-      assert.isTrue(cbStub.calledOnce);
-    });
-
-    test('removeAllListeners', () => {
-      const cbStub = sinon.stub();
-      testObj.on('test', cbStub);
-      testObj.removeAllListeners('test');
-      testObj.emit('test');
-      assert.isTrue(cbStub.notCalled);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
index 0d19f00..bc79737 100644
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
@@ -14,15 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-fixed-panel_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrFixedPanel extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.js
deleted file mode 100644
index 61e8b24..0000000
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.js
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      box-sizing: border-box;
-      display: block;
-      min-height: var(--header-height);
-      position: relative;
-    }
-    header {
-      background: inherit;
-      border: inherit;
-      display: inline;
-      height: inherit;
-    }
-    .floating {
-      left: 0;
-      position: fixed;
-      width: 100%;
-      will-change: top;
-    }
-    .fixedAtTop {
-      border-bottom: 1px solid #a4a4a4;
-      box-shadow: var(--elevation-level-2);
-    }
-  </style>
-  <header
-    id="header"
-    class$="[[_computeHeaderClass(_headerFloating, _topLast)]]"
-  >
-    <slot></slot>
-  </header>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.ts b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.ts
new file mode 100644
index 0000000..ce475c6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      box-sizing: border-box;
+      display: block;
+      min-height: var(--header-height);
+      position: relative;
+    }
+    header {
+      background: inherit;
+      border: inherit;
+      display: inline;
+      height: inherit;
+    }
+    .floating {
+      left: 0;
+      position: fixed;
+      width: 100%;
+      will-change: top;
+    }
+    .fixedAtTop {
+      border-bottom: 1px solid #a4a4a4;
+      box-shadow: var(--elevation-level-2);
+    }
+  </style>
+  <header
+    id="header"
+    class$="[[_computeHeaderClass(_headerFloating, _topLast)]]"
+  >
+    <slot></slot>
+  </header>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
deleted file mode 100644
index ef31382..0000000
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
+++ /dev/null
@@ -1,124 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-fixed-panel</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<style>
-  /* Prevent horizontal scrolling on page.
-   New version of web-component-tester creates body with margins */
-  body {
-    margin: 0px;
-    padding: 0px;
-  }
-</style>
-
-<test-fixture id="basic">
-  <template>
-    <gr-fixed-panel>
-      <div style="height: 100px"></div>
-    </gr-fixed-panel>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-fixed-panel.js';
-suite('gr-fixed-panel', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    element.readyForMeasure = true;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('can be disabled with floatingDisabled', () => {
-    element.floatingDisabled = true;
-    sandbox.stub(element, '_reposition');
-    window.dispatchEvent(new CustomEvent('resize'));
-    element.flushDebouncer('update');
-    assert.isFalse(element._reposition.called);
-  });
-
-  test('header is the height of the content', () => {
-    assert.equal(element.getBoundingClientRect().height, 100);
-  });
-
-  test('scroll triggers _reposition', () => {
-    sandbox.stub(element, '_reposition');
-    window.dispatchEvent(new CustomEvent('scroll'));
-    element.flushDebouncer('update');
-    assert.isTrue(element._reposition.called);
-  });
-
-  suite('_reposition', () => {
-    const getHeaderTop = function() {
-      return element.$.header.style.top;
-    };
-
-    const emulateScrollY = function(distance) {
-      element._getElementTop.returns(element._headerTopInitial - distance);
-      element._updateDebounced();
-      element.flushDebouncer('scroll');
-    };
-
-    setup(() => {
-      element._headerTopInitial = 10;
-      sandbox.stub(element, '_getElementTop')
-          .returns(element._headerTopInitial);
-    });
-
-    test('scrolls header along with document', () => {
-      emulateScrollY(20);
-      // No top property is set when !_headerFloating.
-      assert.equal(getHeaderTop(), '');
-    });
-
-    test('does not stick to the top by default', () => {
-      emulateScrollY(150);
-      // No top property is set when !_headerFloating.
-      assert.equal(getHeaderTop(), '');
-    });
-
-    test('sticks to the top if enabled', () => {
-      element.keepOnScroll = true;
-      emulateScrollY(120);
-      assert.equal(getHeaderTop(), '0px');
-    });
-
-    test('drops a shadow when fixed to the top', () => {
-      element.keepOnScroll = true;
-      emulateScrollY(5);
-      assert.isFalse(element.$.header.classList.contains('fixedAtTop'));
-      emulateScrollY(120);
-      assert.isTrue(element.$.header.classList.contains('fixedAtTop'));
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.js
new file mode 100644
index 0000000..b9378ba
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.js
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-fixed-panel.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-fixed-panel>
+      <div style="height: 100px"></div>
+    </gr-fixed-panel>
+`);
+
+suite('gr-fixed-panel', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.readyForMeasure = true;
+  });
+
+  test('can be disabled with floatingDisabled', () => {
+    element.floatingDisabled = true;
+    sinon.stub(element, '_reposition');
+    window.dispatchEvent(new CustomEvent('resize'));
+    element.flushDebouncer('update');
+    assert.isFalse(element._reposition.called);
+  });
+
+  test('header is the height of the content', () => {
+    assert.equal(element.getBoundingClientRect().height, 100);
+  });
+
+  test('scroll triggers _reposition', () => {
+    sinon.stub(element, '_reposition');
+    window.dispatchEvent(new CustomEvent('scroll'));
+    element.flushDebouncer('update');
+    assert.isTrue(element._reposition.called);
+  });
+
+  suite('_reposition', () => {
+    const getHeaderTop = function() {
+      return element.$.header.style.top;
+    };
+
+    const emulateScrollY = function(distance) {
+      element._getElementTop.returns(element._headerTopInitial - distance);
+      element._updateDebounced();
+      element.flushDebouncer('scroll');
+    };
+
+    setup(() => {
+      element._headerTopInitial = 10;
+      sinon.stub(element, '_getElementTop')
+          .returns(element._headerTopInitial);
+    });
+
+    test('scrolls header along with document', () => {
+      emulateScrollY(20);
+      // No top property is set when !_headerFloating.
+      assert.equal(getHeaderTop(), '');
+    });
+
+    test('does not stick to the top by default', () => {
+      emulateScrollY(150);
+      // No top property is set when !_headerFloating.
+      assert.equal(getHeaderTop(), '');
+    });
+
+    test('sticks to the top if enabled', () => {
+      element.keepOnScroll = true;
+      emulateScrollY(120);
+      assert.equal(getHeaderTop(), '0px');
+    });
+
+    test('drops a shadow when fixed to the top', () => {
+      element.keepOnScroll = true;
+      emulateScrollY(5);
+      assert.isFalse(element.$.header.classList.contains('fixedAtTop'));
+      emulateScrollY(120);
+      assert.isTrue(element.$.header.classList.contains('fixedAtTop'));
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
index 139e09c..862c48c 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../gr-linked-text/gr-linked-text.js';
 import '../../../styles/shared-styles.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
@@ -28,7 +26,7 @@
 const QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
 const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrFormattedText extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -85,13 +83,13 @@
 
     // Add new content.
     for (const node of this._computeNodes(this._computeBlocks(content))) {
-      container.appendChild(node);
+      if (node) container.appendChild(node);
     }
   }
 
   /**
    * Given a source string, parse into an array of block objects. Each block
-   * has a `type` property which takes any of the follwoing values.
+   * has a `type` property which takes any of the following values.
    * * 'paragraph'
    * * 'quote' (Block quote.)
    * * 'pre' (Pre-formatted text.)
@@ -278,7 +276,7 @@
       if (block.type === 'quote') {
         const bq = document.createElement('blockquote');
         for (const node of this._computeNodes(block.blocks)) {
-          bq.appendChild(node);
+          if (node) bq.appendChild(node);
         }
         return bq;
       }
@@ -302,6 +300,9 @@
         }
         return ul;
       }
+
+      console.warn('Unrecognized type.');
+      return;
     });
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js
deleted file mode 100644
index 5cb8670..0000000
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.js
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      font-family: var(--font-family);
-    }
-    p,
-    ul,
-    code,
-    blockquote,
-    gr-linked-text.pre {
-      margin: 0 0 var(--spacing-m) 0;
-    }
-    p,
-    ul,
-    code,
-    blockquote {
-      max-width: var(--gr-formatted-text-prose-max-width, none);
-    }
-    :host(.noTrailingMargin) p:last-child,
-    :host(.noTrailingMargin) ul:last-child,
-    :host(.noTrailingMargin) blockquote:last-child,
-    :host(.noTrailingMargin) gr-linked-text.pre:last-child {
-      margin: 0;
-    }
-    code,
-    blockquote {
-      border-left: 1px solid #aaa;
-      padding: 0 var(--spacing-m);
-    }
-    code {
-      display: block;
-      white-space: pre-wrap;
-      color: var(--deemphasized-text-color);
-    }
-    li {
-      list-style-type: disc;
-      margin-left: var(--spacing-xl);
-    }
-    gr-linked-text.pre {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-code);
-      line-height: var(--line-height-code);
-    }
-  </style>
-  <div id="container"></div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
new file mode 100644
index 0000000..468bbee
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      font-family: var(--font-family);
+    }
+    p,
+    ul,
+    code,
+    blockquote,
+    gr-linked-text.pre {
+      margin: 0 0 var(--spacing-m) 0;
+    }
+    p,
+    ul,
+    code,
+    blockquote {
+      max-width: var(--gr-formatted-text-prose-max-width, none);
+    }
+    :host(.noTrailingMargin) p:last-child,
+    :host(.noTrailingMargin) ul:last-child,
+    :host(.noTrailingMargin) blockquote:last-child,
+    :host(.noTrailingMargin) gr-linked-text.pre:last-child {
+      margin: 0;
+    }
+    code,
+    blockquote {
+      border-left: 1px solid #aaa;
+      padding: 0 var(--spacing-m);
+    }
+    code {
+      display: block;
+      white-space: pre-wrap;
+      color: var(--deemphasized-text-color);
+    }
+    li {
+      list-style-type: disc;
+      margin-left: var(--spacing-xl);
+    }
+    gr-linked-text.pre {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-code);
+      line-height: var(--line-height-code);
+    }
+  </style>
+  <div id="container"></div>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
deleted file mode 100644
index 083eac4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
+++ /dev/null
@@ -1,426 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-editable-label</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-formatted-text></gr-formatted-text>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-formatted-text.js';
-suite('gr-formatted-text tests', () => {
-  let element;
-  let sandbox;
-
-  function assertBlock(result, index, type, text) {
-    assert.equal(result[index].type, type);
-    assert.equal(result[index].text, text);
-  }
-
-  function assertListBlock(result, resultIndex, itemIndex, text) {
-    assert.equal(result[resultIndex].type, 'list');
-    assert.equal(result[resultIndex].items[itemIndex], text);
-  }
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('parse null undefined and empty', () => {
-    assert.lengthOf(element._computeBlocks(null), 0);
-    assert.lengthOf(element._computeBlocks(undefined), 0);
-    assert.lengthOf(element._computeBlocks(''), 0);
-  });
-
-  test('parse simple', () => {
-    const comment = 'Para1';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertBlock(result, 0, 'paragraph', comment);
-  });
-
-  test('parse multiline para', () => {
-    const comment = 'Para 1\nStill para 1';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertBlock(result, 0, 'paragraph', comment);
-  });
-
-  test('parse para break without special blocks', () => {
-    const comment = 'Para 1\n\nPara 2\n\nPara 3';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertBlock(result, 0, 'paragraph', comment);
-  });
-
-  test('parse quote', () => {
-    const comment = '> Quote text';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assert.equal(result[0].type, 'quote');
-    assert.lengthOf(result[0].blocks, 1);
-    assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
-  });
-
-  test('parse quote lead space', () => {
-    const comment = ' > Quote text';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assert.equal(result[0].type, 'quote');
-    assert.lengthOf(result[0].blocks, 1);
-    assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
-  });
-
-  test('parse multiline quote', () => {
-    const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assert.equal(result[0].type, 'quote');
-    assert.lengthOf(result[0].blocks, 1);
-    assertBlock(result[0].blocks, 0, 'paragraph',
-        'Quote line 1\nQuote line 2\nQuote line 3');
-  });
-
-  test('parse pre', () => {
-    const comment = '    Four space indent.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertBlock(result, 0, 'pre', comment);
-  });
-
-  test('parse one space pre', () => {
-    const comment = ' One space indent.\n Another line.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertBlock(result, 0, 'pre', comment);
-  });
-
-  test('parse tab pre', () => {
-    const comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertBlock(result, 0, 'pre', comment);
-  });
-
-  test('parse star list', () => {
-    const comment = '* Item 1\n* Item 2\n* Item 3';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertListBlock(result, 0, 0, 'Item 1');
-    assertListBlock(result, 0, 1, 'Item 2');
-    assertListBlock(result, 0, 2, 'Item 3');
-  });
-
-  test('parse dash list', () => {
-    const comment = '- Item 1\n- Item 2\n- Item 3';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertListBlock(result, 0, 0, 'Item 1');
-    assertListBlock(result, 0, 1, 'Item 2');
-    assertListBlock(result, 0, 2, 'Item 3');
-  });
-
-  test('parse mixed list', () => {
-    const comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertListBlock(result, 0, 0, 'Item 1');
-    assertListBlock(result, 0, 1, 'Item 2');
-    assertListBlock(result, 0, 2, 'Item 3');
-    assertListBlock(result, 0, 3, 'Item 4');
-  });
-
-  test('parse mixed block types', () => {
-    const comment = 'Paragraph\nacross\na\nfew\nlines.' +
-        '\n\n' +
-        '> Quote\n> across\n> not many lines.' +
-        '\n\n' +
-        'Another paragraph' +
-        '\n\n' +
-        '* Series\n* of\n* list\n* items' +
-        '\n\n' +
-        'Yet another paragraph' +
-        '\n\n' +
-        '\tPreformatted text.' +
-        '\n\n' +
-        'Parting words.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 7);
-    assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.\n');
-
-    assert.equal(result[1].type, 'quote');
-    assert.lengthOf(result[1].blocks, 1);
-    assertBlock(result[1].blocks, 0, 'paragraph',
-        'Quote\nacross\nnot many lines.');
-
-    assertBlock(result, 2, 'paragraph', 'Another paragraph\n');
-    assertListBlock(result, 3, 0, 'Series');
-    assertListBlock(result, 3, 1, 'of');
-    assertListBlock(result, 3, 2, 'list');
-    assertListBlock(result, 3, 3, 'items');
-    assertBlock(result, 4, 'paragraph', 'Yet another paragraph\n');
-    assertBlock(result, 5, 'pre', '\tPreformatted text.');
-    assertBlock(result, 6, 'paragraph', 'Parting words.');
-  });
-
-  test('bullet list 1', () => {
-    const comment = 'A\n\n* line 1\n* 2nd line';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertBlock(result, 0, 'paragraph', 'A\n');
-    assertListBlock(result, 1, 0, 'line 1');
-    assertListBlock(result, 1, 1, '2nd line');
-  });
-
-  test('bullet list 2', () => {
-    const comment = 'A\n* line 1\n* 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertBlock(result, 0, 'paragraph', 'A');
-    assertListBlock(result, 1, 0, 'line 1');
-    assertListBlock(result, 1, 1, '2nd line');
-    assertBlock(result, 2, 'paragraph', 'B');
-  });
-
-  test('bullet list 3', () => {
-    const comment = '* line 1\n* 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertListBlock(result, 0, 0, 'line 1');
-    assertListBlock(result, 0, 1, '2nd line');
-    assertBlock(result, 1, 'paragraph', 'B');
-  });
-
-  test('bullet list 4', () => {
-    const comment = 'To see this bug, you have to:\n' +
-        '* Be on IMAP or EAS (not on POP)\n' +
-        '* Be very unlucky\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
-    assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
-    assertListBlock(result, 1, 1, 'Be very unlucky');
-  });
-
-  test('bullet list 5', () => {
-    const comment = 'To see this bug,\n' +
-        'you have to:\n' +
-        '* Be on IMAP or EAS (not on POP)\n' +
-        '* Be very unlucky\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertBlock(result, 0, 'paragraph', 'To see this bug,\nyou have to:');
-    assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
-    assertListBlock(result, 1, 1, 'Be very unlucky');
-  });
-
-  test('dash list 1', () => {
-    const comment = 'A\n- line 1\n- 2nd line';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertBlock(result, 0, 'paragraph', 'A');
-    assertListBlock(result, 1, 0, 'line 1');
-    assertListBlock(result, 1, 1, '2nd line');
-  });
-
-  test('dash list 2', () => {
-    const comment = 'A\n- line 1\n- 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertBlock(result, 0, 'paragraph', 'A');
-    assertListBlock(result, 1, 0, 'line 1');
-    assertListBlock(result, 1, 1, '2nd line');
-    assertBlock(result, 2, 'paragraph', 'B');
-  });
-
-  test('dash list 3', () => {
-    const comment = '- line 1\n- 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertListBlock(result, 0, 0, 'line 1');
-    assertListBlock(result, 0, 1, '2nd line');
-    assertBlock(result, 1, 'paragraph', 'B');
-  });
-
-  test('nested list will NOT be recognized', () => {
-    // will be rendered as two separate lists
-    const comment = '- line 1\n  - line with indentation\n- line 2';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertListBlock(result, 0, 0, 'line 1');
-    assert.equal(result[1].type, 'pre');
-    assertListBlock(result, 2, 0, 'line 2');
-  });
-
-  test('pre format 1', () => {
-    const comment = 'A\n  This is pre\n  formatted';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertBlock(result, 0, 'paragraph', 'A');
-    assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
-  });
-
-  test('pre format 2', () => {
-    const comment = 'A\n  This is pre\n  formatted\n\nbut this is not';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertBlock(result, 0, 'paragraph', 'A');
-    assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
-    assertBlock(result, 2, 'paragraph', 'but this is not');
-  });
-
-  test('pre format 3', () => {
-    const comment = 'A\n  Q\n    <R>\n  S\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertBlock(result, 0, 'paragraph', 'A');
-    assertBlock(result, 1, 'pre', '  Q\n    <R>\n  S');
-    assertBlock(result, 2, 'paragraph', 'B');
-  });
-
-  test('pre format 4', () => {
-    const comment = '  Q\n    <R>\n  S\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertBlock(result, 0, 'pre', '  Q\n    <R>\n  S');
-    assertBlock(result, 1, 'paragraph', 'B');
-  });
-
-  test('quote 1', () => {
-    const comment = '> I\'m happy\n > with quotes!\n\nSee above.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assert.equal(result[0].type, 'quote');
-    assert.lengthOf(result[0].blocks, 1);
-    assertBlock(result[0].blocks, 0, 'paragraph', 'I\'m happy\nwith quotes!');
-    assertBlock(result, 1, 'paragraph', 'See above.');
-  });
-
-  test('quote 2', () => {
-    const comment = 'See this said:\n > a quoted\n > string block\n\nOK?';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertBlock(result, 0, 'paragraph', 'See this said:');
-    assert.equal(result[1].type, 'quote');
-    assert.lengthOf(result[1].blocks, 1);
-    assertBlock(result[1].blocks, 0, 'paragraph', 'a quoted\nstring block');
-    assertBlock(result, 2, 'paragraph', 'OK?');
-  });
-
-  test('nested quotes', () => {
-    const comment = ' > > prior\n > \n > next\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assert.equal(result[0].type, 'quote');
-    assert.lengthOf(result[0].blocks, 2);
-    assert.equal(result[0].blocks[0].type, 'quote');
-    assert.lengthOf(result[0].blocks[0].blocks, 1);
-    assertBlock(result[0].blocks[0].blocks, 0, 'paragraph', 'prior');
-    assertBlock(result[0].blocks, 1, 'paragraph', 'next');
-  });
-
-  test('code 1', () => {
-    const comment = '```\n// test code\n```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assert.equal(result[0].type, 'code');
-    assert.equal(result[0].text, '// test code');
-  });
-
-  test('code 2', () => {
-    const comment = 'test code\n```// test code```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assert.equal(result[0].type, 'paragraph');
-    assert.equal(result[0].text, 'test code');
-    assert.equal(result[1].type, 'code');
-    assert.equal(result[1].text, '// test code');
-  });
-
-  test('code 3', () => {
-    const comment = 'test code\n```// test code```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assert.equal(result[0].type, 'paragraph');
-    assert.equal(result[0].text, 'test code');
-    assert.equal(result[1].type, 'code');
-    assert.equal(result[1].text, '// test code');
-  });
-
-  test('not a code', () => {
-    const comment = 'test code\n```// test code';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assert.equal(result[0].type, 'paragraph');
-    assert.equal(result[0].text, 'test code\n```// test code');
-  });
-
-  test('not a code 2', () => {
-    const comment = 'test code\n```\n// test code';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assert.equal(result[0].type, 'paragraph');
-    assert.equal(result[0].text, 'test code');
-    assert.equal(result[1].type, 'paragraph');
-    assert.equal(result[1].text, '```\n// test code');
-  });
-
-  test('mix all 1', () => {
-    const comment = ' bullets:\n- bullet 1\n- bullet 2\n\ncode example:\n' +
-      '```// test code```\n\n> reference is here';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 5);
-    assert.equal(result[0].type, 'pre');
-    assert.equal(result[1].type, 'list');
-    assert.equal(result[2].type, 'paragraph');
-    assert.equal(result[3].type, 'code');
-    assert.equal(result[4].type, 'quote');
-  });
-
-  test('_computeNodes called without config', () => {
-    const computeNodesSpy = sandbox.spy(element, '_computeNodes');
-    element.content = 'some text';
-    assert.isTrue(computeNodesSpy.called);
-  });
-
-  test('_contentOrConfigChanged called with config', () => {
-    const contentStub = sandbox.stub(element, '_contentChanged');
-    const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
-    element.content = 'some text';
-    element.config = {};
-    assert.isTrue(contentStub.called);
-    assert.isTrue(contentConfigStub.called);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
new file mode 100644
index 0000000..fd5a9ba
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
@@ -0,0 +1,406 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-formatted-text.js';
+
+const basicFixture = fixtureFromElement('gr-formatted-text');
+
+suite('gr-formatted-text tests', () => {
+  let element;
+
+  function assertBlock(result, index, type, text) {
+    assert.equal(result[index].type, type);
+    assert.equal(result[index].text, text);
+  }
+
+  function assertListBlock(result, resultIndex, itemIndex, text) {
+    assert.equal(result[resultIndex].type, 'list');
+    assert.equal(result[resultIndex].items[itemIndex], text);
+  }
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('parse null undefined and empty', () => {
+    assert.lengthOf(element._computeBlocks(null), 0);
+    assert.lengthOf(element._computeBlocks(undefined), 0);
+    assert.lengthOf(element._computeBlocks(''), 0);
+  });
+
+  test('parse simple', () => {
+    const comment = 'Para1';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'paragraph', comment);
+  });
+
+  test('parse multiline para', () => {
+    const comment = 'Para 1\nStill para 1';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'paragraph', comment);
+  });
+
+  test('parse para break without special blocks', () => {
+    const comment = 'Para 1\n\nPara 2\n\nPara 3';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'paragraph', comment);
+  });
+
+  test('parse quote', () => {
+    const comment = '> Quote text';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 1);
+    assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
+  });
+
+  test('parse quote lead space', () => {
+    const comment = ' > Quote text';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 1);
+    assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
+  });
+
+  test('parse multiline quote', () => {
+    const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 1);
+    assertBlock(result[0].blocks, 0, 'paragraph',
+        'Quote line 1\nQuote line 2\nQuote line 3');
+  });
+
+  test('parse pre', () => {
+    const comment = '    Four space indent.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'pre', comment);
+  });
+
+  test('parse one space pre', () => {
+    const comment = ' One space indent.\n Another line.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'pre', comment);
+  });
+
+  test('parse tab pre', () => {
+    const comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertBlock(result, 0, 'pre', comment);
+  });
+
+  test('parse star list', () => {
+    const comment = '* Item 1\n* Item 2\n* Item 3';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertListBlock(result, 0, 0, 'Item 1');
+    assertListBlock(result, 0, 1, 'Item 2');
+    assertListBlock(result, 0, 2, 'Item 3');
+  });
+
+  test('parse dash list', () => {
+    const comment = '- Item 1\n- Item 2\n- Item 3';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertListBlock(result, 0, 0, 'Item 1');
+    assertListBlock(result, 0, 1, 'Item 2');
+    assertListBlock(result, 0, 2, 'Item 3');
+  });
+
+  test('parse mixed list', () => {
+    const comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertListBlock(result, 0, 0, 'Item 1');
+    assertListBlock(result, 0, 1, 'Item 2');
+    assertListBlock(result, 0, 2, 'Item 3');
+    assertListBlock(result, 0, 3, 'Item 4');
+  });
+
+  test('parse mixed block types', () => {
+    const comment = 'Paragraph\nacross\na\nfew\nlines.' +
+        '\n\n' +
+        '> Quote\n> across\n> not many lines.' +
+        '\n\n' +
+        'Another paragraph' +
+        '\n\n' +
+        '* Series\n* of\n* list\n* items' +
+        '\n\n' +
+        'Yet another paragraph' +
+        '\n\n' +
+        '\tPreformatted text.' +
+        '\n\n' +
+        'Parting words.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 7);
+    assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.\n');
+
+    assert.equal(result[1].type, 'quote');
+    assert.lengthOf(result[1].blocks, 1);
+    assertBlock(result[1].blocks, 0, 'paragraph',
+        'Quote\nacross\nnot many lines.');
+
+    assertBlock(result, 2, 'paragraph', 'Another paragraph\n');
+    assertListBlock(result, 3, 0, 'Series');
+    assertListBlock(result, 3, 1, 'of');
+    assertListBlock(result, 3, 2, 'list');
+    assertListBlock(result, 3, 3, 'items');
+    assertBlock(result, 4, 'paragraph', 'Yet another paragraph\n');
+    assertBlock(result, 5, 'pre', '\tPreformatted text.');
+    assertBlock(result, 6, 'paragraph', 'Parting words.');
+  });
+
+  test('bullet list 1', () => {
+    const comment = 'A\n\n* line 1\n* 2nd line';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'A\n');
+    assertListBlock(result, 1, 0, 'line 1');
+    assertListBlock(result, 1, 1, '2nd line');
+  });
+
+  test('bullet list 2', () => {
+    const comment = 'A\n* line 1\n* 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertListBlock(result, 1, 0, 'line 1');
+    assertListBlock(result, 1, 1, '2nd line');
+    assertBlock(result, 2, 'paragraph', 'B');
+  });
+
+  test('bullet list 3', () => {
+    const comment = '* line 1\n* 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertListBlock(result, 0, 0, 'line 1');
+    assertListBlock(result, 0, 1, '2nd line');
+    assertBlock(result, 1, 'paragraph', 'B');
+  });
+
+  test('bullet list 4', () => {
+    const comment = 'To see this bug, you have to:\n' +
+        '* Be on IMAP or EAS (not on POP)\n' +
+        '* Be very unlucky\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
+    assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
+    assertListBlock(result, 1, 1, 'Be very unlucky');
+  });
+
+  test('bullet list 5', () => {
+    const comment = 'To see this bug,\n' +
+        'you have to:\n' +
+        '* Be on IMAP or EAS (not on POP)\n' +
+        '* Be very unlucky\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'To see this bug,\nyou have to:');
+    assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
+    assertListBlock(result, 1, 1, 'Be very unlucky');
+  });
+
+  test('dash list 1', () => {
+    const comment = 'A\n- line 1\n- 2nd line';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertListBlock(result, 1, 0, 'line 1');
+    assertListBlock(result, 1, 1, '2nd line');
+  });
+
+  test('dash list 2', () => {
+    const comment = 'A\n- line 1\n- 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertListBlock(result, 1, 0, 'line 1');
+    assertListBlock(result, 1, 1, '2nd line');
+    assertBlock(result, 2, 'paragraph', 'B');
+  });
+
+  test('dash list 3', () => {
+    const comment = '- line 1\n- 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertListBlock(result, 0, 0, 'line 1');
+    assertListBlock(result, 0, 1, '2nd line');
+    assertBlock(result, 1, 'paragraph', 'B');
+  });
+
+  test('nested list will NOT be recognized', () => {
+    // will be rendered as two separate lists
+    const comment = '- line 1\n  - line with indentation\n- line 2';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertListBlock(result, 0, 0, 'line 1');
+    assert.equal(result[1].type, 'pre');
+    assertListBlock(result, 2, 0, 'line 2');
+  });
+
+  test('pre format 1', () => {
+    const comment = 'A\n  This is pre\n  formatted';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
+  });
+
+  test('pre format 2', () => {
+    const comment = 'A\n  This is pre\n  formatted\n\nbut this is not';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
+    assertBlock(result, 2, 'paragraph', 'but this is not');
+  });
+
+  test('pre format 3', () => {
+    const comment = 'A\n  Q\n    <R>\n  S\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'A');
+    assertBlock(result, 1, 'pre', '  Q\n    <R>\n  S');
+    assertBlock(result, 2, 'paragraph', 'B');
+  });
+
+  test('pre format 4', () => {
+    const comment = '  Q\n    <R>\n  S\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertBlock(result, 0, 'pre', '  Q\n    <R>\n  S');
+    assertBlock(result, 1, 'paragraph', 'B');
+  });
+
+  test('quote 1', () => {
+    const comment = '> I\'m happy\n > with quotes!\n\nSee above.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 1);
+    assertBlock(result[0].blocks, 0, 'paragraph', 'I\'m happy\nwith quotes!');
+    assertBlock(result, 1, 'paragraph', 'See above.');
+  });
+
+  test('quote 2', () => {
+    const comment = 'See this said:\n > a quoted\n > string block\n\nOK?';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertBlock(result, 0, 'paragraph', 'See this said:');
+    assert.equal(result[1].type, 'quote');
+    assert.lengthOf(result[1].blocks, 1);
+    assertBlock(result[1].blocks, 0, 'paragraph', 'a quoted\nstring block');
+    assertBlock(result, 2, 'paragraph', 'OK?');
+  });
+
+  test('nested quotes', () => {
+    const comment = ' > > prior\n > \n > next\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'quote');
+    assert.lengthOf(result[0].blocks, 2);
+    assert.equal(result[0].blocks[0].type, 'quote');
+    assert.lengthOf(result[0].blocks[0].blocks, 1);
+    assertBlock(result[0].blocks[0].blocks, 0, 'paragraph', 'prior');
+    assertBlock(result[0].blocks, 1, 'paragraph', 'next');
+  });
+
+  test('code 1', () => {
+    const comment = '```\n// test code\n```';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'code');
+    assert.equal(result[0].text, '// test code');
+  });
+
+  test('code 2', () => {
+    const comment = 'test code\n```// test code```';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assert.equal(result[0].type, 'paragraph');
+    assert.equal(result[0].text, 'test code');
+    assert.equal(result[1].type, 'code');
+    assert.equal(result[1].text, '// test code');
+  });
+
+  test('code 3', () => {
+    const comment = 'test code\n```// test code```';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assert.equal(result[0].type, 'paragraph');
+    assert.equal(result[0].text, 'test code');
+    assert.equal(result[1].type, 'code');
+    assert.equal(result[1].text, '// test code');
+  });
+
+  test('not a code', () => {
+    const comment = 'test code\n```// test code';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assert.equal(result[0].type, 'paragraph');
+    assert.equal(result[0].text, 'test code\n```// test code');
+  });
+
+  test('not a code 2', () => {
+    const comment = 'test code\n```\n// test code';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assert.equal(result[0].type, 'paragraph');
+    assert.equal(result[0].text, 'test code');
+    assert.equal(result[1].type, 'paragraph');
+    assert.equal(result[1].text, '```\n// test code');
+  });
+
+  test('mix all 1', () => {
+    const comment = ' bullets:\n- bullet 1\n- bullet 2\n\ncode example:\n' +
+      '```// test code```\n\n> reference is here';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 5);
+    assert.equal(result[0].type, 'pre');
+    assert.equal(result[1].type, 'list');
+    assert.equal(result[2].type, 'paragraph');
+    assert.equal(result[3].type, 'code');
+    assert.equal(result[4].type, 'quote');
+  });
+
+  test('_computeNodes called without config', () => {
+    const computeNodesSpy = sinon.spy(element, '_computeNodes');
+    element.content = 'some text';
+    assert.isTrue(computeNodesSpy.called);
+  });
+
+  test('_contentOrConfigChanged called with config', () => {
+    const contentStub = sinon.stub(element, '_contentChanged');
+    const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
+    element.content = 'some text';
+    element.config = {};
+    assert.isTrue(contentStub.called);
+    assert.isTrue(contentConfigStub.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js
index 0bc9cb7..a92ae4d 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js
@@ -14,19 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 
 import '@polymer/iron-icon/iron-icon.js';
 import '../../../styles/shared-styles.js';
 import '../gr-avatar/gr-avatar.js';
 import '../gr-button/gr-button.js';
+import '../gr-rest-api-interface/gr-rest-api-interface.js';
 import {hovercardBehaviorMixin} from '../gr-hovercard/gr-hovercard-behavior.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-hovercard-account_html.js';
+import {appContext} from '../../../services/app-context.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrHovercardAccount extends GestureEventListeners(
     hovercardBehaviorMixin(LegacyElementMixin(
         PolymerElement))) {
@@ -36,13 +37,154 @@
 
   static get properties() {
     return {
+      /**
+       * This is an AccountInfo response object.
+       */
       account: Object,
+      _selfAccount: Object,
+      /**
+       * Optional ChangeInfo object, typically comes from the change page or
+       * from a row in a list of search results. This is needed for some change
+       * related features like adding the user as a reviewer.
+       */
+      change: Object,
+      /**
+       * Explains which labels the user can vote on and which score they can
+       * give.
+       */
       voteableText: String,
-      attention: {
+      /**
+       * Should attention set related features be shown in the component? Note
+       * that the information whether the user is in the attention set or not is
+       * part of the ChangeInfo object in the change property.
+       */
+      highlightAttention: {
         type: Boolean,
         value: false,
-        reflectToAttribute: true,
       },
+      /**
+       * This is a ServerInfo response object.
+       */
+      _config: {
+        type: Object,
+        value: null,
+      },
+    };
+  }
+
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(config => {
+      this._config = config;
+    });
+    this.$.restAPI.getAccount().then(account => {
+      this._selfAccount = account;
+    });
+  }
+
+  _computeText(account, selfAccount) {
+    if (!account || !selfAccount) return '';
+    return account._account_id === selfAccount._account_id ? 'Your' : 'Their';
+  }
+
+  get isAttentionSetEnabled() {
+    return !!this._config && !!this._config.change
+        && !!this._config.change.enable_attention_set
+        && !!this.highlightAttention && !!this.change && !!this.account;
+  }
+
+  get hasAttention() {
+    if (!this.isAttentionSetEnabled || !this.change.attention_set) return false;
+    return this.change.attention_set.hasOwnProperty(this.account._account_id);
+  }
+
+  _computeReason(change) {
+    if (!change || !change.attention_set) return '';
+    const entry = change.attention_set[this.account._account_id];
+    if (!entry || !entry.reason) return '';
+    return entry.reason;
+  }
+
+  _computeLastUpdate(change) {
+    if (!change || !change.attention_set) return '';
+    const entry = change.attention_set[this.account._account_id];
+    if (!entry || !entry.last_update) return '';
+    return entry.last_update;
+  }
+
+  _computeShowLabelNeedsAttention(config, highlightAttention, account, change) {
+    return this.isAttentionSetEnabled && this.hasAttention;
+  }
+
+  _computeShowActionAddToAttentionSet(config, highlightAttn, account, change) {
+    return this.isAttentionSetEnabled && !this.hasAttention;
+  }
+
+  _computeShowActionRemoveFromAttentionSet(config, highlightAttention, account,
+      change) {
+    return this.isAttentionSetEnabled && this.hasAttention;
+  }
+
+  _handleClickAddToAttentionSet(e) {
+    this.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {
+        message: 'Adding user to attention set. Will be reloading ...',
+        dismissOnNavigation: true,
+      },
+      composed: true,
+      bubbles: true,
+    }));
+    this.reporting.reportInteraction('attention-hovercard-add',
+        this._reportingDetails());
+    this.$.restAPI.addToAttentionSet(this.change._number,
+        this.account._account_id, 'manually added').then(obj => {
+      this.dispatchEventThroughTarget('hide-alert');
+      this.dispatchEventThroughTarget('reload');
+    });
+    this.hide();
+  }
+
+  _handleClickRemoveFromAttentionSet(e) {
+    this.dispatchEvent(new CustomEvent('show-alert', {
+      detail: {
+        message: 'Removing user from attention set. Will be reloading ...',
+        dismissOnNavigation: true,
+      },
+      composed: true,
+      bubbles: true,
+    }));
+    this.reporting.reportInteraction('attention-hovercard-remove',
+        this._reportingDetails());
+    this.$.restAPI.removeFromAttentionSet(this.change._number,
+        this.account._account_id, 'manually removed').then(obj => {
+      this.dispatchEventThroughTarget('hide-alert');
+      this.dispatchEventThroughTarget('reload');
+    });
+    this.hide();
+  }
+
+  _reportingDetails() {
+    const targetId = this.account._account_id;
+    const ownerId = (this.change && this.change.owner
+        && this.change.owner._account_id) || -1;
+    const selfId = (this._selfAccount && this._selfAccount._account_id) || -1;
+    const reviewers = (
+      this.change && this.change.reviewers && this.change.reviewers.REVIEWER ?
+        [...this.change.reviewers.REVIEWER] : []);
+    const reviewerIds = reviewers
+        .map(r => r._account_id)
+        .filter(rId => rId !== ownerId);
+    return {
+      actionByOwner: selfId === ownerId,
+      actionByReviewer: reviewerIds.includes(selfId),
+      targetIsOwner: targetId === ownerId,
+      targetIsReviewer: reviewerIds.includes(targetId),
+      targetIsSelf: targetId === selfId,
     };
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js
deleted file mode 100644
index 8d14ff4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.js
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../gr-hovercard/gr-hovercard-shared-style.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="gr-hovercard-shared-style">
-    .top,
-    .attention,
-    .status,
-    .voteable {
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    .top {
-      display: flex;
-      padding-top: var(--spacing-xl);
-      min-width: 300px;
-    }
-    gr-avatar {
-      height: 48px;
-      width: 48px;
-      margin-right: var(--spacing-l);
-    }
-    .title,
-    .email {
-      color: var(--deemphasized-text-color);
-    }
-    .status iron-icon {
-      width: 14px;
-      height: 14px;
-      vertical-align: top;
-      position: relative;
-      top: 2px;
-    }
-    .action {
-      border-top: 1px solid var(--border-color);
-      padding: var(--spacing-s) var(--spacing-l);
-      --gr-button: {
-        padding: var(--spacing-s) 0;
-      }
-    }
-    :host(:not([attention])) .attention {
-      display: none;
-    }
-    .attention {
-      background-color: var(--emphasis-color);
-    }
-    .attention iron-icon {
-      vertical-align: top;
-    }
-  </style>
-  <div id="container" role="tooltip" tabindex="-1">
-    <div class="top">
-      <div class="avatar">
-        <gr-avatar account="[[account]]" image-size="56"></gr-avatar>
-      </div>
-      <div class="account">
-        <h3 class="name">[[account.name]]</h3>
-        <div class="email">[[account.email]]</div>
-      </div>
-    </div>
-    <template is="dom-if" if="[[account.status]]">
-      <div class="status">
-        <span class="title">
-          <iron-icon icon="gr-icons:calendar"></iron-icon>
-          Status:
-        </span>
-        <span class="value">[[account.status]]</span>
-      </div>
-    </template>
-    <template is="dom-if" if="[[voteableText]]">
-      <div class="voteable">
-        <span class="title">Voteable:</span>
-        <span class="value">[[voteableText]]</span>
-      </div>
-    </template>
-    <div class="attention">
-      <iron-icon icon="gr-icons:attention"></iron-icon>
-      <span>It is this user's turn to take action.</span>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
new file mode 100644
index 0000000..c9d1682
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -0,0 +1,175 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../gr-hovercard/gr-hovercard-shared-style';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="gr-hovercard-shared-style">
+    .top,
+    .attention,
+    .status,
+    .voteable {
+      padding: var(--spacing-s) var(--spacing-l);
+    }
+    .top {
+      display: flex;
+      padding-top: var(--spacing-xl);
+      min-width: 300px;
+    }
+    gr-avatar {
+      height: 48px;
+      width: 48px;
+      margin-right: var(--spacing-l);
+    }
+    .title,
+    .email {
+      color: var(--deemphasized-text-color);
+    }
+    .action {
+      border-top: 1px solid var(--border-color);
+      padding: var(--spacing-s) var(--spacing-l);
+      --gr-button: {
+        padding: var(--spacing-s) var(--spacing-m);
+      }
+    }
+    .attention {
+      background-color: var(--emphasis-color);
+    }
+    .attention a {
+      text-decoration: none;
+    }
+    iron-icon {
+      vertical-align: top;
+    }
+    .status iron-icon {
+      width: 14px;
+      height: 14px;
+      position: relative;
+      top: 2px;
+    }
+    iron-icon.attentionIcon {
+      width: 14px;
+      height: 14px;
+      position: relative;
+      top: 3px;
+    }
+    .reason {
+      padding-top: var(--spacing-s);
+    }
+  </style>
+  <div id="container" role="tooltip" tabindex="-1">
+    <template is="dom-if" if="[[_isShowing]]">
+      <div class="top">
+        <div class="avatar">
+          <gr-avatar account="[[account]]" image-size="56"></gr-avatar>
+        </div>
+        <div class="account">
+          <h3 class="name heading-3">[[account.name]]</h3>
+          <div class="email">[[account.email]]</div>
+        </div>
+      </div>
+      <template is="dom-if" if="[[account.status]]">
+        <div class="status">
+          <span class="title">
+            <iron-icon icon="gr-icons:calendar"></iron-icon>
+            Status:
+          </span>
+          <span class="value">[[account.status]]</span>
+        </div>
+      </template>
+      <template is="dom-if" if="[[voteableText]]">
+        <div class="voteable">
+          <span class="title">Voteable:</span>
+          <span class="value">[[voteableText]]</span>
+        </div>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_computeShowLabelNeedsAttention(_config, highlightAttention, account, change)]]"
+      >
+        <div class="attention">
+          <div>
+            <iron-icon
+              class="attentionIcon"
+              icon="gr-icons:attention"
+            ></iron-icon>
+            <span>
+              [[_computeText(account, _selfAccount)]] turn to take action.
+            </span>
+            <a
+              href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
+              target="_blank"
+            >
+              <iron-icon
+                icon="gr-icons:bug"
+                title="report a problem"
+              ></iron-icon>
+            </a>
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              target="_blank"
+            >
+              <iron-icon
+                icon="gr-icons:help-outline"
+                title="read documentation"
+              ></iron-icon>
+            </a>
+          </div>
+          <div class="reason">
+            <span class="title">Reason:</span>
+            <span class="value">[[_computeReason(change)]]</span>,
+            <gr-date-formatter
+              has-tooltip
+              date-str="[[_computeLastUpdate(change)]]"
+            ></gr-date-formatter>
+          </div>
+        </div>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_computeShowActionAddToAttentionSet(_config, highlightAttention, account, change)]]"
+      >
+        <div class="action">
+          <gr-button
+            class="addToAttentionSet"
+            link=""
+            no-uppercase=""
+            on-click="_handleClickAddToAttentionSet"
+          >
+            Add to attention set
+          </gr-button>
+        </div>
+      </template>
+      <template
+        is="dom-if"
+        if="[[_computeShowActionRemoveFromAttentionSet(_config, highlightAttention, account, change)]]"
+      >
+        <div class="action">
+          <gr-button
+            class="removeFromAttentionSet"
+            link=""
+            no-uppercase=""
+            on-click="_handleClickRemoveFromAttentionSet"
+          >
+            Remove from attention set
+          </gr-button>
+        </div>
+      </template>
+    </template>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.html b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.html
deleted file mode 100644
index be0f2b2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.html
+++ /dev/null
@@ -1,81 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2020 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport"
-      content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-hovercard-account</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script src="../../../node_modules/iron-test-helpers/mock-interactions.js" type="module"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-hovercard-account class="hovered"></gr-hovercard-account>
-  </template>
-</test-fixture>
-
-
-<script type="module">
-  import '../../../test/common-test-setup.js';
-  import './gr-hovercard-account.js';
-
-  suite('gr-hovercard-account tests', () => {
-    let element;
-    const ACCOUNT = {
-      email: 'kermit@gmail.com',
-      username: 'kermit',
-      name: 'Kermit The Frog',
-      _account_id: '31415926535',
-    };
-
-    setup(() => {
-      element = fixture('basic');
-      element.account = Object.assign({}, ACCOUNT);
-    });
-
-    test('account name is shown', () => {
-      assert.equal(element.shadowRoot.querySelector('.name').innerText,
-          'Kermit The Frog');
-    });
-
-    test('account status is not shown if the property is not set', () => {
-      assert.isNull(element.shadowRoot.querySelector('.status'));
-    });
-
-    test('account status is displayed', () => {
-      element.account = Object.assign({status: 'OOO'}, ACCOUNT);
-      flushAsynchronousOperations();
-      assert.equal(element.shadowRoot.querySelector('.status .value').innerText,
-          'OOO');
-    });
-
-    test('voteable div is not shown if the property is not set', () => {
-      assert.isNull(element.shadowRoot.querySelector('.voteable'));
-    });
-
-    test('voteable div is displayed', () => {
-      element.voteableText = 'CodeReview: +2';
-      flushAsynchronousOperations();
-      assert.equal(element.shadowRoot.querySelector('.voteable .value').innerText,
-          element.voteableText);
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
new file mode 100644
index 0000000..6e611d5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
@@ -0,0 +1,182 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-hovercard-account.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-hovercard-account class="hovered"></gr-hovercard-account>
+`);
+
+suite('gr-hovercard-account tests', () => {
+  let element;
+
+  const ACCOUNT = {
+    email: 'kermit@gmail.com',
+    username: 'kermit',
+    name: 'Kermit The Frog',
+    _account_id: '31415926535',
+  };
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    sinon.stub(element.$.restAPI, 'getAccount').returns(
+        new Promise(resolve => { '2'; })
+    );
+
+    element.account = {...ACCOUNT};
+    element._config = {
+      change: {enable_attention_set: true},
+    };
+    element.change = {
+      attention_set: {},
+    };
+    element.show({});
+    flushAsynchronousOperations();
+  });
+
+  teardown(() => {
+    element.hide({});
+  });
+
+  test('account name is shown', () => {
+    assert.equal(element.shadowRoot.querySelector('.name').innerText,
+        'Kermit The Frog');
+  });
+
+  test('_computeReason', () => {
+    const change = {
+      attention_set: {
+        31415926535: {
+          reason: 'a good reason',
+        },
+      },
+    };
+    assert.equal(element._computeReason(change), 'a good reason');
+  });
+
+  test('_computeLastUpdate', () => {
+    const last_update = '2019-07-17 19:39:02.000000000';
+    const change = {
+      attention_set: {
+        31415926535: {
+          last_update,
+        },
+      },
+    };
+    assert.equal(element._computeLastUpdate(change), last_update);
+  });
+
+  test('_computeText', () => {
+    let account = {_account_id: '1'};
+    const selfAccount = {_account_id: '1'};
+    assert.equal(element._computeText(account, selfAccount), 'Your');
+    account = {_account_id: '2'};
+    assert.equal(element._computeText(account, selfAccount), 'Their');
+  });
+
+  test('account status is not shown if the property is not set', () => {
+    assert.isNull(element.shadowRoot.querySelector('.status'));
+  });
+
+  test('account status is displayed', () => {
+    element.account = {status: 'OOO', ...ACCOUNT};
+    flushAsynchronousOperations();
+    assert.equal(element.shadowRoot.querySelector('.status .value').innerText,
+        'OOO');
+  });
+
+  test('voteable div is not shown if the property is not set', () => {
+    assert.isNull(element.shadowRoot.querySelector('.voteable'));
+  });
+
+  test('voteable div is displayed', () => {
+    element.voteableText = 'CodeReview: +2';
+    flushAsynchronousOperations();
+    assert.equal(element.shadowRoot.querySelector('.voteable .value').innerText,
+        element.voteableText);
+  });
+
+  test('add to attention set', done => {
+    let apiResolve;
+    const apiPromise = new Promise(r => {
+      apiResolve = r;
+    });
+    sinon.stub(element.$.restAPI, 'addToAttentionSet')
+        .callsFake(() => apiPromise);
+    element.highlightAttention = true;
+    element._target = document.createElement('div');
+    flushAsynchronousOperations();
+    const showAlertListener = sinon.spy();
+    const hideAlertListener = sinon.spy();
+    const reloadListener = sinon.spy();
+    element.addEventListener('show-alert', showAlertListener);
+    element._target.addEventListener('hide-alert', hideAlertListener);
+    element._target.addEventListener('reload', reloadListener);
+
+    const button = element.shadowRoot.querySelector('.addToAttentionSet');
+    assert.isOk(button);
+    assert.isTrue(element._isShowing, 'hovercard is showing');
+    MockInteractions.tap(button);
+
+    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+    assert.isFalse(element._isShowing, 'hovercard is hidden');
+
+    apiResolve({});
+    flush(() => {
+      assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+      assert.isTrue(reloadListener.called, 'reloadListener was called');
+      done();
+    });
+  });
+
+  test('remove from attention set', done => {
+    let apiResolve;
+    const apiPromise = new Promise(r => {
+      apiResolve = r;
+    });
+    sinon.stub(element.$.restAPI, 'removeFromAttentionSet')
+        .callsFake(() => apiPromise);
+    element.highlightAttention = true;
+    element.change = {attention_set: {31415926535: {}}};
+    element._target = document.createElement('div');
+    flushAsynchronousOperations();
+    const showAlertListener = sinon.spy();
+    const hideAlertListener = sinon.spy();
+    const reloadListener = sinon.spy();
+    element.addEventListener('show-alert', showAlertListener);
+    element._target.addEventListener('hide-alert', hideAlertListener);
+    element._target.addEventListener('reload', reloadListener);
+
+    const button = element.shadowRoot.querySelector('.removeFromAttentionSet');
+    assert.isOk(button);
+    assert.isTrue(element._isShowing, 'hovercard is showing');
+    MockInteractions.tap(button);
+
+    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+    assert.isFalse(element._isShowing, 'hovercard is hidden');
+
+    apiResolve({});
+    flush(() => {
+      assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+      assert.isTrue(reloadListener.called, 'reloadListener was called');
+      done();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js
index a4d00224..71bfe72 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js
@@ -14,29 +14,30 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {Debouncer} from '@polymer/polymer/lib/utils/debounce.js';
+import {timeOut} from '@polymer/polymer/lib/utils/async.js';
 import {getRootElement} from '../../../scripts/rootElement.js';
 
 const HOVER_CLASS = 'hovered';
 const HIDE_CLASS = 'hide';
 
 /**
- * When the hovercard is positioned diagonally (bottom-left, bottom-right,
- * top-left, or top-right), we add additional (invisible) padding so that the
- * area that a user can hover over to access the hovercard is larger.
- */
-const DIAGONAL_OVERFLOW = 15;
-
-/**
- * How long should be wait before showing the hovercard when the user hovers
+ * How long should we wait before showing the hovercard when the user hovers
  * over the element?
  */
 const SHOW_DELAY_MS = 500;
 
 /**
+ * How long should we wait before hiding the hovercard when the user moves from
+ * target to the hovercard.
+ *
+ * Note: this should be lower than SHOW_DELAY_MS to avoid flickering.
+ */
+const HIDE_DELAY_MS = 300;
+
+/**
  * The mixin for gr-hovercard-behavior.
  *
  * @example
@@ -125,18 +126,19 @@
   attached() {
     super.attached();
     if (!this._target) { this._target = this.target; }
-    this.listen(this._target, 'mouseenter', 'showDelayed');
-    this.listen(this._target, 'focus', 'showDelayed');
-    this.listen(this._target, 'mouseleave', 'hide');
-    this.listen(this._target, 'blur', 'hide');
-    this.listen(this._target, 'click', 'hide');
-  }
+    this.listen(this._target, 'mouseenter', 'debounceShow');
+    this.listen(this._target, 'focus', 'debounceShow');
+    this.listen(this._target, 'mouseleave', 'debounceHide');
+    this.listen(this._target, 'blur', 'debounceHide');
 
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('mouseleave',
-        e => this.hide(e));
+    // when click, dismiss immediately
+    this.listen(this._target, 'click', 'hide');
+
+    // show the hovercard if mouse moves to hovercard
+    // this will cancel pending hide as well
+    this.listen(this, 'mouseenter', 'show');
+    // when leave hovercard, hide it immediately
+    this.listen(this, 'mouseleave', 'hide');
   }
 
   /** @override */
@@ -155,13 +157,46 @@
   }
 
   removeListeners() {
-    this.unlisten(this._target, 'mouseenter', 'show');
-    this.unlisten(this._target, 'focus', 'show');
-    this.unlisten(this._target, 'mouseleave', 'hide');
-    this.unlisten(this._target, 'blur', 'hide');
+    this.unlisten(this._target, 'mouseenter', 'debounceShow');
+    this.unlisten(this._target, 'focus', 'debounceShow');
+    this.unlisten(this._target, 'mouseleave', 'debounceHide');
+    this.unlisten(this._target, 'blur', 'debounceHide');
     this.unlisten(this._target, 'click', 'hide');
   }
 
+  debounceHide() {
+    this.cancelShowDebouncer();
+    if (!this._isShowing || this._isScheduledToHide) return;
+    this._isScheduledToHide = true;
+    this._hideDebouncer = Debouncer.debounce(
+        this._hideDebouncer,
+        timeOut.after(HIDE_DELAY_MS),
+        () => {
+          // This happens when hide immediately through click or mouse leave
+          // on the hovercard
+          if (!this._isScheduledToHide) return;
+          this.hide();
+        });
+  }
+
+  cancelHideDebouncer() {
+    if (this._hideDebouncer) {
+      this._hideDebouncer.cancel();
+      this._isScheduledToHide = false;
+    }
+  }
+
+  /**
+   * Hovercard elements are created outside of <gr-app>, so if you want to fire
+   * events, then you probably want to do that through the target element.
+   */
+  dispatchEventThroughTarget(eventName) {
+    this._target.dispatchEvent(new CustomEvent(eventName, {
+      bubbles: true,
+      composed: true,
+    }));
+  }
+
   /**
    * Returns the target element that the hovercard is anchored to (the `id` of
    * the `for` property).
@@ -188,10 +223,11 @@
    * `mouseleave` event on the hovercard's `target` element (as long as the
    * user is not hovering over the hovercard).
    *
-   * @param {Event} e DOM Event (e.g. `mouseleave` event)
+   * @param {Event} opt_e DOM Event (e.g. `mouseleave` event)
    */
-  hide(e) {
-    this._isScheduledToShow = false;
+  hide(opt_e) {
+    this.cancelHideDebouncer();
+    this.cancelShowDebouncer();
     if (!this._isShowing) {
       return;
     }
@@ -199,9 +235,11 @@
     // If the user is now hovering over the hovercard or the user is returning
     // from the hovercard but now hovering over the target (to stop an annoying
     // flicker effect), just return.
-    if (e.toElement === this ||
-        (e.fromElement === this && e.toElement === this._target)) {
-      return;
+    if (opt_e) {
+      if (opt_e.relatedTarget === this ||
+          (opt_e.target === this && opt_e.relatedTarget === this._target)) {
+        return;
+      }
     }
 
     // Mark that the hovercard is not visible and do not allow focusing
@@ -224,22 +262,32 @@
   /**
    * Shows/opens the hovercard with a fixed delay.
    */
-  showDelayed() {
-    this.showDelayedBy(SHOW_DELAY_MS);
+  debounceShow() {
+    this.debounceShowBy(SHOW_DELAY_MS);
   }
 
   /**
    * Shows/opens the hovercard with the given delay.
    */
-  showDelayedBy(delayMs) {
+  debounceShowBy(delayMs) {
+    this.cancelHideDebouncer();
     if (this._isShowing || this._isScheduledToShow) return;
     this._isScheduledToShow = true;
-    setTimeout(() => {
-      // This happens when the mouse leaves the target before the delay is over.
-      if (!this._isScheduledToShow) return;
+    this._showDebouncer = Debouncer.debounce(
+        this._showDebouncer,
+        timeOut.after(delayMs),
+        () => {
+          // This happens when the mouse leaves the target before the delay is over.
+          if (!this._isScheduledToShow) return;
+          this.show();
+        });
+  }
+
+  cancelShowDebouncer() {
+    if (this._showDebouncer) {
+      this._showDebouncer.cancel();
       this._isScheduledToShow = false;
-      this.show();
-    }, delayMs);
+    }
   }
 
   /**
@@ -247,6 +295,8 @@
    * `mousenter` event on the hovercard's `target` element.
    */
   show() {
+    this.cancelHideDebouncer();
+    this.cancelShowDebouncer();
     if (this._isShowing) {
       return;
     }
@@ -318,65 +368,40 @@
 
     let hovercardLeft;
     let hovercardTop;
-    const diagonalPadding = this.offset + DIAGONAL_OVERFLOW;
     let cssText = '';
 
     switch (position) {
       case 'top':
         hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
         hovercardTop = targetTop - thisRect.height - this.offset;
-        cssText += `padding-bottom:${this.offset
-        }px; margin-bottom:-${this.offset}px;`;
         break;
       case 'bottom':
         hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
         hovercardTop = targetTop + targetRect.height + this.offset;
-        cssText +=
-            `padding-top:${this.offset}px; margin-top:-${this.offset}px;`;
         break;
       case 'left':
         hovercardLeft = targetLeft - thisRect.width - this.offset;
         hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
-        cssText +=
-            `padding-right:${this.offset}px; margin-right:-${this.offset}px;`;
         break;
       case 'right':
         hovercardLeft = targetLeft + targetRect.width + this.offset;
         hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
-        cssText +=
-            `padding-left:${this.offset}px; margin-left:-${this.offset}px;`;
         break;
       case 'bottom-right':
         hovercardLeft = targetLeft + targetRect.width + this.offset;
-        hovercardTop = targetTop + targetRect.height + this.offset;
-        cssText += `padding-top:${diagonalPadding}px;`;
-        cssText += `padding-left:${diagonalPadding}px;`;
-        cssText += `margin-left:-${diagonalPadding}px;`;
-        cssText += `margin-top:-${diagonalPadding}px;`;
+        hovercardTop = targetTop;
         break;
       case 'bottom-left':
         hovercardLeft = targetLeft - thisRect.width - this.offset;
-        hovercardTop = targetTop + targetRect.height + this.offset;
-        cssText += `padding-top:${diagonalPadding}px;`;
-        cssText += `padding-right:${diagonalPadding}px;`;
-        cssText += `margin-right:-${diagonalPadding}px;`;
-        cssText += `margin-top:-${diagonalPadding}px;`;
+        hovercardTop = targetTop;
         break;
       case 'top-left':
         hovercardLeft = targetLeft - thisRect.width - this.offset;
-        hovercardTop = targetTop - thisRect.height - this.offset;
-        cssText += `padding-bottom:${diagonalPadding}px;`;
-        cssText += `padding-right:${diagonalPadding}px;`;
-        cssText += `margin-bottom:-${diagonalPadding}px;`;
-        cssText += `margin-right:-${diagonalPadding}px;`;
+        hovercardTop = targetTop + targetRect.height - thisRect.height;
         break;
       case 'top-right':
         hovercardLeft = targetLeft + targetRect.width + this.offset;
-        hovercardTop = targetTop - thisRect.height - this.offset;
-        cssText += `padding-bottom:${diagonalPadding}px;`;
-        cssText += `padding-left:${diagonalPadding}px;`;
-        cssText += `margin-bottom:-${diagonalPadding}px;`;
-        cssText += `margin-left:-${diagonalPadding}px;`;
+        hovercardTop = targetTop + targetRect.height - thisRect.height;
         break;
     }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.js
deleted file mode 100644
index 5fb1add..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/** The shared styles for all hover cards. */
-const GrHoverCardSharedStyle = document.createElement('dom-module');
-GrHoverCardSharedStyle.innerHTML =
-  `<template>
-    <style include="shared-styles">
-      :host {
-        position: absolute;
-        display: none;
-        z-index: 200;
-      }
-      :host(.hovered) {
-        display: block;
-      }
-      :host(.hide) {
-        visibility: hidden;
-      }
-      /* You have to use a <div class="container"> in your hovercard in order
-         to pick up this consistent styling. */
-      #container {
-        background: var(--dialog-background-color);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        box-shadow: var(--elevation-level-5);
-      }
-    </style>
-  </template>`;
-
-GrHoverCardSharedStyle.register('gr-hovercard-shared-style');
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts
new file mode 100644
index 0000000..aa92654
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+/** The shared styles for all hover cards. */
+const GrHoverCardSharedStyle = document.createElement('dom-module');
+GrHoverCardSharedStyle.innerHTML = `<template>
+    <style include="shared-styles">
+      :host {
+        position: absolute;
+        display: none;
+        z-index: 200;
+        max-width: 600px;
+        outline: none;
+      }
+      :host(.hovered) {
+        display: block;
+      }
+      :host(.hide) {
+        visibility: hidden;
+      }
+      /* You have to use a <div class="container"> in your hovercard in order
+         to pick up this consistent styling. */
+      #container {
+        background: var(--dialog-background-color);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        box-shadow: var(--elevation-level-5);
+      }
+    </style>
+  </template>`;
+
+GrHoverCardSharedStyle.register('gr-hovercard-shared-style');
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
index e77a4c5..e334064 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 
 import '../../../styles/shared-styles.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
@@ -24,7 +23,7 @@
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import './gr-hovercard-shared-style.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrHovercard extends GestureEventListeners(
     hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
 ) {
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js
deleted file mode 100644
index 67a3545..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="gr-hovercard-shared-style">
-    #container {
-      padding: var(--spacing-l);
-    }
-  </style>
-  <div id="container" role="tooltip" tabindex="-1">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts
new file mode 100644
index 0000000..830cbd878
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="gr-hovercard-shared-style">
+    #container {
+      padding: var(--spacing-l);
+    }
+  </style>
+  <div id="container" role="tooltip" tabindex="-1">
+    <slot></slot>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
deleted file mode 100644
index 21f692d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.html
+++ /dev/null
@@ -1,159 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-hovercard</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<!-- Can't use absolute path below for mock-interaction.js.
-Web component tester(wct) has a built-in http server and it serves "/components" directory (which is
-actually /node_modules directory). Also, wct patches some files to load modules from /components.
-With the absolute path, browser tries to load dom-module from 2 different places (/component/... and
-/node_modules/...) though this is actually the same file. This leads to a run-time error.
--->
-<script src="../../../node_modules/iron-test-helpers/mock-interactions.js"></script>
-
-<button id="foo">Hello</button>
-<test-fixture id="basic">
-  <template>
-    <gr-hovercard for="foo" id="bar"></gr-hovercard>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-hovercard.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-hovercard tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('updatePosition', () => {
-    // Test that the correct style properties have at least been set.
-    element.position = 'bottom';
-    element.updatePosition();
-    assert.typeOf(element.style.getPropertyValue('left'), 'string');
-    assert.typeOf(element.style.getPropertyValue('top'), 'string');
-    assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
-    assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
-
-    const parentRect = document.documentElement.getBoundingClientRect();
-    const targetRect = element._target.getBoundingClientRect();
-    const thisRect = element.getBoundingClientRect();
-
-    const targetLeft = targetRect.left - parentRect.left;
-    const targetTop = targetRect.top - parentRect.top;
-
-    const pixelCompare = pixel =>
-      Math.round(parseInt(pixel.substring(0, pixel.length - 1)), 10);
-
-    assert.equal(
-        pixelCompare(element.style.left),
-        pixelCompare(
-            (targetLeft + (targetRect.width - thisRect.width) / 2) + 'px'));
-    assert.equal(
-        pixelCompare(element.style.top),
-        pixelCompare(
-            (targetTop + targetRect.height + element.offset) + 'px'));
-  });
-
-  test('hide', () => {
-    element.hide({});
-    const style = getComputedStyle(element);
-    assert.isFalse(element._isShowing);
-    assert.isFalse(element.classList.contains('hovered'));
-    assert.equal(style.display, 'none');
-    assert.notEqual(element.container, dom(element).parentNode);
-  });
-
-  test('show', () => {
-    element.show({});
-    const style = getComputedStyle(element);
-    assert.isTrue(element._isShowing);
-    assert.isTrue(element.classList.contains('hovered'));
-    assert.equal(style.opacity, '1');
-    assert.equal(style.visibility, 'visible');
-  });
-
-  test('showDelayed does not show immediately', done => {
-    element.showDelayedBy(100);
-    setTimeout(() => {
-      assert.isFalse(element._isShowing);
-      done();
-    }, 0);
-  });
-
-  test('showDelayed shows after delay', done => {
-    element.showDelayedBy(1);
-    setTimeout(() => {
-      assert.isTrue(element._isShowing);
-      done();
-    }, 10);
-  });
-
-  test('card is scheduled to show on enter and hides on leave', done => {
-    const button = dom(document).querySelector('button');
-    assert.isFalse(element._isShowing);
-    const enterHandler = event => {
-      assert.isTrue(element._isScheduledToShow);
-      button.dispatchEvent(new CustomEvent('mouseleave'));
-    };
-    const leaveHandler = event => {
-      assert.isFalse(element._isScheduledToShow);
-      assert.isFalse(element._isShowing);
-      button.removeEventListener('mouseenter', enterHandler);
-      button.removeEventListener('mouseleave', leaveHandler);
-      done();
-    };
-    button.addEventListener('mouseenter', enterHandler);
-    button.addEventListener('mouseleave', leaveHandler);
-    button.dispatchEvent(new CustomEvent('mouseenter'));
-  });
-
-  test('card should disappear on click', done => {
-    const button = dom(document).querySelector('button');
-    assert.isFalse(element._isShowing);
-    const enterHandler = event => {
-      assert.isTrue(element._isScheduledToShow);
-      // click to hide
-      MockInteractions.tap(button);
-    };
-    const leaveHandler = event => {
-      assert.isFalse(element._isScheduledToShow);
-      assert.isFalse(element._isShowing);
-      button.removeEventListener('mouseenter', enterHandler);
-      button.removeEventListener('click', leaveHandler);
-      done();
-    };
-    button.addEventListener('mouseenter', enterHandler);
-    button.addEventListener('click', leaveHandler);
-    button.dispatchEvent(new CustomEvent('mouseenter'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
new file mode 100644
index 0000000..accb6d5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
@@ -0,0 +1,155 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-hovercard.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-hovercard for="foo" id="bar"></gr-hovercard>
+`);
+
+suite('gr-hovercard tests', () => {
+  let element;
+
+  let button;
+
+  setup(() => {
+    button = document.createElement('button');
+    button.innerHTML = 'Hello';
+    button.setAttribute('id', 'foo');
+    document.body.appendChild(button);
+
+    element = basicFixture.instantiate();
+  });
+
+  teardown(() => {
+    element.hide({});
+    button.remove();
+  });
+
+  test('updatePosition', () => {
+    // Test that the correct style properties have at least been set.
+    element.position = 'bottom';
+    element.updatePosition();
+    assert.typeOf(element.style.getPropertyValue('left'), 'string');
+    assert.typeOf(element.style.getPropertyValue('top'), 'string');
+    assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
+    assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
+
+    const parentRect = document.documentElement.getBoundingClientRect();
+    const targetRect = element._target.getBoundingClientRect();
+    const thisRect = element.getBoundingClientRect();
+
+    const targetLeft = targetRect.left - parentRect.left;
+    const targetTop = targetRect.top - parentRect.top;
+
+    const pixelCompare = pixel =>
+      Math.round(parseInt(pixel.substring(0, pixel.length - 1)), 10);
+
+    assert.equal(
+        pixelCompare(element.style.left),
+        pixelCompare(
+            (targetLeft + (targetRect.width - thisRect.width) / 2) + 'px'));
+    assert.equal(
+        pixelCompare(element.style.top),
+        pixelCompare(
+            (targetTop + targetRect.height + element.offset) + 'px'));
+  });
+
+  test('hide', () => {
+    element.hide({});
+    const style = getComputedStyle(element);
+    assert.isFalse(element._isShowing);
+    assert.isFalse(element.classList.contains('hovered'));
+    assert.equal(style.display, 'none');
+    assert.notEqual(element.container, dom(element).parentNode);
+  });
+
+  test('show', () => {
+    element.show({});
+    const style = getComputedStyle(element);
+    assert.isTrue(element._isShowing);
+    assert.isTrue(element.classList.contains('hovered'));
+    assert.equal(style.opacity, '1');
+    assert.equal(style.visibility, 'visible');
+  });
+
+  test('debounceShow does not show immediately', done => {
+    element.debounceShowBy(100);
+    setTimeout(() => {
+      assert.isFalse(element._isShowing);
+      done();
+    }, 0);
+  });
+
+  test('debounceShow shows after delay', done => {
+    element.debounceShowBy(1);
+    setTimeout(() => {
+      assert.isTrue(element._isShowing);
+      done();
+    }, 10);
+  });
+
+  test('card is scheduled to show on enter and hides on leave', done => {
+    const button = dom(document).querySelector('button');
+    assert.isFalse(element._isShowing);
+    const enterHandler = event => {
+      assert.isTrue(element._isScheduledToShow);
+      element._showDebouncer.flush();
+      assert.isTrue(element._isShowing);
+      assert.isFalse(element._isScheduledToShow);
+      button.dispatchEvent(new CustomEvent('mouseleave'));
+    };
+    const leaveHandler = event => {
+      assert.isTrue(element._isScheduledToHide);
+      assert.isTrue(element._isShowing);
+      element._hideDebouncer.flush();
+      assert.isFalse(element._isScheduledToShow);
+      assert.isFalse(element._isShowing);
+      button.removeEventListener('mouseenter', enterHandler);
+      button.removeEventListener('mouseleave', leaveHandler);
+      done();
+    };
+    button.addEventListener('mouseenter', enterHandler);
+    button.addEventListener('mouseleave', leaveHandler);
+    button.dispatchEvent(new CustomEvent('mouseenter'));
+  });
+
+  test('card should disappear on click', done => {
+    const button = dom(document).querySelector('button');
+    assert.isFalse(element._isShowing);
+    const enterHandler = event => {
+      assert.isTrue(element._isScheduledToShow);
+      // click to hide
+      MockInteractions.tap(button);
+    };
+    const leaveHandler = event => {
+      // no flush needed as hide will be called immediately
+      assert.isFalse(element._isScheduledToShow);
+      assert.isFalse(element._isShowing);
+      button.removeEventListener('mouseenter', enterHandler);
+      button.removeEventListener('click', leaveHandler);
+      done();
+    };
+    button.addEventListener('mouseenter', enterHandler);
+    button.addEventListener('click', leaveHandler);
+    button.dispatchEvent(new CustomEvent('mouseenter'));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
deleted file mode 100644
index 84d0d2a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.js
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '@polymer/iron-icon/iron-icon.js';
-import '@polymer/iron-iconset-svg/iron-iconset-svg.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<iron-iconset-svg name="gr-icons" size="24">
-  <svg>
-    <defs>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#unfold_more -->
-      <g id="unfold-more"><path d="M0 0h24v24H0z" fill="none"></path><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="create"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="star"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="star-border"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="close"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="chevron-left"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="chevron-right"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="more-vert"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="deleteEdit"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
-      <g id="publishEdit"><path d="M5 4v2h14V4H5zm0 10h4v6h6v-6h4l-7-7-7 7z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
-      <g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="help"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
-      <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"></path><path d="M0 0h24v24H0V0z" fill="none"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#mode_comment-->
-      <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#calendar_today-->
-      <g id="calendar"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="error"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="lightbulb-outline"><path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="side-by-side"><path d="M17.1578947,10.8888889 L2.84210526,10.8888889 C2.37894737,10.8888889 2,11.2888889 2,11.7777778 L2,17.1111111 C2,17.6 2.37894737,18 2.84210526,18 L17.1578947,18 C17.6210526,18 18,17.6 18,17.1111111 L18,11.7777778 C18,11.2888889 17.6210526,10.8888889 17.1578947,10.8888889 Z M17.1578947,2 L2.84210526,2 C2.37894737,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.37894737,9.11111111 2.84210526,9.11111111 L17.1578947,9.11111111 C17.6210526,9.11111111 18,8.71111111 18,8.22222222 L18,2.88888889 C18,2.4 17.6210526,2 17.1578947,2 Z M16.1973628,2 L2.78874238,2 C2.35493407,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.35493407,9.11111111 2.78874238,9.11111111 L16.1973628,9.11111111 C16.6311711,9.11111111 16.9861052,8.71111111 16.9861052,8.22222222 L16.9861052,2.88888889 C16.9861052,2.4 16.6311711,2 16.1973628,2 Z" id="Shape" transform="scale(1.2) translate(10.000000, 10.000000) rotate(-90.000000) translate(-10.000000, -10.000000)"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.12, 1.2)"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="robot"><path d="M4.137453,5.61015591 L4.54835569,1.5340419 C4.5717665,1.30180904 4.76724872,1.12504213 5.00065859,1.12504213 C5.23327176,1.12504213 5.42730868,1.30282046 5.44761309,1.53454578 L5.76084628,5.10933916 C6.16304484,5.03749412 6.57714381,5 7,5 L17,5 C20.8659932,5 24,8.13400675 24,12 L24,15.1250421 C24,18.9910354 20.8659932,22.1250421 17,22.1250421 L7,22.1250421 C3.13400675,22.1250421 2.19029351e-15,18.9910354 0,15.1250421 L0,12 C-3.48556243e-16,9.15382228 1.69864167,6.70438358 4.137453,5.61015591 Z M5.77553049,6.12504213 C3.04904264,6.69038358 1,9.10590202 1,12 L1,15.1250421 C1,18.4387506 3.6862915,21.1250421 7,21.1250421 L17,21.1250421 C20.3137085,21.1250421 23,18.4387506 23,15.1250421 L23,12 C23,8.6862915 20.3137085,6 17,6 L7,6 C6.60617231,6 6.2212068,6.03794347 5.84855971,6.11037415 L5.84984496,6.12504213 L5.77553049,6.12504213 Z M6.93003717,6.95027711 L17.1232083,6.95027711 C19.8638332,6.95027711 22.0855486,9.17199258 22.0855486,11.9126175 C22.0855486,14.6532424 19.8638332,16.8749579 17.1232083,16.8749579 L6.93003717,16.8749579 C4.18941226,16.8749579 1.9676968,14.6532424 1.9676968,11.9126175 C1.9676968,9.17199258 4.18941226,6.95027711 6.93003717,6.95027711 Z M7.60124392,14.0779303 C9.03787127,14.0779303 10.2024878,12.9691885 10.2024878,11.6014862 C10.2024878,10.2337839 9.03787127,9.12504213 7.60124392,9.12504213 C6.16461657,9.12504213 5,10.2337839 5,11.6014862 C5,12.9691885 6.16461657,14.0779303 7.60124392,14.0779303 Z M16.617997,14.1098288 C18.0638768,14.1098288 19.2359939,12.9939463 19.2359939,11.6174355 C19.2359939,10.2409246 18.0638768,9.12504213 16.617997,9.12504213 C15.1721172,9.12504213 14,10.2409246 14,11.6174355 C14,12.9939463 15.1721172,14.1098288 16.617997,14.1098288 Z M9.79751216,18.1250421 L15,18.1250421 L15,19.1250421 C15,19.6773269 14.5522847,20.1250421 14,20.1250421 L10.7975122,20.1250421 C10.2452274,20.1250421 9.79751216,19.6773269 9.79751216,19.1250421 L9.79751216,18.1250421 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="abandon"><path d="M17.65675,17.65725 C14.77275,20.54125 10.23775,20.75625 7.09875,18.31525 L18.31475,7.09925 C20.75575,10.23825 20.54075,14.77325 17.65675,17.65725 M6.34275,6.34325 C9.22675,3.45925 13.76275,3.24425 16.90075,5.68525 L5.68475,16.90125 C3.24375,13.76325 3.45875,9.22725 6.34275,6.34325 M19.07075,4.92925 C15.16575,1.02425 8.83375,1.02425 4.92875,4.92925 C1.02375,8.83425 1.02375,15.16625 4.92875,19.07125 C8.83375,22.97625 15.16575,22.97625 19.07075,19.07125 C22.97575,15.16625 22.97575,8.83425 19.07075,4.92925"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="edit"><path d="M3,17.2525 L3,21.0025 L6.75,21.0025 L17.81,9.9425 L14.06,6.1925 L3,17.2525 L3,17.2525 Z M20.71,7.0425 C21.1,6.6525 21.1,6.0225 20.71,5.6325 L18.37,3.2925 C17.98,2.9025 17.35,2.9025 16.96,3.2925 L15.13,5.1225 L18.88,8.8725 L20.71,7.0425 L20.71,7.0425 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="rebase"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="rebaseEdit"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="restore"><path d="M12,8 L12,13 L16.28,15.54 L17,14.33 L13.5,12.25 L13.5,8 L12,8 Z M13,3 C8.03,3 4,7.03 4,12 L1,12 L4.89,15.89 L4.96,16.03 L9,12 L6,12 C6,8.13 9.13,5 13,5 C16.87,5 20,8.13 20,12 C20,15.87 16.87,19 13,19 C11.07,19 9.32,18.21 8.06,16.94 L6.64,18.36 C8.27,19.99 10.51,21 13,21 C17.97,21 22,16.97 22,12 C22,7.03 17.97,3 13,3 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="revert"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
-      <g id="revert_submission"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="stopEdit"><path d="M4 4 20 4 20 20 4 20z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="submit"><path d="M22.23,5 L11.65,15.58 L7.47000001,11.41 L6.06000001,12.82 L11.65,18.41 L23.649,6.41 L22.23,5 Z M16.58,5 L10.239,11.34 L11.65,12.75 L17.989,6.41 L16.58,5 Z M0.400000006,12.82 L5.99000001,18.41 L7.40000001,17 L1.82000001,11.41 L0.400000006,12.82 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"></path></g>
-      <!-- This SVG is an adaptation of material.io https://material.io/icons/#label_important-->
-      <g id="attention"><path d="M5.5 19 l9 0 c.67 0 1.27 -.33 1.63 -.84 L20.5 12 l-4.37 -6.16 c-.36 -.51 -.96 -.84 -1.63 -.84 l-9 0 L9 12 z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#visibility-->
-      <g id="ready"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></g>
-    </defs>
-  </svg>
-</iron-iconset-svg>`;
-
-document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
new file mode 100644
index 0000000..21e4073
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '@polymer/iron-icon/iron-icon';
+import '@polymer/iron-iconset-svg/iron-iconset-svg';
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<iron-iconset-svg name="gr-icons" size="24">
+  <svg>
+    <defs>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#unfold_more -->
+      <g id="unfold-more"><path d="M0 0h24v24H0z" fill="none"></path><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="create"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="star"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="star-border"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="close"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="chevron-left"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="chevron-right"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="more-vert"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="deleteEdit"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
+      <g id="publishEdit"><path d="M5 4v2h14V4H5zm0 10h4v6h6v-6h4l-7-7-7 7z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
+      <g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="help"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/resources/icons/?icon=help_outline -->
+      <g id="help-outline"><path d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="info-outline"><path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
+      <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"></path><path d="M0 0h24v24H0V0z" fill="none"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#mode_comment-->
+      <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#calendar_today-->
+      <g id="calendar"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="error"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="lightbulb-outline"><path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="side-by-side"><path d="M17.1578947,10.8888889 L2.84210526,10.8888889 C2.37894737,10.8888889 2,11.2888889 2,11.7777778 L2,17.1111111 C2,17.6 2.37894737,18 2.84210526,18 L17.1578947,18 C17.6210526,18 18,17.6 18,17.1111111 L18,11.7777778 C18,11.2888889 17.6210526,10.8888889 17.1578947,10.8888889 Z M17.1578947,2 L2.84210526,2 C2.37894737,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.37894737,9.11111111 2.84210526,9.11111111 L17.1578947,9.11111111 C17.6210526,9.11111111 18,8.71111111 18,8.22222222 L18,2.88888889 C18,2.4 17.6210526,2 17.1578947,2 Z M16.1973628,2 L2.78874238,2 C2.35493407,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.35493407,9.11111111 2.78874238,9.11111111 L16.1973628,9.11111111 C16.6311711,9.11111111 16.9861052,8.71111111 16.9861052,8.22222222 L16.9861052,2.88888889 C16.9861052,2.4 16.6311711,2 16.1973628,2 Z" id="Shape" transform="scale(1.2) translate(10.000000, 10.000000) rotate(-90.000000) translate(-10.000000, -10.000000)"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.12, 1.2)"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="robot"><path d="M4.137453,5.61015591 L4.54835569,1.5340419 C4.5717665,1.30180904 4.76724872,1.12504213 5.00065859,1.12504213 C5.23327176,1.12504213 5.42730868,1.30282046 5.44761309,1.53454578 L5.76084628,5.10933916 C6.16304484,5.03749412 6.57714381,5 7,5 L17,5 C20.8659932,5 24,8.13400675 24,12 L24,15.1250421 C24,18.9910354 20.8659932,22.1250421 17,22.1250421 L7,22.1250421 C3.13400675,22.1250421 2.19029351e-15,18.9910354 0,15.1250421 L0,12 C-3.48556243e-16,9.15382228 1.69864167,6.70438358 4.137453,5.61015591 Z M5.77553049,6.12504213 C3.04904264,6.69038358 1,9.10590202 1,12 L1,15.1250421 C1,18.4387506 3.6862915,21.1250421 7,21.1250421 L17,21.1250421 C20.3137085,21.1250421 23,18.4387506 23,15.1250421 L23,12 C23,8.6862915 20.3137085,6 17,6 L7,6 C6.60617231,6 6.2212068,6.03794347 5.84855971,6.11037415 L5.84984496,6.12504213 L5.77553049,6.12504213 Z M6.93003717,6.95027711 L17.1232083,6.95027711 C19.8638332,6.95027711 22.0855486,9.17199258 22.0855486,11.9126175 C22.0855486,14.6532424 19.8638332,16.8749579 17.1232083,16.8749579 L6.93003717,16.8749579 C4.18941226,16.8749579 1.9676968,14.6532424 1.9676968,11.9126175 C1.9676968,9.17199258 4.18941226,6.95027711 6.93003717,6.95027711 Z M7.60124392,14.0779303 C9.03787127,14.0779303 10.2024878,12.9691885 10.2024878,11.6014862 C10.2024878,10.2337839 9.03787127,9.12504213 7.60124392,9.12504213 C6.16461657,9.12504213 5,10.2337839 5,11.6014862 C5,12.9691885 6.16461657,14.0779303 7.60124392,14.0779303 Z M16.617997,14.1098288 C18.0638768,14.1098288 19.2359939,12.9939463 19.2359939,11.6174355 C19.2359939,10.2409246 18.0638768,9.12504213 16.617997,9.12504213 C15.1721172,9.12504213 14,10.2409246 14,11.6174355 C14,12.9939463 15.1721172,14.1098288 16.617997,14.1098288 Z M9.79751216,18.1250421 L15,18.1250421 L15,19.1250421 C15,19.6773269 14.5522847,20.1250421 14,20.1250421 L10.7975122,20.1250421 C10.2452274,20.1250421 9.79751216,19.6773269 9.79751216,19.1250421 L9.79751216,18.1250421 Z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="abandon"><path d="M17.65675,17.65725 C14.77275,20.54125 10.23775,20.75625 7.09875,18.31525 L18.31475,7.09925 C20.75575,10.23825 20.54075,14.77325 17.65675,17.65725 M6.34275,6.34325 C9.22675,3.45925 13.76275,3.24425 16.90075,5.68525 L5.68475,16.90125 C3.24375,13.76325 3.45875,9.22725 6.34275,6.34325 M19.07075,4.92925 C15.16575,1.02425 8.83375,1.02425 4.92875,4.92925 C1.02375,8.83425 1.02375,15.16625 4.92875,19.07125 C8.83375,22.97625 15.16575,22.97625 19.07075,19.07125 C22.97575,15.16625 22.97575,8.83425 19.07075,4.92925"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="edit"><path d="M3,17.2525 L3,21.0025 L6.75,21.0025 L17.81,9.9425 L14.06,6.1925 L3,17.2525 L3,17.2525 Z M20.71,7.0425 C21.1,6.6525 21.1,6.0225 20.71,5.6325 L18.37,3.2925 C17.98,2.9025 17.35,2.9025 16.96,3.2925 L15.13,5.1225 L18.88,8.8725 L20.71,7.0425 L20.71,7.0425 Z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="rebase"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="rebaseEdit"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="restore"><path d="M12,8 L12,13 L16.28,15.54 L17,14.33 L13.5,12.25 L13.5,8 L12,8 Z M13,3 C8.03,3 4,7.03 4,12 L1,12 L4.89,15.89 L4.96,16.03 L9,12 L6,12 C6,8.13 9.13,5 13,5 C16.87,5 20,8.13 20,12 C20,15.87 16.87,19 13,19 C11.07,19 9.32,18.21 8.06,16.94 L6.64,18.36 C8.27,19.99 10.51,21 13,21 C17.97,21 22,16.97 22,12 C22,7.03 17.97,3 13,3 Z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="revert"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
+      <g id="revert_submission"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="stopEdit"><path d="M4 4 20 4 20 20 4 20z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="submit"><path d="M22.23,5 L11.65,15.58 L7.47000001,11.41 L6.06000001,12.82 L11.65,18.41 L23.649,6.41 L22.23,5 Z M16.58,5 L10.239,11.34 L11.65,12.75 L17.989,6.41 L16.58,5 Z M0.400000006,12.82 L5.99000001,18.41 L7.40000001,17 L1.82000001,11.41 L0.400000006,12.82 Z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"></path></g>
+      <!-- This SVG is an adaptation of material.io https://material.io/icons/#label_important-->
+      <g id="attention"><path d="M1 23 l13 0 c.67 0 1.27 -.33 1.63 -.84 l7.37 -10.16 l-7.37 -10.16 c-.36 -.51 -.96 -.84 -1.63 -.84 L1 1 L7 12 z"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#pets-->
+      <g id="pets"><circle cx="4.5" cy="9.5" r="2.5"/><circle cx="9" cy="5.5" r="2.5"/><circle cx="15" cy="5.5" r="2.5"/><circle cx="19.5" cy="9.5" r="2.5"/><path d="M17.34 14.86c-.87-1.02-1.6-1.89-2.48-2.91-.46-.54-1.05-1.08-1.75-1.32-.11-.04-.22-.07-.33-.09-.25-.04-.52-.04-.78-.04s-.53 0-.79.05c-.11.02-.22.05-.33.09-.7.24-1.28.78-1.75 1.32-.87 1.02-1.6 1.89-2.48 2.91-1.31 1.31-2.92 2.76-2.62 4.79.29 1.02 1.02 2.03 2.33 2.32.73.15 3.06-.44 5.54-.44h.18c2.48 0 4.81.58 5.54.44 1.31-.29 2.04-1.31 2.33-2.32.31-2.04-1.3-3.49-2.61-4.8z"/><path d="M0 0h24v24H0z" fill="none"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#visibility-->
+      <g id="ready"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons -->
+      <g id="schedule"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#bug_report-->
+      <g id="bug"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></g>
+    </defs>
+  </svg>
+</iron-iconset-svg>`;
+
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
deleted file mode 100644
index a14612b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.html
+++ /dev/null
@@ -1,104 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-annotation-actions-context</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-js-api-interface.js';
-import {GrAnnotation} from '../../diff/gr-diff-highlight/gr-annotation.js';
-import {GrAnnotationActionsContext} from './gr-annotation-actions-context.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-annotation-actions-context tests', () => {
-  let instance;
-  let sandbox;
-  let el;
-  let lineNumberEl;
-  let plugin;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-
-    const str = 'lorem ipsum blah blah';
-    const line = {text: str};
-    el = document.createElement('div');
-    el.textContent = str;
-    el.setAttribute('data-side', 'right');
-    lineNumberEl = document.createElement('td');
-    lineNumberEl.classList.add('right');
-    document.body.appendChild(el);
-    instance = new GrAnnotationActionsContext(
-        el, lineNumberEl, line, 'dummy/path', '123', '1');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('test annotateRange', () => {
-    const annotateElementSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-    const start = 0;
-    const end = 100;
-    const cssStyleObject = plugin.styles().css('background-color: #000000');
-
-    // Assert annotateElement is not called when side is different.
-    instance.annotateRange(start, end, cssStyleObject, 'left');
-    assert.equal(annotateElementSpy.callCount, 0);
-
-    // Assert annotateElement is called once when side is the same.
-    instance.annotateRange(start, end, cssStyleObject, 'right');
-    assert.equal(annotateElementSpy.callCount, 1);
-    const args = annotateElementSpy.getCalls()[0].args;
-    assert.equal(args[0], el);
-    assert.equal(args[1], start);
-    assert.equal(args[2], end);
-    assert.equal(args[3], cssStyleObject.getClassName(el));
-  });
-
-  test('test annotateLineNumber', () => {
-    const cssStyleObject = plugin.styles().css('background-color: #000000');
-
-    const className = cssStyleObject.getClassName(lineNumberEl);
-
-    // Assert that css class is *not* applied when side is different.
-    instance.annotateLineNumber(cssStyleObject, 'left');
-    assert.isFalse(lineNumberEl.classList.contains(className));
-
-    // Assert that css class is applied when side is the same.
-    instance.annotateLineNumber(cssStyleObject, 'right');
-    assert.isTrue(lineNumberEl.classList.contains(className));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.js
new file mode 100644
index 0000000..b46a3b0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context_test.js
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-js-api-interface.js';
+import {GrAnnotation} from '../../diff/gr-diff-highlight/gr-annotation.js';
+import {GrAnnotationActionsContext} from './gr-annotation-actions-context.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-annotation-actions-context tests', () => {
+  let instance;
+
+  let el;
+  let lineNumberEl;
+  let plugin;
+
+  setup(() => {
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+
+    const str = 'lorem ipsum blah blah';
+    const line = {text: str};
+    el = document.createElement('div');
+    el.textContent = str;
+    el.setAttribute('data-side', 'right');
+    lineNumberEl = document.createElement('td');
+    lineNumberEl.classList.add('right');
+    document.body.appendChild(el);
+    instance = new GrAnnotationActionsContext(
+        el, lineNumberEl, line, 'dummy/path', '123', '1');
+  });
+
+  teardown(() => {
+    el.remove();
+  });
+
+  test('test annotateRange', () => {
+    const annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+    const start = 0;
+    const end = 100;
+    const cssStyleObject = plugin.styles().css('background-color: #000000');
+
+    // Assert annotateElement is not called when side is different.
+    instance.annotateRange(start, end, cssStyleObject, 'left');
+    assert.equal(annotateElementSpy.callCount, 0);
+
+    // Assert annotateElement is called once when side is the same.
+    instance.annotateRange(start, end, cssStyleObject, 'right');
+    assert.equal(annotateElementSpy.callCount, 1);
+    const args = annotateElementSpy.getCalls()[0].args;
+    assert.equal(args[0], el);
+    assert.equal(args[1], start);
+    assert.equal(args[2], end);
+    assert.equal(args[3], cssStyleObject.getClassName(el));
+  });
+
+  test('test annotateLineNumber', () => {
+    const cssStyleObject = plugin.styles().css('background-color: #000000');
+
+    const className = cssStyleObject.getClassName(lineNumberEl);
+
+    // Assert that css class is *not* applied when side is different.
+    instance.annotateLineNumber(cssStyleObject, 'left');
+    assert.isFalse(lineNumberEl.classList.contains(className));
+
+    // Assert that css class is applied when side is the same.
+    instance.annotateLineNumber(cssStyleObject, 'right');
+    assert.isTrue(lineNumberEl.classList.contains(className));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
index 3b24404..6f73c36 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
@@ -144,7 +144,6 @@
     // path.
     if (annotationLayer._path === path) {
       annotationLayer.notifyListeners(startRange, endRange, side);
-      break;
     }
   }
 };
@@ -153,6 +152,8 @@
  * Should be called to register annotation layers by the framework. Not
  * intended to be called by plugins.
  *
+ * Don't forget to dispose layer.
+ *
  * @param {string} path The file path (eg: /COMMIT_MSG').
  * @param {string} changeNum The Gerrit change number.
  * @param {string} patchNum The Gerrit patch number.
@@ -165,6 +166,11 @@
   return annotationLayer;
 };
 
+GrAnnotationActionsInterface.prototype.disposeLayer = function(path) {
+  this._annotationLayers = this._annotationLayers
+      .filter(annotationLayer => annotationLayer._path !== path);
+};
+
 /**
  * Used to create an instance of the Annotation Layer interface.
  *
@@ -186,6 +192,7 @@
 
 /**
  * Register a listener for layer updates.
+ * Don't forget to removeListener when you stop using layer.
  *
  * @param {Function} fn The update handler function.
  *     Should accept as arguments the line numbers for the start and end of
@@ -195,6 +202,10 @@
   this._listeners.push(fn);
 };
 
+AnnotationLayer.prototype.removeListener = function(fn) {
+  this._listeners = this._listeners.filter(f => f != fn);
+};
+
 /**
  * Layer method to add annotations to a line.
  *
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
deleted file mode 100644
index e72fc63..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html
+++ /dev/null
@@ -1,192 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-annotation-actions-js-api-js-api</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<test-fixture id="basic">
-  <template>
-    <span hidden id="annotation-span">
-      <label for="annotation-checkbox" id="annotation-label"></label>
-      <iron-input type="checkbox" disabled>
-        <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled>
-      </iron-input>
-    </span>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../change/gr-change-actions/gr-change-actions.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-annotation-actions-js-api tests', () => {
-  let annotationActions;
-  let sandbox;
-  let plugin;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    annotationActions = plugin.annotationApi();
-  });
-
-  teardown(() => {
-    annotationActions = null;
-    sandbox.restore();
-  });
-
-  test('add/get layer', () => {
-    const str = 'lorem ipsum blah blah';
-    const line = {text: str};
-    const el = document.createElement('div');
-    el.textContent = str;
-    const changeNum = 1234;
-    const patchNum = 2;
-    let testLayerFuncCalled = false;
-
-    const testLayerFunc = context => {
-      testLayerFuncCalled = true;
-      assert.equal(context.line, line);
-      assert.equal(context.changeNum, changeNum);
-      assert.equal(context.patchNum, 2);
-    };
-    annotationActions.addLayer(testLayerFunc);
-
-    const annotationLayer = annotationActions.getLayer(
-        '/dummy/path', changeNum, patchNum);
-
-    const lineNumberEl = document.createElement('td');
-    annotationLayer.annotate(el, lineNumberEl, line);
-    assert.isTrue(testLayerFuncCalled);
-  });
-
-  test('add notifier', () => {
-    const path1 = '/dummy/path1';
-    const path2 = '/dummy/path2';
-    const annotationLayer1 = annotationActions.getLayer(path1, 1, 2);
-    const annotationLayer2 = annotationActions.getLayer(path2, 1, 2);
-    const layer1Spy = sandbox.spy(annotationLayer1, 'notifyListeners');
-    const layer2Spy = sandbox.spy(annotationLayer2, 'notifyListeners');
-
-    let notify;
-    let notifyFuncCalled;
-    const notifyFunc = n => {
-      notifyFuncCalled = true;
-      notify = n;
-    };
-    annotationActions.addNotifier(notifyFunc);
-    assert.isTrue(notifyFuncCalled);
-
-    // Assert that no layers are invoked with a different path.
-    notify('/dummy/path3', 0, 10, 'right');
-    assert.isFalse(layer1Spy.called);
-    assert.isFalse(layer2Spy.called);
-
-    // Assert that only the 1st layer is invoked with path1.
-    notify(path1, 0, 10, 'right');
-    assert.isTrue(layer1Spy.called);
-    assert.isFalse(layer2Spy.called);
-
-    // Reset spies.
-    layer1Spy.reset();
-    layer2Spy.reset();
-
-    // Assert that only the 2nd layer is invoked with path2.
-    notify(path2, 0, 20, 'left');
-    assert.isFalse(layer1Spy.called);
-    assert.isTrue(layer2Spy.called);
-  });
-
-  test('toggle checkbox', () => {
-    const fakeEl = {content: fixture('basic')};
-    const hookStub = {onAttached: sandbox.stub()};
-    sandbox.stub(plugin, 'hook').returns(hookStub);
-
-    let checkbox;
-    let onAttachedFuncCalled = false;
-    const onAttachedFunc = c => {
-      checkbox = c;
-      onAttachedFuncCalled = true;
-    };
-    annotationActions.enableToggleCheckbox('test label', onAttachedFunc);
-    const emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
-    emulateAttached();
-
-    // Assert that onAttachedFunc is called and HTML elements have the
-    // expected state.
-    assert.isTrue(onAttachedFuncCalled);
-    assert.equal(checkbox.id, 'annotation-checkbox');
-    assert.isTrue(checkbox.disabled);
-    assert.equal(document.getElementById('annotation-label').textContent,
-        'test label');
-    assert.isFalse(document.getElementById('annotation-span').hidden);
-
-    // Assert that error is shown if we try to enable checkbox again.
-    onAttachedFuncCalled = false;
-    annotationActions.enableToggleCheckbox('test label2', onAttachedFunc);
-    const errorStub = sandbox.stub(
-        console, 'error', (msg, err) => undefined);
-    emulateAttached();
-    assert.isTrue(
-        errorStub.calledWith(
-            'annotation-span is already enabled. Cannot re-enable.'));
-    // Assert that onAttachedFunc is not called and the label has not changed.
-    assert.isFalse(onAttachedFuncCalled);
-    assert.equal(document.getElementById('annotation-label').textContent,
-        'test label');
-  });
-
-  test('layer notify listeners', () => {
-    const annotationLayer = annotationActions.getLayer(
-        '/dummy/path', 1, 2);
-    let listenerCalledTimes = 0;
-    const startRange = 10;
-    const endRange = 20;
-    const side = 'right';
-    const listener = (st, end, s) => {
-      listenerCalledTimes++;
-      assert.equal(st, startRange);
-      assert.equal(end, endRange);
-      assert.equal(s, side);
-    };
-
-    // Notify with 0 listeners added.
-    annotationLayer.notifyListeners(startRange, endRange, side);
-    assert.equal(listenerCalledTimes, 0);
-
-    // Add 1 listener.
-    annotationLayer.addListener(listener);
-    annotationLayer.notifyListeners(startRange, endRange, side);
-    assert.equal(listenerCalledTimes, 1);
-
-    // Add 1 more listener. Total 2 listeners.
-    annotationLayer.addListener(listener);
-    annotationLayer.notifyListeners(startRange, endRange, side);
-    assert.equal(listenerCalledTimes, 3);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
new file mode 100644
index 0000000..e819529
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
@@ -0,0 +1,179 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../change/gr-change-actions/gr-change-actions.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+  <span hidden id="annotation-span">
+    <label for="annotation-checkbox" id="annotation-label"></label>
+    <iron-input type="checkbox" disabled>
+      <input is="iron-input" type="checkbox" id="annotation-checkbox" disabled>
+    </iron-input>
+  </span>
+`);
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-annotation-actions-js-api tests', () => {
+  let annotationActions;
+
+  let plugin;
+
+  setup(() => {
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    annotationActions = plugin.annotationApi();
+  });
+
+  teardown(() => {
+    annotationActions = null;
+  });
+
+  test('add/get layer', () => {
+    const str = 'lorem ipsum blah blah';
+    const line = {text: str};
+    const el = document.createElement('div');
+    el.textContent = str;
+    const changeNum = 1234;
+    const patchNum = 2;
+    let testLayerFuncCalled = false;
+
+    const testLayerFunc = context => {
+      testLayerFuncCalled = true;
+      assert.equal(context.line, line);
+      assert.equal(context.changeNum, changeNum);
+      assert.equal(context.patchNum, 2);
+    };
+    annotationActions.addLayer(testLayerFunc);
+
+    const annotationLayer = annotationActions.getLayer(
+        '/dummy/path', changeNum, patchNum);
+
+    const lineNumberEl = document.createElement('td');
+    annotationLayer.annotate(el, lineNumberEl, line);
+    assert.isTrue(testLayerFuncCalled);
+  });
+
+  test('add notifier', () => {
+    const path1 = '/dummy/path1';
+    const path2 = '/dummy/path2';
+    const annotationLayer1 = annotationActions.getLayer(path1, 1, 2);
+    const annotationLayer2 = annotationActions.getLayer(path2, 1, 2);
+    const layer1Spy = sinon.spy(annotationLayer1, 'notifyListeners');
+    const layer2Spy = sinon.spy(annotationLayer2, 'notifyListeners');
+
+    let notify;
+    let notifyFuncCalled;
+    const notifyFunc = n => {
+      notifyFuncCalled = true;
+      notify = n;
+    };
+    annotationActions.addNotifier(notifyFunc);
+    assert.isTrue(notifyFuncCalled);
+
+    // Assert that no layers are invoked with a different path.
+    notify('/dummy/path3', 0, 10, 'right');
+    assert.isFalse(layer1Spy.called);
+    assert.isFalse(layer2Spy.called);
+
+    // Assert that only the 1st layer is invoked with path1.
+    notify(path1, 0, 10, 'right');
+    assert.isTrue(layer1Spy.called);
+    assert.isFalse(layer2Spy.called);
+
+    // Reset spies.
+    layer1Spy.resetHistory();
+    layer2Spy.resetHistory();
+
+    // Assert that only the 2nd layer is invoked with path2.
+    notify(path2, 0, 20, 'left');
+    assert.isFalse(layer1Spy.called);
+    assert.isTrue(layer2Spy.called);
+  });
+
+  test('toggle checkbox', () => {
+    const fakeEl = {content: basicFixture.instantiate()};
+    const hookStub = {onAttached: sinon.stub()};
+    sinon.stub(plugin, 'hook').returns(hookStub);
+
+    let checkbox;
+    let onAttachedFuncCalled = false;
+    const onAttachedFunc = c => {
+      checkbox = c;
+      onAttachedFuncCalled = true;
+    };
+    annotationActions.enableToggleCheckbox('test label', onAttachedFunc);
+    const emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
+    emulateAttached();
+
+    // Assert that onAttachedFunc is called and HTML elements have the
+    // expected state.
+    assert.isTrue(onAttachedFuncCalled);
+    assert.equal(checkbox.id, 'annotation-checkbox');
+    assert.isTrue(checkbox.disabled);
+    assert.equal(document.getElementById('annotation-label').textContent,
+        'test label');
+    assert.isFalse(document.getElementById('annotation-span').hidden);
+
+    // Assert that error is shown if we try to enable checkbox again.
+    onAttachedFuncCalled = false;
+    annotationActions.enableToggleCheckbox('test label2', onAttachedFunc);
+    const errorStub = sinon.stub(
+        console, 'error').callsFake((msg, err) => undefined);
+    emulateAttached();
+    assert.isTrue(
+        errorStub.calledWith(
+            'annotation-span is already enabled. Cannot re-enable.'));
+    // Assert that onAttachedFunc is not called and the label has not changed.
+    assert.isFalse(onAttachedFuncCalled);
+    assert.equal(document.getElementById('annotation-label').textContent,
+        'test label');
+  });
+
+  test('layer notify listeners', () => {
+    const annotationLayer = annotationActions.getLayer(
+        '/dummy/path', 1, 2);
+    let listenerCalledTimes = 0;
+    const startRange = 10;
+    const endRange = 20;
+    const side = 'right';
+    const listener = (st, end, s) => {
+      listenerCalledTimes++;
+      assert.equal(st, startRange);
+      assert.equal(end, endRange);
+      assert.equal(s, side);
+    };
+
+    // Notify with 0 listeners added.
+    annotationLayer.notifyListeners(startRange, endRange, side);
+    assert.equal(listenerCalledTimes, 0);
+
+    // Add 1 listener.
+    annotationLayer.addListener(listener);
+    annotationLayer.notifyListeners(startRange, endRange, side);
+    assert.equal(listenerCalledTimes, 1);
+
+    // Add 1 more listener. Total 2 listeners.
+    annotationLayer.addListener(listener);
+    annotationLayer.notifyListeners(startRange, endRange, side);
+    assert.equal(listenerCalledTimes, 3);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
index 5b58a5c..9bef3a2 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
 
 export const PRELOADED_PROTOCOL = 'preloaded:';
 export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
@@ -28,10 +28,6 @@
   return _restAPI;
 }
 
-export function getBaseUrl() {
-  return BaseUrlBehavior.getBaseUrl();
-}
-
 /**
  * Retrieves the name of the plugin base on the url.
  *
@@ -49,14 +45,17 @@
   if (url.protocol === PRELOADED_PROTOCOL) {
     return url.pathname;
   }
-  const base = BaseUrlBehavior.getBaseUrl();
+  const base = getBaseUrl();
   let pathname = url.pathname.replace(base, '');
   // Load from ASSETS_PATH
   if (window.ASSETS_PATH && url.href.includes(window.ASSETS_PATH)) {
     pathname = url.href.replace(window.ASSETS_PATH, '');
   }
   // Site theme is server from predefined path.
-  if (pathname === '/static/gerrit-theme.html') {
+  if ([
+    '/static/gerrit-theme.html',
+    '/static/gerrit-theme.js',
+  ].includes(pathname)) {
     return 'gerrit-theme';
   } else if (!pathname.startsWith('/plugins')) {
     console.warn('Plugin not being loaded from /plugins base path:',
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
deleted file mode 100644
index d01566a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.html
+++ /dev/null
@@ -1,85 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-api-interface</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-js-api-interface.js';
-import {getPluginNameFromUrl} from './gr-api-utils.js';
-
-const PRELOADED_PROTOCOL = 'preloaded:';
-
-suite('gr-api-utils tests', () => {
-  suite('test getPluginNameFromUrl', () => {
-    test('with empty string', () => {
-      assert.equal(getPluginNameFromUrl(''), null);
-    });
-
-    test('with invalid url', () => {
-      assert.equal(getPluginNameFromUrl('test'), null);
-    });
-
-    test('with random invalid url', () => {
-      assert.equal(getPluginNameFromUrl('http://example.com'), null);
-      assert.equal(
-          getPluginNameFromUrl('http://example.com/static/a.html'),
-          null
-      );
-    });
-
-    test('with valid urls', () => {
-      assert.equal(
-          getPluginNameFromUrl('http://example.com/plugins/a.html'),
-          'a'
-      );
-      assert.equal(
-          getPluginNameFromUrl('http://example.com/plugins/a/static/t.html'),
-          'a'
-      );
-    });
-
-    test('with preloaded urls', () => {
-      assert.equal(getPluginNameFromUrl(`${PRELOADED_PROTOCOL}a`), 'a');
-    });
-
-    test('with gerrit-theme override', () => {
-      assert.equal(
-          getPluginNameFromUrl('http://example.com/static/gerrit-theme.html'),
-          'gerrit-theme'
-      );
-    });
-
-    test('with ASSETS_PATH', () => {
-      window.ASSETS_PATH = 'http://cdn.com/2';
-      assert.equal(
-          getPluginNameFromUrl(`${window.ASSETS_PATH}/plugins/a.html`),
-          'a'
-      );
-      window.ASSETS_PATH = undefined;
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js
new file mode 100644
index 0000000..85c62cb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-js-api-interface.js';
+import {getPluginNameFromUrl} from './gr-api-utils.js';
+
+const PRELOADED_PROTOCOL = 'preloaded:';
+
+suite('gr-api-utils tests', () => {
+  suite('test getPluginNameFromUrl', () => {
+    test('with empty string', () => {
+      assert.equal(getPluginNameFromUrl(''), null);
+    });
+
+    test('with invalid url', () => {
+      assert.equal(getPluginNameFromUrl('test'), null);
+    });
+
+    test('with random invalid url', () => {
+      assert.equal(getPluginNameFromUrl('http://example.com'), null);
+      assert.equal(
+          getPluginNameFromUrl('http://example.com/static/a.html'),
+          null
+      );
+    });
+
+    test('with valid urls', () => {
+      assert.equal(
+          getPluginNameFromUrl('http://example.com/plugins/a.html'),
+          'a'
+      );
+      assert.equal(
+          getPluginNameFromUrl('http://example.com/plugins/a/static/t.html'),
+          'a'
+      );
+    });
+
+    test('with preloaded urls', () => {
+      assert.equal(getPluginNameFromUrl(`${PRELOADED_PROTOCOL}a`), 'a');
+    });
+
+    test('with gerrit-theme override', () => {
+      assert.equal(
+          getPluginNameFromUrl('http://example.com/static/gerrit-theme.html'),
+          'gerrit-theme'
+      );
+    });
+
+    test('with ASSETS_PATH', () => {
+      window.ASSETS_PATH = 'http://cdn.com/2';
+      assert.equal(
+          getPluginNameFromUrl(`${window.ASSETS_PATH}/plugins/a.html`),
+          'a'
+      );
+      window.ASSETS_PATH = undefined;
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
deleted file mode 100644
index 1d5e423..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ /dev/null
@@ -1,232 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-change-actions-js-api</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<!--
-This must refer to the element this interface is wrapping around. Otherwise
-breaking changes to gr-change-actions won’t be noticed.
--->
-
-<test-fixture id="basic">
-  <template>
-    <gr-change-actions></gr-change-actions>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../change/gr-change-actions/gr-change-actions.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {pluginLoader} from './gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-js-api-interface tests', () => {
-  let element;
-  let changeActions;
-  let plugin;
-
-  // Because deepEqual doesn’t behave in Safari.
-  function assertArraysEqual(actual, expected) {
-    assert.equal(actual.length, expected.length);
-    for (let i = 0; i < actual.length; i++) {
-      assert.equal(actual[i], expected[i]);
-    }
-  }
-
-  suite('early init', () => {
-    setup(() => {
-      resetPlugins();
-      pluginApi.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      // Mimic all plugins loaded.
-      pluginLoader.loadPlugins([]);
-      changeActions = plugin.changeActions();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      changeActions = null;
-      resetPlugins();
-    });
-
-    test('does not throw', ()=> {
-      assert.doesNotThrow(() => {
-        changeActions.add('change', 'foo');
-      });
-    });
-  });
-
-  suite('normal init', () => {
-    setup(() => {
-      resetPlugins();
-      element = fixture('basic');
-      sinon.stub(element, '_editStatusChanged');
-      element.change = {};
-      element._hasKnownChainState = false;
-      pluginApi.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeActions = plugin.changeActions();
-      // Mimic all plugins loaded.
-      pluginLoader.loadPlugins([]);
-    });
-
-    teardown(() => {
-      changeActions = null;
-      resetPlugins();
-    });
-
-    test('property existence', () => {
-      const properties = [
-        'ActionType',
-        'ChangeActions',
-        'RevisionActions',
-      ];
-      for (const p of properties) {
-        assertArraysEqual(changeActions[p], element[p]);
-      }
-    });
-
-    test('add/remove primary action keys', () => {
-      element.primaryActionKeys = [];
-      changeActions.addPrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['foo']);
-      changeActions.addPrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['foo']);
-      changeActions.addPrimaryActionKey('bar');
-      assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
-      changeActions.removePrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['bar']);
-      changeActions.removePrimaryActionKey('baz');
-      assertArraysEqual(element.primaryActionKeys, ['bar']);
-      changeActions.removePrimaryActionKey('bar');
-      assertArraysEqual(element.primaryActionKeys, []);
-    });
-
-    test('action buttons', done => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      const handler = sinon.spy();
-      changeActions.addTapListener(key, handler);
-      flush(() => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('[data-action-key="' + key + '"]'));
-        assert(handler.calledOnce);
-        changeActions.removeTapListener(key, handler);
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('[data-action-key="' + key + '"]'));
-        assert(handler.calledOnce);
-        changeActions.remove(key);
-        flush(() => {
-          assert.isNull(element.shadowRoot
-              .querySelector('[data-action-key="' + key + '"]'));
-          done();
-        });
-      });
-    });
-
-    test('action button properties', done => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush(() => {
-        const button = element.shadowRoot
-            .querySelector('[data-action-key="' + key + '"]');
-        assert.isOk(button);
-        assert.equal(button.getAttribute('data-label'), 'Bork!');
-        assert.isNotOk(button.disabled);
-        changeActions.setLabel(key, 'Yo');
-        changeActions.setTitle(key, 'Yo hint');
-        changeActions.setEnabled(key, false);
-        changeActions.setIcon(key, 'pupper');
-        flush(() => {
-          assert.equal(button.getAttribute('data-label'), 'Yo');
-          assert.equal(button.getAttribute('title'), 'Yo hint');
-          assert.isTrue(button.disabled);
-          assert.equal(dom(button).querySelector('iron-icon').icon,
-              'gr-icons:pupper');
-          done();
-        });
-      });
-    });
-
-    test('hide action buttons', done => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush(() => {
-        const button = element.shadowRoot
-            .querySelector('[data-action-key="' + key + '"]');
-        assert.isOk(button);
-        assert.isFalse(button.hasAttribute('hidden'));
-        changeActions.setActionHidden(
-            changeActions.ActionType.REVISION, key, true);
-        flush(() => {
-          const button = element.shadowRoot
-              .querySelector('[data-action-key="' + key + '"]');
-          assert.isNotOk(button);
-          done();
-        });
-      });
-    });
-
-    test('move action button to overflow', done => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush(() => {
-        assert.isTrue(element.$.moreActions.hidden);
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="' + key + '"]'));
-        changeActions.setActionOverflow(
-            changeActions.ActionType.REVISION, key, true);
-        flush(() => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="' + key + '"]'));
-          assert.isFalse(element.$.moreActions.hidden);
-          assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
-          done();
-        });
-      });
-    });
-
-    test('change actions priority', done => {
-      const key1 =
-        changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      const key2 =
-        changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
-      flush(() => {
-        let buttons =
-          dom(element.root).querySelectorAll('[data-action-key]');
-        assert.equal(buttons[0].getAttribute('data-action-key'), key1);
-        assert.equal(buttons[1].getAttribute('data-action-key'), key2);
-        changeActions.setActionPriority(
-            changeActions.ActionType.REVISION, key1, 10);
-        flush(() => {
-          buttons =
-            dom(element.root).querySelectorAll('[data-action-key]');
-          assert.equal(buttons[0].getAttribute('data-action-key'), key2);
-          assert.equal(buttons[1].getAttribute('data-action-key'), key1);
-          done();
-        });
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
new file mode 100644
index 0000000..231830bd
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
@@ -0,0 +1,214 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../change/gr-change-actions/gr-change-actions.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {pluginLoader} from './gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const basicFixture = fixtureFromElement('gr-change-actions');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-actions-js-api-interface tests', () => {
+  let element;
+  let changeActions;
+  let plugin;
+
+  // Because deepEqual doesn’t behave in Safari.
+  function assertArraysEqual(actual, expected) {
+    assert.equal(actual.length, expected.length);
+    for (let i = 0; i < actual.length; i++) {
+      assert.equal(actual[i], expected[i]);
+    }
+  }
+
+  suite('early init', () => {
+    setup(() => {
+      resetPlugins();
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      // Mimic all plugins loaded.
+      pluginLoader.loadPlugins([]);
+      changeActions = plugin.changeActions();
+      element = basicFixture.instantiate();
+    });
+
+    teardown(() => {
+      changeActions = null;
+      resetPlugins();
+    });
+
+    test('does not throw', ()=> {
+      assert.doesNotThrow(() => {
+        changeActions.add('change', 'foo');
+      });
+    });
+  });
+
+  suite('normal init', () => {
+    setup(() => {
+      resetPlugins();
+      element = basicFixture.instantiate();
+      sinon.stub(element, '_editStatusChanged');
+      element.change = {};
+      element._hasKnownChainState = false;
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeActions = plugin.changeActions();
+      // Mimic all plugins loaded.
+      pluginLoader.loadPlugins([]);
+    });
+
+    teardown(() => {
+      changeActions = null;
+      resetPlugins();
+    });
+
+    test('property existence', () => {
+      const properties = [
+        'ActionType',
+        'ChangeActions',
+        'RevisionActions',
+      ];
+      for (const p of properties) {
+        assertArraysEqual(changeActions[p], element[p]);
+      }
+    });
+
+    test('add/remove primary action keys', () => {
+      element.primaryActionKeys = [];
+      changeActions.addPrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['foo']);
+      changeActions.addPrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['foo']);
+      changeActions.addPrimaryActionKey('bar');
+      assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
+      changeActions.removePrimaryActionKey('foo');
+      assertArraysEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('baz');
+      assertArraysEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('bar');
+      assertArraysEqual(element.primaryActionKeys, []);
+    });
+
+    test('action buttons', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      const handler = sinon.spy();
+      changeActions.addTapListener(key, handler);
+      flush(() => {
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]'));
+        assert(handler.calledOnce);
+        changeActions.removeTapListener(key, handler);
+        MockInteractions.tap(element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]'));
+        assert(handler.calledOnce);
+        changeActions.remove(key);
+        flush(() => {
+          assert.isNull(element.shadowRoot
+              .querySelector('[data-action-key="' + key + '"]'));
+          done();
+        });
+      });
+    });
+
+    test('action button properties', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        const button = element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]');
+        assert.isOk(button);
+        assert.equal(button.getAttribute('data-label'), 'Bork!');
+        assert.isNotOk(button.disabled);
+        changeActions.setLabel(key, 'Yo');
+        changeActions.setTitle(key, 'Yo hint');
+        changeActions.setEnabled(key, false);
+        changeActions.setIcon(key, 'pupper');
+        flush(() => {
+          assert.equal(button.getAttribute('data-label'), 'Yo');
+          assert.equal(button.getAttribute('title'), 'Yo hint');
+          assert.isTrue(button.disabled);
+          assert.equal(dom(button).querySelector('iron-icon').icon,
+              'gr-icons:pupper');
+          done();
+        });
+      });
+    });
+
+    test('hide action buttons', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        const button = element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]');
+        assert.isOk(button);
+        assert.isFalse(button.hasAttribute('hidden'));
+        changeActions.setActionHidden(
+            changeActions.ActionType.REVISION, key, true);
+        flush(() => {
+          const button = element.shadowRoot
+              .querySelector('[data-action-key="' + key + '"]');
+          assert.isNotOk(button);
+          done();
+        });
+      });
+    });
+
+    test('move action button to overflow', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        assert.isTrue(element.$.moreActions.hidden);
+        assert.isOk(element.shadowRoot
+            .querySelector('[data-action-key="' + key + '"]'));
+        changeActions.setActionOverflow(
+            changeActions.ActionType.REVISION, key, true);
+        flush(() => {
+          assert.isNotOk(element.shadowRoot
+              .querySelector('[data-action-key="' + key + '"]'));
+          assert.isFalse(element.$.moreActions.hidden);
+          assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
+          done();
+        });
+      });
+    });
+
+    test('change actions priority', done => {
+      const key1 =
+        changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      const key2 =
+        changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
+      flush(() => {
+        let buttons =
+          dom(element.root).querySelectorAll('[data-action-key]');
+        assert.equal(buttons[0].getAttribute('data-action-key'), key1);
+        assert.equal(buttons[1].getAttribute('data-action-key'), key2);
+        changeActions.setActionPriority(
+            changeActions.ActionType.REVISION, key1, 10);
+        flush(() => {
+          buttons =
+            dom(element.root).querySelectorAll('[data-action-key]');
+          assert.equal(buttons[0].getAttribute('data-action-key'), key2);
+          assert.equal(buttons[1].getAttribute('data-action-key'), key1);
+          done();
+        });
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
index da0157f..2fa9acc 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
@@ -19,14 +19,14 @@
  * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
  */
 export class GrChangeReplyInterface {
-  constructor(plugin) {
+  constructor(plugin, sharedApiElement) {
     this.plugin = plugin;
-    this._sharedApiEl = Plugin._sharedAPIElement;
+    this.sharedApiElement = sharedApiElement;
   }
 
   get _el() {
-    return this._sharedApiEl.getElement(
-        this._sharedApiEl.Element.REPLY_DIALOG);
+    return this.sharedApiElement.getElement(
+        this.sharedApiElement.Element.REPLY_DIALOG);
   }
 
   getLabelValue(label) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
deleted file mode 100644
index 0360f85..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
+++ /dev/null
@@ -1,125 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-change-reply-js-api</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<!--
-This must refer to the element this interface is wrapping around. Otherwise
-breaking changes to gr-reply-dialog won’t be noticed.
--->
-
-<test-fixture id="basic">
-  <template>
-    <gr-reply-dialog></gr-reply-dialog>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import '../../change/gr-reply-dialog/gr-reply-dialog.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-change-reply-js-api tests', () => {
-  let element;
-  let sandbox;
-  let changeReply;
-  let plugin;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-      getAccount() { return Promise.resolve(null); },
-    });
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('early init', () => {
-    setup(() => {
-      pluginApi.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeReply = plugin.changeReply();
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      changeReply = null;
-    });
-
-    test('works', () => {
-      sandbox.stub(element, 'getLabelValue').returns('+123');
-      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
-
-      sandbox.stub(element, 'setLabelValue');
-      changeReply.setLabelValue('My-Label', '+1337');
-      assert.isTrue(
-          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
-
-      sandbox.stub(element, 'send');
-      changeReply.send(false);
-      assert.isTrue(element.send.calledWithExactly(false));
-
-      sandbox.stub(element, 'setPluginMessage');
-      changeReply.showMessage('foobar');
-      assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
-    });
-  });
-
-  suite('normal init', () => {
-    setup(() => {
-      element = fixture('basic');
-      pluginApi.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeReply = plugin.changeReply();
-    });
-
-    teardown(() => {
-      changeReply = null;
-    });
-
-    test('works', () => {
-      sandbox.stub(element, 'getLabelValue').returns('+123');
-      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
-
-      sandbox.stub(element, 'setLabelValue');
-      changeReply.setLabelValue('My-Label', '+1337');
-      assert.isTrue(
-          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
-
-      sandbox.stub(element, 'send');
-      changeReply.send(false);
-      assert.isTrue(element.send.calledWithExactly(false));
-
-      sandbox.stub(element, 'setPluginMessage');
-      changeReply.showMessage('foobar');
-      assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
new file mode 100644
index 0000000..8f41b39
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
@@ -0,0 +1,101 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../change/gr-reply-dialog/gr-reply-dialog.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const basicFixture = fixtureFromElement('gr-reply-dialog');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-change-reply-js-api tests', () => {
+  let element;
+
+  let changeReply;
+  let plugin;
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+      getAccount() { return Promise.resolve(null); },
+    });
+  });
+
+  suite('early init', () => {
+    setup(() => {
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeReply = plugin.changeReply();
+      element = basicFixture.instantiate();
+    });
+
+    teardown(() => {
+      changeReply = null;
+    });
+
+    test('works', () => {
+      sinon.stub(element, 'getLabelValue').returns('+123');
+      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+
+      sinon.stub(element, 'setLabelValue');
+      changeReply.setLabelValue('My-Label', '+1337');
+      assert.isTrue(
+          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
+
+      sinon.stub(element, 'send');
+      changeReply.send(false);
+      assert.isTrue(element.send.calledWithExactly(false));
+
+      sinon.stub(element, 'setPluginMessage');
+      changeReply.showMessage('foobar');
+      assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
+    });
+  });
+
+  suite('normal init', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      pluginApi.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      changeReply = plugin.changeReply();
+    });
+
+    teardown(() => {
+      changeReply = null;
+    });
+
+    test('works', () => {
+      sinon.stub(element, 'getLabelValue').returns('+123');
+      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+
+      sinon.stub(element, 'setLabelValue');
+      changeReply.setLabelValue('My-Label', '+1337');
+      assert.isTrue(
+          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
+
+      sinon.stub(element, 'send');
+      changeReply.send(false);
+      assert.isTrue(element.send.calledWithExactly(false));
+
+      sinon.stub(element, 'setPluginMessage');
+      changeReply.showMessage('foobar');
+      assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
index ef57ae9..ce65755 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
@@ -21,8 +21,8 @@
  */
 
 import {pluginLoader} from './gr-plugin-loader.js';
-import {gerritEventEmitter} from '../gr-event-emitter/gr-event-emitter.js';
 import {getRestAPI, send} from './gr-api-utils.js';
+import {appContext} from '../../../services/app-context.js';
 
 /**
  * Trigger the preinstalls for bundled plugins.
@@ -146,9 +146,11 @@
     return pluginLoader.isPluginLoaded(pathOrUrl);
   };
 
+  const eventEmitter = appContext.eventEmitter;
+
   // TODO(taoalpha): List all internal supported event names.
   // Also convert this to inherited class once we move Gerrit to class.
-  globalGerritObj._eventEmitter = gerritEventEmitter;
+  globalGerritObj._eventEmitter = eventEmitter;
   ['addListener',
     'dispatch',
     'emit',
@@ -180,7 +182,7 @@
      *   });
      * });
      */
-    globalGerritObj[method] = gerritEventEmitter[method]
-        .bind(gerritEventEmitter);
+    globalGerritObj[method] = eventEmitter[method]
+        .bind(eventEmitter);
   });
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html
deleted file mode 100644
index 2d87497..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.html
+++ /dev/null
@@ -1,105 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-api-interface</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-js-api-interface></gr-js-api-interface>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-js-api-interface.js';
-import {pluginLoader} from './gr-plugin-loader.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-gerrit tests', () => {
-  let element;
-  let sandbox;
-  let sendStub;
-
-  setup(() => {
-    window.clock = sinon.useFakeTimers();
-    sandbox = sinon.sandbox.create();
-    sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
-    stub('gr-rest-api-interface', {
-      getAccount() {
-        return Promise.resolve({name: 'Judy Hopps'});
-      },
-      send(...args) {
-        return sendStub(...args);
-      },
-    });
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    window.clock.restore();
-    sandbox.restore();
-    element._removeEventCallbacks();
-    resetPlugins();
-  });
-
-  suite('proxy methods', () => {
-    test('Gerrit._isPluginEnabled proxy to pluginLoader', () => {
-      const stubFn = sandbox.stub();
-      sandbox.stub(
-          pluginLoader,
-          'isPluginEnabled',
-          (...args) => stubFn(...args)
-      );
-      pluginApi._isPluginEnabled('test_plugin');
-      assert.isTrue(stubFn.calledWith('test_plugin'));
-    });
-
-    test('Gerrit._isPluginLoaded proxy to pluginLoader', () => {
-      const stubFn = sandbox.stub();
-      sandbox.stub(
-          pluginLoader,
-          'isPluginLoaded',
-          (...args) => stubFn(...args)
-      );
-      pluginApi._isPluginLoaded('test_plugin');
-      assert.isTrue(stubFn.calledWith('test_plugin'));
-    });
-
-    test('Gerrit._isPluginPreloaded proxy to pluginLoader', () => {
-      const stubFn = sandbox.stub();
-      sandbox.stub(
-          pluginLoader,
-          'isPluginPreloaded',
-          (...args) => stubFn(...args)
-      );
-      pluginApi._isPluginPreloaded('test_plugin');
-      assert.isTrue(stubFn.calledWith('test_plugin'));
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
new file mode 100644
index 0000000..e5b32f7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-js-api-interface.js';
+import {pluginLoader} from './gr-plugin-loader.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const basicFixture = fixtureFromElement('gr-js-api-interface');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-gerrit tests', () => {
+  let element;
+
+  let sendStub;
+
+  setup(() => {
+    window.clock = sinon.useFakeTimers();
+
+    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve({name: 'Judy Hopps'});
+      },
+      send(...args) {
+        return sendStub(...args);
+      },
+    });
+    element = basicFixture.instantiate();
+  });
+
+  teardown(() => {
+    window.clock.restore();
+    element._removeEventCallbacks();
+    resetPlugins();
+  });
+
+  suite('proxy methods', () => {
+    test('Gerrit._isPluginEnabled proxy to pluginLoader', () => {
+      const stubFn = sinon.stub();
+      sinon.stub(
+          pluginLoader,
+          'isPluginEnabled')
+          .callsFake((...args) => stubFn(...args)
+          );
+      pluginApi._isPluginEnabled('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
+    });
+
+    test('Gerrit._isPluginLoaded proxy to pluginLoader', () => {
+      const stubFn = sinon.stub();
+      sinon.stub(
+          pluginLoader,
+          'isPluginLoaded')
+          .callsFake((...args) => stubFn(...args));
+      pluginApi._isPluginLoaded('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
+    });
+
+    test('Gerrit._isPluginPreloaded proxy to pluginLoader', () => {
+      const stubFn = sinon.stub();
+      sinon.stub(
+          pluginLoader,
+          'isPluginPreloaded')
+          .callsFake((...args) => stubFn(...args));
+      pluginApi._isPluginPreloaded('test_plugin');
+      assert.isTrue(stubFn.calledWith('test_plugin'));
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js
index 997e08c..6c785d4 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js
@@ -14,14 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
 import {pluginLoader} from './gr-plugin-loader.js';
+import {patchNumEquals} from '../../../utils/patch-set-util.js';
 
 // Note: for new events, naming convention should be: `a-b`
 const EventType = {
@@ -46,13 +43,11 @@
 };
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrJsApiInterface extends mixinBehaviors( [
-  PatchSetBehavior,
-], GestureEventListeners(
+class GrJsApiInterface extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get is() { return 'gr-js-api-interface'; }
 
   constructor() {
@@ -160,19 +155,17 @@
     //
     // assign on getter with existing property will report error
     // see Issue: 12286
-    const change = Object.assign({}, detail.change, {
-      get mergeable() {
-        console.warn('Accessing change.mergeable from SHOW_CHANGE is ' +
+    const change = {...detail.change, get mergeable() {
+      console.warn('Accessing change.mergeable from SHOW_CHANGE is ' +
             'deprecated! Use info.mergeable instead.');
-        return detail.info && detail.info.mergeable;
-      },
-    });
+      return detail.info && detail.info.mergeable;
+    }};
     const patchNum = detail.patchNum;
     const info = detail.info;
 
     let revision;
     for (const rev of Object.values(change.revisions || {})) {
-      if (this.patchNumEquals(rev._number, patchNum)) {
+      if (patchNumEquals(rev._number, patchNum)) {
         revision = rev;
         break;
       }
@@ -279,6 +272,17 @@
     return layers;
   }
 
+  disposeDiffLayers(path) {
+    for (const annotationApi of
+      this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
+      try {
+        annotationApi.disposeLayer(path);
+      } catch (err) {
+        console.error(err);
+      }
+    }
+  }
+
   /**
    * Retrieves coverage data possibly provided by a plugin.
    *
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
index 1cdb20f..6f0ade9 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-import '../../core/gr-reporting/gr-reporting.js';
 import '../gr-rest-api-interface/gr-rest-api-interface.js';
 import './gr-js-api-interface-element.js';
 import './gr-public-js-api.js';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
deleted file mode 100644
index ea1ac91..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ /dev/null
@@ -1,588 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-api-interface</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-js-api-interface></gr-js-api-interface>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-js-api-interface.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
-import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
-import {GrPluginActionContext} from './gr-plugin-action-context.js';
-import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
-import {pluginLoader} from './gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-js-api-interface tests', () => {
-  let element;
-  let plugin;
-  let errorStub;
-  let sandbox;
-  let getResponseObjectStub;
-  let sendStub;
-
-  const throwErrFn = function() {
-    throw Error('Unfortunately, this handler has stopped');
-  };
-
-  setup(() => {
-    window.clock = sinon.useFakeTimers();
-    sandbox = sinon.sandbox.create();
-    getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
-    sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
-    stub('gr-rest-api-interface', {
-      getAccount() {
-        return Promise.resolve({name: 'Judy Hopps'});
-      },
-      getResponseObject: getResponseObjectStub,
-      send(...args) {
-        return sendStub(...args);
-      },
-    });
-    element = fixture('basic');
-    errorStub = sandbox.stub(console, 'error');
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    pluginLoader.loadPlugins([]);
-  });
-
-  teardown(() => {
-    window.clock.restore();
-    sandbox.restore();
-    element._removeEventCallbacks();
-    plugin = null;
-  });
-
-  test('url', () => {
-    assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
-    assert.equal(plugin.url('/static/test.js'),
-        'http://test.com/plugins/testplugin/static/test.js');
-  });
-
-  test('url for preloaded plugin without ASSETS_PATH', () => {
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'preloaded:testpluginB');
-    assert.equal(plugin.url(),
-        `${window.location.origin}/plugins/testpluginB/`);
-    assert.equal(plugin.url('/static/test.js'),
-        `${window.location.origin}/plugins/testpluginB/static/test.js`);
-  });
-
-  test('url for preloaded plugin without ASSETS_PATH', () => {
-    const oldAssetsPath = window.ASSETS_PATH;
-    window.ASSETS_PATH = 'http://test.com';
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'preloaded:testpluginC');
-    assert.equal(plugin.url(), `${window.ASSETS_PATH}/plugins/testpluginC/`);
-    assert.equal(plugin.url('/static/test.js'),
-        `${window.ASSETS_PATH}/plugins/testpluginC/static/test.js`);
-    window.ASSETS_PATH = oldAssetsPath;
-  });
-
-  test('_send on failure rejects with response text', () => {
-    sendStub.returns(Promise.resolve(
-        {status: 400, text() { return Promise.resolve('text'); }}));
-    return plugin._send().catch(r => {
-      assert.equal(r.message, 'text');
-    });
-  });
-
-  test('_send on failure without text rejects with code', () => {
-    sendStub.returns(Promise.resolve(
-        {status: 400, text() { return Promise.resolve(null); }}));
-    return plugin._send().catch(r => {
-      assert.equal(r.message, '400');
-    });
-  });
-
-  test('get', () => {
-    const response = {foo: 'foo'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return plugin.get('/url', r => {
-      assert.isTrue(sendStub.calledWith(
-          'GET', 'http://test.com/plugins/testplugin/url'));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('get using Promise', () => {
-    const response = {foo: 'foo'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return plugin.get('/url', r => 'rubbish').then(r => {
-      assert.isTrue(sendStub.calledWith(
-          'GET', 'http://test.com/plugins/testplugin/url'));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('post', () => {
-    const payload = {foo: 'foo'};
-    const response = {bar: 'bar'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return plugin.post('/url', payload, r => {
-      assert.isTrue(sendStub.calledWith(
-          'POST', 'http://test.com/plugins/testplugin/url', payload));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('put', () => {
-    const payload = {foo: 'foo'};
-    const response = {bar: 'bar'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return plugin.put('/url', payload, r => {
-      assert.isTrue(sendStub.calledWith(
-          'PUT', 'http://test.com/plugins/testplugin/url', payload));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('delete works', () => {
-    const response = {status: 204};
-    sendStub.returns(Promise.resolve(response));
-    return plugin.delete('/url', r => {
-      assert.isTrue(sendStub.calledWithExactly(
-          'DELETE', 'http://test.com/plugins/testplugin/url'));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('delete fails', () => {
-    sendStub.returns(Promise.resolve(
-        {status: 400, text() { return Promise.resolve('text'); }}));
-    return plugin.delete('/url', r => {
-      throw new Error('Should not resolve');
-    }).catch(err => {
-      assert.isTrue(sendStub.calledWith(
-          'DELETE', 'http://test.com/plugins/testplugin/url'));
-      assert.equal('text', err.message);
-    });
-  });
-
-  test('history event', done => {
-    plugin.on(element.EventType.HISTORY, throwErrFn);
-    plugin.on(element.EventType.HISTORY, path => {
-      assert.equal(path, '/path/to/awesomesauce');
-      assert.isTrue(errorStub.calledOnce);
-      done();
-    });
-    element.handleEvent(element.EventType.HISTORY,
-        {path: '/path/to/awesomesauce'});
-  });
-
-  test('showchange event', done => {
-    const testChange = {
-      _number: 42,
-      revisions: {def: {_number: 2}, abc: {_number: 1}},
-    };
-    const expectedChange = Object.assign({mergeable: false}, testChange);
-    plugin.on(element.EventType.SHOW_CHANGE, throwErrFn);
-    plugin.on(element.EventType.SHOW_CHANGE, (change, revision, info) => {
-      assert.deepEqual(change, expectedChange);
-      assert.deepEqual(revision, testChange.revisions.abc);
-      assert.deepEqual(info, {mergeable: false});
-      assert.isTrue(errorStub.calledOnce);
-      done();
-    });
-    element.handleEvent(element.EventType.SHOW_CHANGE,
-        {change: testChange, patchNum: 1, info: {mergeable: false}});
-  });
-
-  test('show-revision-actions event', done => {
-    const testChange = {
-      _number: 42,
-      revisions: {def: {_number: 2}, abc: {_number: 1}},
-    };
-    plugin.on(element.EventType.SHOW_REVISION_ACTIONS, throwErrFn);
-    plugin.on(element.EventType.SHOW_REVISION_ACTIONS, (actions, change) => {
-      assert.deepEqual(change, testChange);
-      assert.deepEqual(actions, {test: {}});
-      assert.isTrue(errorStub.calledOnce);
-      done();
-    });
-    element.handleEvent(element.EventType.SHOW_REVISION_ACTIONS,
-        {change: testChange, revisionActions: {test: {}}});
-  });
-
-  test('handleEvent awaits plugins load', done => {
-    const testChange = {
-      _number: 42,
-      revisions: {def: {_number: 2}, abc: {_number: 1}},
-    };
-    const spy = sandbox.spy();
-    pluginLoader.loadPlugins(['plugins/test.html']);
-    plugin.on(element.EventType.SHOW_CHANGE, spy);
-    element.handleEvent(element.EventType.SHOW_CHANGE,
-        {change: testChange, patchNum: 1});
-    assert.isFalse(spy.called);
-
-    // Timeout on loading plugins
-    window.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
-
-    flush(() => {
-      assert.isTrue(spy.called);
-      done();
-    });
-  });
-
-  test('comment event', done => {
-    const testCommentNode = {foo: 'bar'};
-    plugin.on(element.EventType.COMMENT, throwErrFn);
-    plugin.on(element.EventType.COMMENT, commentNode => {
-      assert.deepEqual(commentNode, testCommentNode);
-      assert.isTrue(errorStub.calledOnce);
-      done();
-    });
-    element.handleEvent(element.EventType.COMMENT, {node: testCommentNode});
-  });
-
-  test('revert event', () => {
-    function appendToRevertMsg(c, revertMsg, originalMsg) {
-      return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
-    }
-
-    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'), 'test');
-    assert.equal(errorStub.callCount, 0);
-
-    plugin.on(element.EventType.REVERT, throwErrFn);
-    plugin.on(element.EventType.REVERT, appendToRevertMsg);
-    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-        'test\n> origTest\ninfo');
-    assert.isTrue(errorStub.calledOnce);
-
-    plugin.on(element.EventType.REVERT, appendToRevertMsg);
-    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-        'test\n> origTest\ninfo\n> origTest\ninfo');
-    assert.isTrue(errorStub.calledTwice);
-  });
-
-  test('postrevert event', () => {
-    function getLabels(c) {
-      return {'Code-Review': 1};
-    }
-
-    assert.deepEqual(element.getLabelValuesPostRevert(null), {});
-    assert.equal(errorStub.callCount, 0);
-
-    plugin.on(element.EventType.POST_REVERT, throwErrFn);
-    plugin.on(element.EventType.POST_REVERT, getLabels);
-    assert.deepEqual(
-        element.getLabelValuesPostRevert(null), {'Code-Review': 1});
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('commitmsgedit event', done => {
-    const testMsg = 'Test CL commit message';
-    plugin.on(element.EventType.COMMIT_MSG_EDIT, throwErrFn);
-    plugin.on(element.EventType.COMMIT_MSG_EDIT, (change, msg) => {
-      assert.deepEqual(msg, testMsg);
-      assert.isTrue(errorStub.calledOnce);
-      done();
-    });
-    element.handleCommitMessage(null, testMsg);
-  });
-
-  test('labelchange event', done => {
-    const testChange = {_number: 42};
-    plugin.on(element.EventType.LABEL_CHANGE, throwErrFn);
-    plugin.on(element.EventType.LABEL_CHANGE, change => {
-      assert.deepEqual(change, testChange);
-      assert.isTrue(errorStub.calledOnce);
-      done();
-    });
-    element.handleEvent(element.EventType.LABEL_CHANGE, {change: testChange});
-  });
-
-  test('submitchange', () => {
-    plugin.on(element.EventType.SUBMIT_CHANGE, throwErrFn);
-    plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
-    assert.isTrue(element.canSubmitChange());
-    assert.isTrue(errorStub.calledOnce);
-    plugin.on(element.EventType.SUBMIT_CHANGE, () => false);
-    plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
-    assert.isFalse(element.canSubmitChange());
-    assert.isTrue(errorStub.calledTwice);
-  });
-
-  test('highlightjs-loaded event', done => {
-    const testHljs = {_number: 42};
-    plugin.on(element.EventType.HIGHLIGHTJS_LOADED, throwErrFn);
-    plugin.on(element.EventType.HIGHLIGHTJS_LOADED, hljs => {
-      assert.deepEqual(hljs, testHljs);
-      assert.isTrue(errorStub.calledOnce);
-      done();
-    });
-    element.handleEvent(element.EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
-  });
-
-  test('getLoggedIn', done => {
-    // fake fetch for authCheck
-    sandbox.stub(window, 'fetch', () => Promise.resolve({status: 204}));
-    plugin.restApi().getLoggedIn()
-        .then(loggedIn => {
-          assert.isTrue(loggedIn);
-          done();
-        });
-  });
-
-  test('attributeHelper', () => {
-    assert.isOk(plugin.attributeHelper());
-  });
-
-  test('deprecated.install', () => {
-    plugin.deprecated.install();
-    assert.strictEqual(plugin.popup, plugin.deprecated.popup);
-    assert.strictEqual(plugin.onAction, plugin.deprecated.onAction);
-    assert.notStrictEqual(plugin.install, plugin.deprecated.install);
-  });
-
-  test('getAdminMenuLinks', () => {
-    const links = [{text: 'a', url: 'b'}, {text: 'c', url: 'd'}];
-    const getCallbacksStub = sandbox.stub(element, '_getEventCallbacks')
-        .returns([
-          {getMenuLinks: () => [links[0]]},
-          {getMenuLinks: () => [links[1]]},
-        ]);
-    const result = element.getAdminMenuLinks();
-    assert.deepEqual(result, links);
-    assert.isTrue(getCallbacksStub.calledOnce);
-    assert.equal(getCallbacksStub.lastCall.args[0],
-        element.EventType.ADMIN_MENU_LINKS);
-  });
-
-  suite('test plugin with base url', () => {
-    let baseUrlPlugin;
-
-    setup(() => {
-      sandbox.stub(BaseUrlBehavior, 'getBaseUrl').returns('/r');
-
-      pluginApi.install(p => { baseUrlPlugin = p; }, '0.1',
-          'http://test.com/r/plugins/baseurlplugin/static/test.js');
-    });
-
-    test('url', () => {
-      assert.notEqual(baseUrlPlugin.url(),
-          'http://test.com/plugins/baseurlplugin/');
-      assert.equal(baseUrlPlugin.url(),
-          'http://test.com/r/plugins/baseurlplugin/');
-      assert.equal(baseUrlPlugin.url('/static/test.js'),
-          'http://test.com/r/plugins/baseurlplugin/static/test.js');
-    });
-  });
-
-  suite('popup', () => {
-    test('popup(element) is deprecated', () => {
-      plugin.popup(document.createElement('div'));
-      assert.isTrue(console.error.calledOnce);
-    });
-
-    test('popup(moduleName) creates popup with component', () => {
-      const openStub = sandbox.stub(GrPopupInterface.prototype, 'open',
-          function() {
-            // Arrow function can't be used here, because we want to
-            // get properties from the instance of GrPopupInterface
-            // eslint-disable-next-line no-invalid-this
-            const grPopupInterface = this;
-            assert.equal(grPopupInterface.plugin, plugin);
-            assert.equal(grPopupInterface._moduleName, 'some-name');
-          });
-      plugin.popup('some-name');
-      assert.isTrue(openStub.calledOnce);
-    });
-
-    test('deprecated.popup(element) creates popup with element', () => {
-      const el = document.createElement('div');
-      el.textContent = 'some text here';
-      const openStub = sandbox.stub(GrPopupInterface.prototype, 'open');
-      openStub.returns(Promise.resolve({
-        _getElement() {
-          return document.createElement('div');
-        }}));
-      plugin.deprecated.popup(el);
-      assert.isTrue(openStub.calledOnce);
-    });
-  });
-
-  suite('onAction', () => {
-    let change;
-    let revision;
-    let actionDetails;
-
-    setup(() => {
-      change = {};
-      revision = {};
-      actionDetails = {__key: 'some'};
-      sandbox.stub(plugin, 'on').callsArgWith(1, change, revision);
-      sandbox.stub(plugin, 'changeActions').returns({
-        addTapListener: sandbox.stub().callsArg(1),
-        getActionDetails: () => actionDetails,
-      });
-    });
-
-    test('returns GrPluginActionContext', () => {
-      const stub = sandbox.stub();
-      plugin.deprecated.onAction('change', 'foo', ctx => {
-        assert.isTrue(ctx instanceof GrPluginActionContext);
-        assert.strictEqual(ctx.change, change);
-        assert.strictEqual(ctx.revision, revision);
-        assert.strictEqual(ctx.action, actionDetails);
-        assert.strictEqual(ctx.plugin, plugin);
-        stub();
-      });
-      assert.isTrue(stub.called);
-    });
-
-    test('other actions', () => {
-      const stub = sandbox.stub();
-      plugin.deprecated.onAction('project', 'foo', stub);
-      plugin.deprecated.onAction('edit', 'foo', stub);
-      plugin.deprecated.onAction('branch', 'foo', stub);
-      assert.isFalse(stub.called);
-    });
-  });
-
-  suite('screen', () => {
-    test('screenUrl()', () => {
-      sandbox.stub(BaseUrlBehavior, 'getBaseUrl').returns('/base');
-      assert.equal(
-          plugin.screenUrl(),
-          `${location.origin}/base/x/testplugin`
-      );
-      assert.equal(
-          plugin.screenUrl('foo'),
-          `${location.origin}/base/x/testplugin/foo`
-      );
-    });
-
-    test('deprecated works', () => {
-      const stub = sandbox.stub();
-      const hookStub = {onAttached: sandbox.stub()};
-      sandbox.stub(plugin, 'hook').returns(hookStub);
-      plugin.deprecated.screen('foo', stub);
-      assert.isTrue(plugin.hook.calledWith('testplugin-screen-foo'));
-      const fakeEl = {style: {display: ''}};
-      hookStub.onAttached.callArgWith(0, fakeEl);
-      assert.isTrue(stub.called);
-      assert.equal(fakeEl.style.display, 'none');
-    });
-
-    test('works', () => {
-      sandbox.stub(plugin, 'registerCustomComponent');
-      plugin.screen('foo', 'some-module');
-      assert.isTrue(plugin.registerCustomComponent.calledWith(
-          'testplugin-screen-foo', 'some-module'));
-    });
-  });
-
-  suite('panel', () => {
-    let fakeEl;
-    let emulateAttached;
-
-    setup(()=> {
-      fakeEl = {change: {}, revision: {}};
-      const hookStub = {onAttached: sandbox.stub()};
-      sandbox.stub(plugin, 'hook').returns(hookStub);
-      emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
-    });
-
-    test('plugin.panel is deprecated', () => {
-      plugin.panel('rubbish');
-      assert.isTrue(console.error.called);
-    });
-
-    [
-      ['CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK', 'change-view-integration'],
-      ['CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK', 'change-metadata-item'],
-    ].forEach(([panelName, endpointName]) => {
-      test(`deprecated.panel works for ${panelName}`, () => {
-        const callback = sandbox.stub();
-        plugin.deprecated.panel(panelName, callback);
-        assert.isTrue(plugin.hook.calledWith(endpointName));
-        emulateAttached();
-        assert.isTrue(callback.called);
-        const args = callback.args[0][0];
-        assert.strictEqual(args.body, fakeEl);
-        assert.strictEqual(args.p.CHANGE_INFO, fakeEl.change);
-        assert.strictEqual(args.p.REVISION_INFO, fakeEl.revision);
-      });
-    });
-  });
-
-  suite('settingsScreen', () => {
-    test('plugin.settingsScreen is deprecated', () => {
-      plugin.settingsScreen('rubbish');
-      assert.isTrue(console.error.called);
-    });
-
-    test('plugin.settings() returns GrSettingsApi', () => {
-      assert.isOk(plugin.settings());
-      assert.isTrue(plugin.settings() instanceof GrSettingsApi);
-    });
-
-    test('plugin.deprecated.settingsScreen() works', () => {
-      const hookStub = {onAttached: sandbox.stub()};
-      sandbox.stub(plugin, 'hook').returns(hookStub);
-      const fakeSettings = {};
-      fakeSettings.title = sandbox.stub().returns(fakeSettings);
-      fakeSettings.token = sandbox.stub().returns(fakeSettings);
-      fakeSettings.module = sandbox.stub().returns(fakeSettings);
-      fakeSettings.build = sandbox.stub().returns(hookStub);
-      sandbox.stub(plugin, 'settings').returns(fakeSettings);
-      const callback = sandbox.stub();
-
-      plugin.deprecated.settingsScreen('path', 'menu', callback);
-      assert.isTrue(fakeSettings.title.calledWith('menu'));
-      assert.isTrue(fakeSettings.token.calledWith('path'));
-      assert.isTrue(fakeSettings.module.calledWith('div'));
-      assert.equal(fakeSettings.build.callCount, 1);
-
-      const fakeBody = {};
-      const fakeEl = {
-        style: {
-          display: '',
-        },
-        querySelector: sandbox.stub().returns(fakeBody),
-      };
-      // Emulate settings screen attached
-      hookStub.onAttached.callArgWith(0, fakeEl);
-      assert.isTrue(callback.called);
-      const args = callback.args[0][0];
-      assert.strictEqual(args.body, fakeBody);
-      assert.equal(fakeEl.style.display, 'none');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
new file mode 100644
index 0000000..2a11f62
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -0,0 +1,578 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-js-api-interface.js';
+import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
+import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
+import {GrPluginActionContext} from './gr-plugin-action-context.js';
+import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
+import {pluginLoader} from './gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {stubBaseUrl} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-js-api-interface');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-js-api-interface tests', () => {
+  let element;
+  let plugin;
+  let errorStub;
+
+  let getResponseObjectStub;
+  let sendStub;
+
+  const throwErrFn = function() {
+    throw Error('Unfortunately, this handler has stopped');
+  };
+
+  setup(() => {
+    window.clock = sinon.useFakeTimers();
+
+    getResponseObjectStub = sinon.stub().returns(Promise.resolve());
+    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve({name: 'Judy Hopps'});
+      },
+      getResponseObject: getResponseObjectStub,
+      send(...args) {
+        return sendStub(...args);
+      },
+    });
+    element = basicFixture.instantiate();
+    errorStub = sinon.stub(console, 'error');
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    pluginLoader.loadPlugins([]);
+  });
+
+  teardown(() => {
+    window.clock.restore();
+    element._removeEventCallbacks();
+    plugin = null;
+  });
+
+  test('url', () => {
+    assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
+    assert.equal(plugin.url('/static/test.js'),
+        'http://test.com/plugins/testplugin/static/test.js');
+  });
+
+  test('url for preloaded plugin without ASSETS_PATH', () => {
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'preloaded:testpluginB');
+    assert.equal(plugin.url(),
+        `${window.location.origin}/plugins/testpluginB/`);
+    assert.equal(plugin.url('/static/test.js'),
+        `${window.location.origin}/plugins/testpluginB/static/test.js`);
+  });
+
+  test('url for preloaded plugin without ASSETS_PATH', () => {
+    const oldAssetsPath = window.ASSETS_PATH;
+    window.ASSETS_PATH = 'http://test.com';
+    let plugin;
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'preloaded:testpluginC');
+    assert.equal(plugin.url(), `${window.ASSETS_PATH}/plugins/testpluginC/`);
+    assert.equal(plugin.url('/static/test.js'),
+        `${window.ASSETS_PATH}/plugins/testpluginC/static/test.js`);
+    window.ASSETS_PATH = oldAssetsPath;
+  });
+
+  test('_send on failure rejects with response text', () => {
+    sendStub.returns(Promise.resolve(
+        {status: 400, text() { return Promise.resolve('text'); }}));
+    return plugin._send().catch(r => {
+      assert.equal(r.message, 'text');
+    });
+  });
+
+  test('_send on failure without text rejects with code', () => {
+    sendStub.returns(Promise.resolve(
+        {status: 400, text() { return Promise.resolve(null); }}));
+    return plugin._send().catch(r => {
+      assert.equal(r.message, '400');
+    });
+  });
+
+  test('get', () => {
+    const response = {foo: 'foo'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return plugin.get('/url', r => {
+      assert.isTrue(sendStub.calledWith(
+          'GET', 'http://test.com/plugins/testplugin/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('get using Promise', () => {
+    const response = {foo: 'foo'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return plugin.get('/url', r => 'rubbish').then(r => {
+      assert.isTrue(sendStub.calledWith(
+          'GET', 'http://test.com/plugins/testplugin/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('post', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return plugin.post('/url', payload, r => {
+      assert.isTrue(sendStub.calledWith(
+          'POST', 'http://test.com/plugins/testplugin/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('put', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return plugin.put('/url', payload, r => {
+      assert.isTrue(sendStub.calledWith(
+          'PUT', 'http://test.com/plugins/testplugin/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('delete works', () => {
+    const response = {status: 204};
+    sendStub.returns(Promise.resolve(response));
+    return plugin.delete('/url', r => {
+      assert.equal(sendStub.lastCall.args[0], 'DELETE');
+      assert.equal(
+          sendStub.lastCall.args[1],
+          'http://test.com/plugins/testplugin/url'
+      );
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('delete fails', () => {
+    sendStub.returns(Promise.resolve(
+        {status: 400, text() { return Promise.resolve('text'); }}));
+    return plugin.delete('/url', r => {
+      throw new Error('Should not resolve');
+    }).catch(err => {
+      assert.equal(sendStub.lastCall.args[0], 'DELETE');
+      assert.equal(
+          sendStub.lastCall.args[1],
+          'http://test.com/plugins/testplugin/url'
+      );
+      assert.equal('text', err.message);
+    });
+  });
+
+  test('history event', done => {
+    plugin.on(element.EventType.HISTORY, throwErrFn);
+    plugin.on(element.EventType.HISTORY, path => {
+      assert.equal(path, '/path/to/awesomesauce');
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.HISTORY,
+        {path: '/path/to/awesomesauce'});
+  });
+
+  test('showchange event', done => {
+    const testChange = {
+      _number: 42,
+      revisions: {def: {_number: 2}, abc: {_number: 1}},
+    };
+    const expectedChange = {mergeable: false, ...testChange};
+    plugin.on(element.EventType.SHOW_CHANGE, throwErrFn);
+    plugin.on(element.EventType.SHOW_CHANGE, (change, revision, info) => {
+      assert.deepEqual(change, expectedChange);
+      assert.deepEqual(revision, testChange.revisions.abc);
+      assert.deepEqual(info, {mergeable: false});
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.SHOW_CHANGE,
+        {change: testChange, patchNum: 1, info: {mergeable: false}});
+  });
+
+  test('show-revision-actions event', done => {
+    const testChange = {
+      _number: 42,
+      revisions: {def: {_number: 2}, abc: {_number: 1}},
+    };
+    plugin.on(element.EventType.SHOW_REVISION_ACTIONS, throwErrFn);
+    plugin.on(element.EventType.SHOW_REVISION_ACTIONS, (actions, change) => {
+      assert.deepEqual(change, testChange);
+      assert.deepEqual(actions, {test: {}});
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.SHOW_REVISION_ACTIONS,
+        {change: testChange, revisionActions: {test: {}}});
+  });
+
+  test('handleEvent awaits plugins load', done => {
+    const testChange = {
+      _number: 42,
+      revisions: {def: {_number: 2}, abc: {_number: 1}},
+    };
+    const spy = sinon.spy();
+    pluginLoader.loadPlugins(['plugins/test.html']);
+    plugin.on(element.EventType.SHOW_CHANGE, spy);
+    element.handleEvent(element.EventType.SHOW_CHANGE,
+        {change: testChange, patchNum: 1});
+    assert.isFalse(spy.called);
+
+    // Timeout on loading plugins
+    window.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
+    flush(() => {
+      assert.isTrue(spy.called);
+      done();
+    });
+  });
+
+  test('comment event', done => {
+    const testCommentNode = {foo: 'bar'};
+    plugin.on(element.EventType.COMMENT, throwErrFn);
+    plugin.on(element.EventType.COMMENT, commentNode => {
+      assert.deepEqual(commentNode, testCommentNode);
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.COMMENT, {node: testCommentNode});
+  });
+
+  test('revert event', () => {
+    function appendToRevertMsg(c, revertMsg, originalMsg) {
+      return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
+    }
+
+    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'), 'test');
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(element.EventType.REVERT, throwErrFn);
+    plugin.on(element.EventType.REVERT, appendToRevertMsg);
+    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
+        'test\n> origTest\ninfo');
+    assert.isTrue(errorStub.calledOnce);
+
+    plugin.on(element.EventType.REVERT, appendToRevertMsg);
+    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
+        'test\n> origTest\ninfo\n> origTest\ninfo');
+    assert.isTrue(errorStub.calledTwice);
+  });
+
+  test('postrevert event', () => {
+    function getLabels(c) {
+      return {'Code-Review': 1};
+    }
+
+    assert.deepEqual(element.getLabelValuesPostRevert(null), {});
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(element.EventType.POST_REVERT, throwErrFn);
+    plugin.on(element.EventType.POST_REVERT, getLabels);
+    assert.deepEqual(
+        element.getLabelValuesPostRevert(null), {'Code-Review': 1});
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('commitmsgedit event', done => {
+    const testMsg = 'Test CL commit message';
+    plugin.on(element.EventType.COMMIT_MSG_EDIT, throwErrFn);
+    plugin.on(element.EventType.COMMIT_MSG_EDIT, (change, msg) => {
+      assert.deepEqual(msg, testMsg);
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleCommitMessage(null, testMsg);
+  });
+
+  test('labelchange event', done => {
+    const testChange = {_number: 42};
+    plugin.on(element.EventType.LABEL_CHANGE, throwErrFn);
+    plugin.on(element.EventType.LABEL_CHANGE, change => {
+      assert.deepEqual(change, testChange);
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.LABEL_CHANGE, {change: testChange});
+  });
+
+  test('submitchange', () => {
+    plugin.on(element.EventType.SUBMIT_CHANGE, throwErrFn);
+    plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
+    assert.isTrue(element.canSubmitChange());
+    assert.isTrue(errorStub.calledOnce);
+    plugin.on(element.EventType.SUBMIT_CHANGE, () => false);
+    plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
+    assert.isFalse(element.canSubmitChange());
+    assert.isTrue(errorStub.calledTwice);
+  });
+
+  test('highlightjs-loaded event', done => {
+    const testHljs = {_number: 42};
+    plugin.on(element.EventType.HIGHLIGHTJS_LOADED, throwErrFn);
+    plugin.on(element.EventType.HIGHLIGHTJS_LOADED, hljs => {
+      assert.deepEqual(hljs, testHljs);
+      assert.isTrue(errorStub.calledOnce);
+      done();
+    });
+    element.handleEvent(element.EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
+  });
+
+  test('getLoggedIn', done => {
+    // fake fetch for authCheck
+    sinon.stub(window, 'fetch').callsFake(() => Promise.resolve({status: 204}));
+    plugin.restApi().getLoggedIn()
+        .then(loggedIn => {
+          assert.isTrue(loggedIn);
+          done();
+        });
+  });
+
+  test('attributeHelper', () => {
+    assert.isOk(plugin.attributeHelper());
+  });
+
+  test('deprecated.install', () => {
+    plugin.deprecated.install();
+    assert.strictEqual(plugin.popup, plugin.deprecated.popup);
+    assert.strictEqual(plugin.onAction, plugin.deprecated.onAction);
+    assert.notStrictEqual(plugin.install, plugin.deprecated.install);
+  });
+
+  test('getAdminMenuLinks', () => {
+    const links = [{text: 'a', url: 'b'}, {text: 'c', url: 'd'}];
+    const getCallbacksStub = sinon.stub(element, '_getEventCallbacks')
+        .returns([
+          {getMenuLinks: () => [links[0]]},
+          {getMenuLinks: () => [links[1]]},
+        ]);
+    const result = element.getAdminMenuLinks();
+    assert.deepEqual(result, links);
+    assert.isTrue(getCallbacksStub.calledOnce);
+    assert.equal(getCallbacksStub.lastCall.args[0],
+        element.EventType.ADMIN_MENU_LINKS);
+  });
+
+  suite('test plugin with base url', () => {
+    let baseUrlPlugin;
+
+    setup(() => {
+      stubBaseUrl('/r');
+
+      pluginApi.install(p => { baseUrlPlugin = p; }, '0.1',
+          'http://test.com/r/plugins/baseurlplugin/static/test.js');
+    });
+
+    test('url', () => {
+      assert.notEqual(baseUrlPlugin.url(),
+          'http://test.com/plugins/baseurlplugin/');
+      assert.equal(baseUrlPlugin.url(),
+          'http://test.com/r/plugins/baseurlplugin/');
+      assert.equal(baseUrlPlugin.url('/static/test.js'),
+          'http://test.com/r/plugins/baseurlplugin/static/test.js');
+    });
+  });
+
+  suite('popup', () => {
+    test('popup(element) is deprecated', () => {
+      plugin.popup(document.createElement('div'));
+      assert.isTrue(console.error.calledOnce);
+    });
+
+    test('popup(moduleName) creates popup with component', () => {
+      const openStub = sinon.stub(GrPopupInterface.prototype, 'open').callsFake(
+          function() {
+            // Arrow function can't be used here, because we want to
+            // get properties from the instance of GrPopupInterface
+            // eslint-disable-next-line no-invalid-this
+            const grPopupInterface = this;
+            assert.equal(grPopupInterface.plugin, plugin);
+            assert.equal(grPopupInterface._moduleName, 'some-name');
+          });
+      plugin.popup('some-name');
+      assert.isTrue(openStub.calledOnce);
+    });
+
+    test('deprecated.popup(element) creates popup with element', () => {
+      const el = document.createElement('div');
+      el.textContent = 'some text here';
+      const openStub = sinon.stub(GrPopupInterface.prototype, 'open');
+      openStub.returns(Promise.resolve({
+        _getElement() {
+          return document.createElement('div');
+        }}));
+      plugin.deprecated.popup(el);
+      assert.isTrue(openStub.calledOnce);
+    });
+  });
+
+  suite('onAction', () => {
+    let change;
+    let revision;
+    let actionDetails;
+
+    setup(() => {
+      change = {};
+      revision = {};
+      actionDetails = {__key: 'some'};
+      sinon.stub(plugin, 'on').callsArgWith(1, change, revision);
+      sinon.stub(plugin, 'changeActions').returns({
+        addTapListener: sinon.stub().callsArg(1),
+        getActionDetails: () => actionDetails,
+      });
+    });
+
+    test('returns GrPluginActionContext', () => {
+      const stub = sinon.stub();
+      plugin.deprecated.onAction('change', 'foo', ctx => {
+        assert.isTrue(ctx instanceof GrPluginActionContext);
+        assert.strictEqual(ctx.change, change);
+        assert.strictEqual(ctx.revision, revision);
+        assert.strictEqual(ctx.action, actionDetails);
+        assert.strictEqual(ctx.plugin, plugin);
+        stub();
+      });
+      assert.isTrue(stub.called);
+    });
+
+    test('other actions', () => {
+      const stub = sinon.stub();
+      plugin.deprecated.onAction('project', 'foo', stub);
+      plugin.deprecated.onAction('edit', 'foo', stub);
+      plugin.deprecated.onAction('branch', 'foo', stub);
+      assert.isFalse(stub.called);
+    });
+  });
+
+  suite('screen', () => {
+    test('screenUrl()', () => {
+      stubBaseUrl('/base');
+      assert.equal(
+          plugin.screenUrl(),
+          `${location.origin}/base/x/testplugin`
+      );
+      assert.equal(
+          plugin.screenUrl('foo'),
+          `${location.origin}/base/x/testplugin/foo`
+      );
+    });
+
+    test('deprecated works', () => {
+      const stub = sinon.stub();
+      const hookStub = {onAttached: sinon.stub()};
+      sinon.stub(plugin, 'hook').returns(hookStub);
+      plugin.deprecated.screen('foo', stub);
+      assert.isTrue(plugin.hook.calledWith('testplugin-screen-foo'));
+      const fakeEl = {style: {display: ''}};
+      hookStub.onAttached.callArgWith(0, fakeEl);
+      assert.isTrue(stub.called);
+      assert.equal(fakeEl.style.display, 'none');
+    });
+
+    test('works', () => {
+      sinon.stub(plugin, 'registerCustomComponent');
+      plugin.screen('foo', 'some-module');
+      assert.isTrue(plugin.registerCustomComponent.calledWith(
+          'testplugin-screen-foo', 'some-module'));
+    });
+  });
+
+  suite('panel', () => {
+    let fakeEl;
+    let emulateAttached;
+
+    setup(()=> {
+      fakeEl = {change: {}, revision: {}};
+      const hookStub = {onAttached: sinon.stub()};
+      sinon.stub(plugin, 'hook').returns(hookStub);
+      emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
+    });
+
+    test('plugin.panel is deprecated', () => {
+      plugin.panel('rubbish');
+      assert.isTrue(console.error.called);
+    });
+
+    [
+      ['CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK', 'change-view-integration'],
+      ['CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK', 'change-metadata-item'],
+    ].forEach(([panelName, endpointName]) => {
+      test(`deprecated.panel works for ${panelName}`, () => {
+        const callback = sinon.stub();
+        plugin.deprecated.panel(panelName, callback);
+        assert.isTrue(plugin.hook.calledWith(endpointName));
+        emulateAttached();
+        assert.isTrue(callback.called);
+        const args = callback.args[0][0];
+        assert.strictEqual(args.body, fakeEl);
+        assert.strictEqual(args.p.CHANGE_INFO, fakeEl.change);
+        assert.strictEqual(args.p.REVISION_INFO, fakeEl.revision);
+      });
+    });
+  });
+
+  suite('settingsScreen', () => {
+    test('plugin.settingsScreen is deprecated', () => {
+      plugin.settingsScreen('rubbish');
+      assert.isTrue(console.error.called);
+    });
+
+    test('plugin.settings() returns GrSettingsApi', () => {
+      assert.isOk(plugin.settings());
+      assert.isTrue(plugin.settings() instanceof GrSettingsApi);
+    });
+
+    test('plugin.deprecated.settingsScreen() works', () => {
+      const hookStub = {onAttached: sinon.stub()};
+      sinon.stub(plugin, 'hook').returns(hookStub);
+      const fakeSettings = {};
+      fakeSettings.title = sinon.stub().returns(fakeSettings);
+      fakeSettings.token = sinon.stub().returns(fakeSettings);
+      fakeSettings.module = sinon.stub().returns(fakeSettings);
+      fakeSettings.build = sinon.stub().returns(hookStub);
+      sinon.stub(plugin, 'settings').returns(fakeSettings);
+      const callback = sinon.stub();
+
+      plugin.deprecated.settingsScreen('path', 'menu', callback);
+      assert.isTrue(fakeSettings.title.calledWith('menu'));
+      assert.isTrue(fakeSettings.token.calledWith('path'));
+      assert.isTrue(fakeSettings.module.calledWith('div'));
+      assert.equal(fakeSettings.build.callCount, 1);
+
+      const fakeBody = {};
+      const fakeEl = {
+        style: {
+          display: '',
+        },
+        querySelector: sinon.stub().returns(fakeBody),
+      };
+      // Emulate settings screen attached
+      hookStub.onAttached.callArgWith(0, fakeEl);
+      assert.isTrue(callback.called);
+      const args = callback.args[0][0];
+      assert.strictEqual(args.body, fakeBody);
+      assert.equal(fakeEl.style.display, 'none');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js
index e3256a1..63555da 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js
@@ -14,6 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+
 export function GrPluginActionContext(plugin, action, change, revision) {
   this.action = action;
   this.plugin = plugin;
@@ -47,14 +49,14 @@
 
 GrPluginActionContext.prototype.msg = function(text) {
   const label = document.createElement('gr-label');
-  Polymer.dom(label).appendChild(document.createTextNode(text));
+  dom(label).appendChild(document.createTextNode(text));
   return label;
 };
 
 GrPluginActionContext.prototype.div = function(...els) {
   const div = document.createElement('div');
   for (const el of els) {
-    Polymer.dom(div).appendChild(el);
+    dom(div).appendChild(el);
   }
   return div;
 };
@@ -62,7 +64,7 @@
 GrPluginActionContext.prototype.button = function(label, callbacks) {
   const onClick = callbacks && callbacks.onclick;
   const button = document.createElement('gr-button');
-  Polymer.dom(button).appendChild(document.createTextNode(label));
+  dom(button).appendChild(document.createTextNode(label));
   if (onClick) {
     this.plugin.eventHelper(button).onTap(onClick);
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
deleted file mode 100644
index 08c784ab..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
+++ /dev/null
@@ -1,163 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-plugin-action-context</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-js-api-interface.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GrPluginActionContext} from './gr-plugin-action-context.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-plugin-action-context tests', () => {
-  let instance;
-  let sandbox;
-  let plugin;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    instance = new GrPluginActionContext(plugin);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('popup() and hide()', () => {
-    const popupApiStub = {
-      close: sandbox.stub(),
-    };
-    sandbox.stub(plugin.deprecated, 'popup').returns(popupApiStub);
-    const el = {};
-    instance.popup(el);
-    assert.isTrue(instance.plugin.deprecated.popup.calledWith(el));
-
-    instance.hide();
-    assert.isTrue(popupApiStub.close.called);
-  });
-
-  test('textfield', () => {
-    assert.equal(instance.textfield().tagName, 'PAPER-INPUT');
-  });
-
-  test('br', () => {
-    assert.equal(instance.br().tagName, 'BR');
-  });
-
-  test('msg', () => {
-    const el = instance.msg('foobar');
-    assert.equal(el.tagName, 'GR-LABEL');
-    assert.equal(el.textContent, 'foobar');
-  });
-
-  test('div', () => {
-    const el1 = document.createElement('span');
-    el1.textContent = 'foo';
-    const el2 = document.createElement('div');
-    el2.textContent = 'bar';
-    const div = instance.div(el1, el2);
-    assert.equal(div.tagName, 'DIV');
-    assert.equal(div.textContent, 'foobar');
-  });
-
-  test('button', done => {
-    const clickStub = sandbox.stub();
-    const button = instance.button('foo', {onclick: clickStub});
-    // If you don't attach a Polymer element to the DOM, then the ready()
-    // callback will not be called and then e.g. this.$ is undefined.
-    dom(document.body).appendChild(button);
-    MockInteractions.tap(button);
-    flush(() => {
-      assert.isTrue(clickStub.called);
-      assert.equal(button.textContent, 'foo');
-      done();
-    });
-  });
-
-  test('checkbox', () => {
-    const el = instance.checkbox();
-    assert.equal(el.tagName, 'INPUT');
-    assert.equal(el.type, 'checkbox');
-  });
-
-  test('label', () => {
-    const fakeMsg = {};
-    const fakeCheckbox = {};
-    sandbox.stub(instance, 'div');
-    sandbox.stub(instance, 'msg').returns(fakeMsg);
-    instance.label(fakeCheckbox, 'foo');
-    assert.isTrue(instance.div.calledWithExactly(fakeCheckbox, fakeMsg));
-  });
-
-  test('call', () => {
-    instance.action = {
-      method: 'METHOD',
-      __key: 'key',
-      __url: '/changes/1/revisions/2/foo~bar',
-    };
-    const sendStub = sandbox.stub().returns(Promise.resolve());
-    sandbox.stub(plugin, 'restApi').returns({
-      send: sendStub,
-    });
-    const payload = {foo: 'foo'};
-    const successStub = sandbox.stub();
-    instance.call(payload, successStub);
-    assert.isTrue(sendStub.calledWith(
-        'METHOD', '/changes/1/revisions/2/foo~bar', payload));
-  });
-
-  test('call error', done => {
-    instance.action = {
-      method: 'METHOD',
-      __key: 'key',
-      __url: '/changes/1/revisions/2/foo~bar',
-    };
-    const sendStub = sandbox.stub().returns(Promise.reject(new Error('boom')));
-    sandbox.stub(plugin, 'restApi').returns({
-      send: sendStub,
-    });
-    const errorStub = sandbox.stub();
-    document.addEventListener('show-alert', errorStub);
-    instance.call();
-    flush(() => {
-      assert.isTrue(errorStub.calledOnce);
-      assert.equal(errorStub.args[0][0].detail.message,
-          'Plugin network error: Error: boom');
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
new file mode 100644
index 0000000..dde3c04
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
@@ -0,0 +1,152 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-js-api-interface.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GrPluginActionContext} from './gr-plugin-action-context.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-plugin-action-context tests', () => {
+  let instance;
+
+  let plugin;
+
+  setup(() => {
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    instance = new GrPluginActionContext(plugin);
+  });
+
+  test('popup() and hide()', () => {
+    const popupApiStub = {
+      close: sinon.stub(),
+    };
+    sinon.stub(plugin.deprecated, 'popup').returns(popupApiStub);
+    const el = {};
+    instance.popup(el);
+    assert.isTrue(instance.plugin.deprecated.popup.calledWith(el));
+
+    instance.hide();
+    assert.isTrue(popupApiStub.close.called);
+  });
+
+  test('textfield', () => {
+    assert.equal(instance.textfield().tagName, 'PAPER-INPUT');
+  });
+
+  test('br', () => {
+    assert.equal(instance.br().tagName, 'BR');
+  });
+
+  test('msg', () => {
+    const el = instance.msg('foobar');
+    assert.equal(el.tagName, 'GR-LABEL');
+    assert.equal(el.textContent, 'foobar');
+  });
+
+  test('div', () => {
+    const el1 = document.createElement('span');
+    el1.textContent = 'foo';
+    const el2 = document.createElement('div');
+    el2.textContent = 'bar';
+    const div = instance.div(el1, el2);
+    assert.equal(div.tagName, 'DIV');
+    assert.equal(div.textContent, 'foobar');
+  });
+
+  suite('button', () => {
+    let clickStub;
+    let button;
+    setup(() => {
+      clickStub = sinon.stub();
+      button = instance.button('foo', {onclick: clickStub});
+      // If you don't attach a Polymer element to the DOM, then the ready()
+      // callback will not be called and then e.g. this.$ is undefined.
+      dom(document.body).appendChild(button);
+    });
+
+    test('click', done => {
+      MockInteractions.tap(button);
+      flush(() => {
+        assert.isTrue(clickStub.called);
+        assert.equal(button.textContent, 'foo');
+        done();
+      });
+    });
+
+    teardown(() => {
+      button.remove();
+    });
+  });
+
+  test('checkbox', () => {
+    const el = instance.checkbox();
+    assert.equal(el.tagName, 'INPUT');
+    assert.equal(el.type, 'checkbox');
+  });
+
+  test('label', () => {
+    const fakeMsg = {};
+    const fakeCheckbox = {};
+    sinon.stub(instance, 'div');
+    sinon.stub(instance, 'msg').returns(fakeMsg);
+    instance.label(fakeCheckbox, 'foo');
+    assert.isTrue(instance.div.calledWithExactly(fakeCheckbox, fakeMsg));
+  });
+
+  test('call', () => {
+    instance.action = {
+      method: 'METHOD',
+      __key: 'key',
+      __url: '/changes/1/revisions/2/foo~bar',
+    };
+    const sendStub = sinon.stub().returns(Promise.resolve());
+    sinon.stub(plugin, 'restApi').returns({
+      send: sendStub,
+    });
+    const payload = {foo: 'foo'};
+    const successStub = sinon.stub();
+    instance.call(payload, successStub);
+    assert.isTrue(sendStub.calledWith(
+        'METHOD', '/changes/1/revisions/2/foo~bar', payload));
+  });
+
+  test('call error', done => {
+    instance.action = {
+      method: 'METHOD',
+      __key: 'key',
+      __url: '/changes/1/revisions/2/foo~bar',
+    };
+    const sendStub = sinon.stub().returns(Promise.reject(new Error('boom')));
+    sinon.stub(plugin, 'restApi').returns({
+      send: sendStub,
+    });
+    const errorStub = sinon.stub();
+    document.addEventListener('show-alert', errorStub);
+    instance.call();
+    flush(() => {
+      assert.isTrue(errorStub.calledOnce);
+      assert.equal(errorStub.args[0][0].detail.message,
+          'Plugin network error: Error: boom');
+      done();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
deleted file mode 100644
index 0727397..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
+++ /dev/null
@@ -1,169 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {pluginLoader} from './gr-plugin-loader.js';
-
-/** @constructor */
-export function GrPluginEndpoints() {
-  this._endpoints = {};
-  this._callbacks = {};
-  this._dynamicPlugins = {};
-}
-
-GrPluginEndpoints.prototype.onNewEndpoint = function(endpoint, callback) {
-  if (!this._callbacks[endpoint]) {
-    this._callbacks[endpoint] = [];
-  }
-  this._callbacks[endpoint].push(callback);
-};
-
-GrPluginEndpoints.prototype.onDetachedEndpoint = function(endpoint,
-    callback) {
-  if (this._callbacks[endpoint]) {
-    this._callbacks[endpoint] = this._callbacks[endpoint]
-        .filter(cb => cb !== callback);
-  }
-};
-
-GrPluginEndpoints.prototype._getOrCreateModuleInfo = function(plugin, opts) {
-  const {endpoint, slot, type, moduleName, domHook} = opts;
-  const existingModule = this._endpoints[endpoint].find(info =>
-    info.plugin === plugin &&
-      info.moduleName === moduleName &&
-      info.domHook === domHook &&
-      info.slot === slot
-  );
-  if (existingModule) {
-    return existingModule;
-  } else {
-    const newModule = {
-      moduleName,
-      plugin,
-      pluginUrl: plugin._url,
-      type,
-      domHook,
-      slot,
-    };
-    this._endpoints[endpoint].push(newModule);
-    return newModule;
-  }
-};
-
-/**
- * Register a plugin to an endpoint.
- *
- * Dynamic plugins are registered to a specific prefix, such as
- * 'change-list-header'. These plugins are then fetched by prefix to determine
- * which endpoints to dynamically add to the page.
- *
- * @param {Object} plugin
- * @param {Object} opts
- */
-GrPluginEndpoints.prototype.registerModule = function(plugin, opts) {
-  const {endpoint, dynamicEndpoint} = opts;
-  if (dynamicEndpoint) {
-    if (!this._dynamicPlugins[dynamicEndpoint]) {
-      this._dynamicPlugins[dynamicEndpoint] = new Set();
-    }
-    this._dynamicPlugins[dynamicEndpoint].add(endpoint);
-  }
-  if (!this._endpoints[endpoint]) {
-    this._endpoints[endpoint] = [];
-  }
-  const moduleInfo = this._getOrCreateModuleInfo(plugin, opts);
-  if (pluginLoader.arePluginsLoaded() && this._callbacks[endpoint]) {
-    this._callbacks[endpoint].forEach(callback => callback(moduleInfo));
-  }
-};
-
-GrPluginEndpoints.prototype.getDynamicEndpoints = function(dynamicEndpoint) {
-  const plugins = this._dynamicPlugins[dynamicEndpoint];
-  if (!plugins) return [];
-  return Array.from(plugins);
-};
-
-/**
- * Get detailed information about modules registered with an extension
- * endpoint.
- *
- * @param {string} name Endpoint name.
- * @param {?{
- *   type: (string|undefined),
- *   moduleName: (string|undefined)
- * }} opt_options
- * @return {!Array<{
- *   moduleName: string,
- *   plugin: Plugin,
- *   pluginUrl: String,
- *   type: EndpointType,
- *   domHook: !Object
- * }>}
- */
-GrPluginEndpoints.prototype.getDetails = function(name, opt_options) {
-  const type = opt_options && opt_options.type;
-  const moduleName = opt_options && opt_options.moduleName;
-  if (!this._endpoints[name]) {
-    return [];
-  }
-  return this._endpoints[name]
-      .filter(item => (!type || item.type === type) &&
-                  (!moduleName || moduleName == item.moduleName));
-};
-
-/**
- * Get detailed module names for instantiating at the endpoint.
- *
- * @param {string} name Endpoint name.
- * @param {?{
- *   type: (string|undefined),
- *   moduleName: (string|undefined)
- * }} opt_options
- * @return {!Array<string>}
- */
-GrPluginEndpoints.prototype.getModules = function(name, opt_options) {
-  const modulesData = this.getDetails(name, opt_options);
-  if (!modulesData.length) {
-    return [];
-  }
-  return modulesData.map(m => m.moduleName);
-};
-
-/**
- * Get .html plugin URLs with element and module definitions.
- *
- * @param {string} name Endpoint name.
- * @param {?{
- *   type: (string|undefined),
- *   moduleName: (string|undefined)
- * }} opt_options
- * @return {!Array<!URL>}
- */
-GrPluginEndpoints.prototype.getPlugins = function(name, opt_options) {
-  const modulesData =
-        this.getDetails(name, opt_options).filter(
-            data => data.pluginUrl.pathname.includes('.html'));
-  if (!modulesData.length) {
-    return [];
-  }
-  return Array.from(new Set(modulesData.map(m => m.pluginUrl)));
-};
-
-// TODO(dmfilippov): Convert to service and add to appContext
-export let pluginEndpoints = new GrPluginEndpoints();
-export function _testOnly_resetEndpoints() {
-  pluginEndpoints = new GrPluginEndpoints();
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
new file mode 100644
index 0000000..3388437
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -0,0 +1,228 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {importHref} from '../../../scripts/import-href';
+
+type Callback = (value: any) => void;
+
+interface ModuleInfo {
+  moduleName: string;
+  // TODO(TS): Convert type to GrPlugin.
+  plugin: any;
+  pluginUrl: URL;
+  type?: string;
+  // TODO(TS): Convert type to GrDomHook.
+  domHook: any;
+  slot?: string;
+}
+
+interface Options {
+  endpoint: string;
+  dynamicEndpoint?: string;
+  slot?: string;
+  type?: string;
+  moduleName?: string;
+  // TODO(TS): Convert type to GrDomHook.
+  domHook?: any;
+}
+
+export class GrPluginEndpoints {
+  private readonly _endpoints = new Map<string, ModuleInfo[]>();
+
+  private readonly _callbacks = new Map<string, ((value: any) => void)[]>();
+
+  private readonly _dynamicPlugins = new Map<string, Set<string>>();
+
+  private readonly _importedUrls = new Set<string>();
+
+  private _pluginLoaded = false;
+
+  setPluginsReady() {
+    this._pluginLoaded = true;
+  }
+
+  onNewEndpoint(endpoint: string, callback: Callback) {
+    if (!this._callbacks.has(endpoint)) {
+      this._callbacks.set(endpoint, []);
+    }
+    this._callbacks.get(endpoint)!.push(callback);
+  }
+
+  onDetachedEndpoint(endpoint: string, callback: Callback) {
+    if (this._callbacks.has(endpoint)) {
+      const filteredCallbacks = this._callbacks
+        .get(endpoint)!
+        .filter((cb: Callback) => cb !== callback);
+      this._callbacks.set(endpoint, filteredCallbacks);
+    }
+  }
+
+  _getOrCreateModuleInfo(plugin: any, opts: Options): ModuleInfo {
+    const {endpoint, slot, type, moduleName, domHook} = opts;
+    const existingModule = this._endpoints
+      .get(endpoint!)!
+      .find(
+        (info: ModuleInfo) =>
+          info.plugin === plugin &&
+          info.moduleName === moduleName &&
+          info.domHook === domHook &&
+          info.slot === slot
+      );
+    if (existingModule) {
+      return existingModule;
+    } else {
+      const newModule: ModuleInfo = {
+        moduleName: moduleName!,
+        plugin,
+        pluginUrl: plugin._url,
+        type,
+        domHook,
+        slot,
+      };
+      this._endpoints.get(endpoint!)!.push(newModule);
+      return newModule;
+    }
+  }
+
+  /**
+   * Register a plugin to an endpoint.
+   *
+   * Dynamic plugins are registered to a specific prefix, such as
+   * 'change-list-header'. These plugins are then fetched by prefix to determine
+   * which endpoints to dynamically add to the page.
+   *
+   * @param {Object} plugin
+   * @param {Object} opts
+   */
+  registerModule(plugin: any, opts: Options) {
+    const endpoint = opts.endpoint!;
+    const dynamicEndpoint = opts.dynamicEndpoint;
+    if (dynamicEndpoint) {
+      if (!this._dynamicPlugins.has(dynamicEndpoint)) {
+        this._dynamicPlugins.set(dynamicEndpoint, new Set());
+      }
+      this._dynamicPlugins.get(dynamicEndpoint)!.add(endpoint);
+    }
+    if (!this._endpoints.has(endpoint)) {
+      this._endpoints.set(endpoint, []);
+    }
+    const moduleInfo = this._getOrCreateModuleInfo(plugin, opts);
+    // TODO: the logic below seems wrong when:
+    // multiple plugins register to the same endpoint
+    // one register before plugins ready
+    // the other done after, then only the later one will have the callbacks
+    // invoked.
+    if (this._pluginLoaded && this._callbacks.has(endpoint)) {
+      this._callbacks.get(endpoint)!.forEach(callback => callback(moduleInfo));
+    }
+  }
+
+  getDynamicEndpoints(dynamicEndpoint: string): string[] {
+    const plugins = this._dynamicPlugins.get(dynamicEndpoint);
+    if (!plugins) return [];
+    return Array.from(plugins);
+  }
+
+  /**
+   * Get detailed information about modules registered with an extension
+   * endpoint.
+   */
+  getDetails(name: string, options?: Options): ModuleInfo[] {
+    const type = options && options.type;
+    const moduleName = options && options.moduleName;
+    if (!this._endpoints.has(name)) {
+      return [];
+    } else {
+      return this._endpoints
+        .get(name)!
+        .filter(
+          (item: ModuleInfo) =>
+            (!type || item.type === type) &&
+            (!moduleName || moduleName === item.moduleName)
+        );
+    }
+  }
+
+  /**
+   * Get detailed module names for instantiating at the endpoint.
+   */
+  getModules(name: string, options?: Options): string[] {
+    const modulesData = this.getDetails(name, options);
+    if (!modulesData.length) {
+      return [];
+    }
+    return modulesData.map(m => m.moduleName);
+  }
+
+  /**
+   * Get plugin URLs with element and module definitions.
+   */
+  getPlugins(name: string, options?: Options): URL[] {
+    const modulesData = this.getDetails(name, options);
+    if (!modulesData.length) {
+      return [];
+    }
+    return Array.from(new Set(modulesData.map(m => m.pluginUrl)));
+  }
+
+  importUrl(pluginUrl: URL) {
+    let timerId: any;
+    return Promise.race([
+      new Promise((resolve, reject) => {
+        this._importedUrls.add(pluginUrl.href);
+        importHref(pluginUrl.href, resolve, reject);
+      }),
+      // Timeout after 3s
+      new Promise(r => (timerId = setTimeout(r, 3000))),
+    ]).finally(() => {
+      if (timerId) clearTimeout(timerId);
+    });
+  }
+
+  /**
+   * Get plugin URLs with element and module definitions.
+   */
+  getAndImportPlugins(name: string, options?: Options) {
+    return Promise.all(
+      this.getPlugins(name, options).map(pluginUrl => {
+        if (this._importedUrls.has(pluginUrl.href)) {
+          return Promise.resolve();
+        }
+
+        // TODO: we will deprecate html plugins entirely
+        // for now, keep the original behavior and import
+        // only for html ones
+        if (pluginUrl && pluginUrl.pathname.endsWith('.html')) {
+          return this.importUrl(pluginUrl);
+        } else {
+          return Promise.resolve();
+        }
+      })
+    );
+  }
+}
+
+// TODO(dmfilippov): Convert to service and add to appContext
+let pluginEndpoints = new GrPluginEndpoints();
+
+// To avoid mutable-exports, we don't want to export above variable directly
+export function getPluginEndpoints() {
+  return pluginEndpoints;
+}
+export function _testOnly_resetEndpoints() {
+  pluginEndpoints = new GrPluginEndpoints();
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
deleted file mode 100644
index 3494e99..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
+++ /dev/null
@@ -1,180 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-plugin-endpoints</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-js-api-interface.js';
-import {GrPluginEndpoints} from './gr-plugin-endpoints.js';
-import {pluginLoader} from './gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-plugin-endpoints tests', () => {
-  let sandbox;
-  let instance;
-  let pluginFoo;
-  let pluginBar;
-  let domHook;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    domHook = {};
-    instance = new GrPluginEndpoints();
-    pluginApi.install(p => { pluginFoo = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/foo.html');
-    instance.registerModule(
-        pluginFoo,
-        {
-          endpoint: 'a-place',
-          type: 'decorate',
-          moduleName: 'foo-module',
-          domHook,
-        }
-    );
-    pluginApi.install(p => { pluginBar = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/bar.html');
-    instance.registerModule(
-        pluginBar,
-        {
-          endpoint: 'a-place',
-          type: 'style',
-          moduleName: 'bar-module',
-          domHook,
-        }
-    );
-    sandbox.stub(pluginLoader, 'arePluginsLoaded').returns(true);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('getDetails all', () => {
-    assert.deepEqual(instance.getDetails('a-place'), [
-      {
-        moduleName: 'foo-module',
-        plugin: pluginFoo,
-        pluginUrl: pluginFoo._url,
-        type: 'decorate',
-        domHook,
-        slot: undefined,
-      },
-      {
-        moduleName: 'bar-module',
-        plugin: pluginBar,
-        pluginUrl: pluginBar._url,
-        type: 'style',
-        domHook,
-        slot: undefined,
-      },
-    ]);
-  });
-
-  test('getDetails by type', () => {
-    assert.deepEqual(instance.getDetails('a-place', {type: 'style'}), [
-      {
-        moduleName: 'bar-module',
-        plugin: pluginBar,
-        pluginUrl: pluginBar._url,
-        type: 'style',
-        domHook,
-        slot: undefined,
-      },
-    ]);
-  });
-
-  test('getDetails by module', () => {
-    assert.deepEqual(
-        instance.getDetails('a-place', {moduleName: 'foo-module'}),
-        [
-          {
-            moduleName: 'foo-module',
-            plugin: pluginFoo,
-            pluginUrl: pluginFoo._url,
-            type: 'decorate',
-            domHook,
-            slot: undefined,
-          },
-        ]);
-  });
-
-  test('getModules', () => {
-    assert.deepEqual(
-        instance.getModules('a-place'), ['foo-module', 'bar-module']);
-  });
-
-  test('getPlugins', () => {
-    assert.deepEqual(
-        instance.getPlugins('a-place'), [pluginFoo._url]);
-  });
-
-  test('onNewEndpoint', () => {
-    const newModuleStub = sandbox.stub();
-    instance.onNewEndpoint('a-place', newModuleStub);
-    instance.registerModule(
-        pluginFoo,
-        {
-          endpoint: 'a-place',
-          type: 'replace',
-          moduleName: 'zaz-module',
-          domHook,
-        });
-    assert.deepEqual(newModuleStub.lastCall.args[0], {
-      moduleName: 'zaz-module',
-      plugin: pluginFoo,
-      pluginUrl: pluginFoo._url,
-      type: 'replace',
-      domHook,
-      slot: undefined,
-    });
-  });
-
-  test('reuse dom hooks', () => {
-    instance.registerModule(
-        pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
-    assert.deepEqual(instance.getDetails('a-place'), [
-      {
-        moduleName: 'foo-module',
-        plugin: pluginFoo,
-        pluginUrl: pluginFoo._url,
-        type: 'decorate',
-        domHook,
-        slot: undefined,
-      },
-      {
-        moduleName: 'bar-module',
-        plugin: pluginBar,
-        pluginUrl: pluginBar._url,
-        type: 'style',
-        domHook,
-        slot: undefined,
-      },
-    ]);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js
new file mode 100644
index 0000000..5b931b4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js
@@ -0,0 +1,175 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {resetPlugins} from '../../../test/test-utils.js';
+import './gr-js-api-interface.js';
+import {GrPluginEndpoints} from './gr-plugin-endpoints.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-plugin-endpoints tests', () => {
+  let instance;
+  let pluginFoo;
+  let pluginBar;
+  let domHook;
+
+  setup(() => {
+    domHook = {};
+    instance = new GrPluginEndpoints();
+    pluginApi.install(p => { pluginFoo = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/foo.html');
+    instance.registerModule(
+        pluginFoo,
+        {
+          endpoint: 'a-place',
+          type: 'decorate',
+          moduleName: 'foo-module',
+          domHook,
+        }
+    );
+    pluginApi.install(p => { pluginBar = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/bar.html');
+    instance.registerModule(
+        pluginBar,
+        {
+          endpoint: 'a-place',
+          type: 'style',
+          moduleName: 'bar-module',
+          domHook,
+        }
+    );
+    sinon.spy(instance, 'importUrl');
+  });
+
+  teardown(() => {
+    resetPlugins();
+  });
+
+  test('getDetails all', () => {
+    assert.deepEqual(instance.getDetails('a-place'), [
+      {
+        moduleName: 'foo-module',
+        plugin: pluginFoo,
+        pluginUrl: pluginFoo._url,
+        type: 'decorate',
+        domHook,
+        slot: undefined,
+      },
+      {
+        moduleName: 'bar-module',
+        plugin: pluginBar,
+        pluginUrl: pluginBar._url,
+        type: 'style',
+        domHook,
+        slot: undefined,
+      },
+    ]);
+  });
+
+  test('getDetails by type', () => {
+    assert.deepEqual(instance.getDetails('a-place', {type: 'style'}), [
+      {
+        moduleName: 'bar-module',
+        plugin: pluginBar,
+        pluginUrl: pluginBar._url,
+        type: 'style',
+        domHook,
+        slot: undefined,
+      },
+    ]);
+  });
+
+  test('getDetails by module', () => {
+    assert.deepEqual(
+        instance.getDetails('a-place', {moduleName: 'foo-module'}),
+        [
+          {
+            moduleName: 'foo-module',
+            plugin: pluginFoo,
+            pluginUrl: pluginFoo._url,
+            type: 'decorate',
+            domHook,
+            slot: undefined,
+          },
+        ]);
+  });
+
+  test('getModules', () => {
+    assert.deepEqual(
+        instance.getModules('a-place'), ['foo-module', 'bar-module']);
+  });
+
+  test('getPlugins', () => {
+    assert.deepEqual(
+        instance.getPlugins('a-place'), [pluginFoo._url]);
+  });
+
+  test('getAndImportPlugins', () => {
+    instance.getAndImportPlugins('a-place');
+    assert.isTrue(instance.importUrl.called);
+    assert.isTrue(instance.importUrl.calledOnce);
+    instance.getAndImportPlugins('a-place');
+    assert.isTrue(instance.importUrl.calledOnce);
+  });
+
+  test('onNewEndpoint', () => {
+    const newModuleStub = sinon.stub();
+    instance.setPluginsReady();
+    instance.onNewEndpoint('a-place', newModuleStub);
+    instance.registerModule(
+        pluginFoo,
+        {
+          endpoint: 'a-place',
+          type: 'replace',
+          moduleName: 'zaz-module',
+          domHook,
+        });
+    assert.deepEqual(newModuleStub.lastCall.args[0], {
+      moduleName: 'zaz-module',
+      plugin: pluginFoo,
+      pluginUrl: pluginFoo._url,
+      type: 'replace',
+      domHook,
+      slot: undefined,
+    });
+  });
+
+  test('reuse dom hooks', () => {
+    instance.registerModule(
+        pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
+    assert.deepEqual(instance.getDetails('a-place'), [
+      {
+        moduleName: 'foo-module',
+        plugin: pluginFoo,
+        pluginUrl: pluginFoo._url,
+        type: 'decorate',
+        domHook,
+        slot: undefined,
+      },
+      {
+        moduleName: 'bar-module',
+        plugin: pluginBar,
+        pluginUrl: pluginBar._url,
+        type: 'style',
+        domHook,
+        slot: undefined,
+      },
+    ]);
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
index 2f27304..6b8f73e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
@@ -1,3 +1,5 @@
+import {appContext} from '../../../services/app-context.js';
+
 /**
  * @license
  * Copyright (C) 2019 The Android Open Source Project
@@ -14,14 +16,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import './gr-api-utils.js';
-
+import {importHref} from '../../../scripts/import-href.js';
 import {
   PLUGIN_LOADING_TIMEOUT_MS,
   PRELOADED_PROTOCOL,
   getPluginNameFromUrl,
-  getBaseUrl,
 } from './gr-api-utils.js';
+import {Plugin} from './gr-public-js-api.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
+import {getPluginEndpoints} from './gr-plugin-endpoints.js';
 
 /**
  * @enum {string}
@@ -85,7 +88,7 @@
 
   _getReporting() {
     if (!this._reporting) {
-      this._reporting = document.createElement('gr-reporting');
+      this._reporting = appContext.reportingService;
     }
     return this._reporting;
   }
@@ -206,10 +209,13 @@
   }
 
   _checkIfCompleted() {
-    if (this.arePluginsLoaded() && this._loadingResolver) {
-      this._loadingResolver();
-      this._loadingResolver = null;
-      this._loadingPromise = null;
+    if (this.arePluginsLoaded()) {
+      getPluginEndpoints().setPluginsReady();
+      if (this._loadingResolver) {
+        this._loadingResolver();
+        this._loadingResolver = null;
+        this._loadingPromise = null;
+      }
     }
   }
 
@@ -242,7 +248,7 @@
       this._plugins.get(key).state = state;
     } else {
       // Plugin is not recorded for some reason.
-      console.warn(`Plugin loaded separately: ${pluginUrl}`);
+      console.info(`Plugin loaded separately: ${pluginUrl}`);
       this._plugins.set(key, {
         name: key,
         url: pluginUrl,
@@ -257,7 +263,7 @@
     const pluginObj = this._updatePluginState(url, PluginState.LOADED);
     pluginObj.plugin = plugin;
     this._getReporting().pluginLoaded(plugin.getPluginName() || url);
-    console.log(`Plugin ${plugin.getPluginName() || url} installed.`);
+    console.info(`Plugin ${plugin.getPluginName() || url} installed.`);
     this._checkIfCompleted();
   }
 
@@ -333,7 +339,7 @@
       };
     }
 
-    (Polymer.importHref || Polymer.Base.importHref)(
+    importHref(
         url, () => {},
         onerror,
         !sync);
@@ -372,7 +378,8 @@
     }
 
     // theme is per host, should always load from assetsPath
-    const isThemeFile = pathOrUrl.endsWith('static/gerrit-theme.html');
+    const isThemeFile = pathOrUrl.endsWith('static/gerrit-theme.html') ||
+      pathOrUrl.endsWith('static/gerrit-theme.js');
     const shouldTryLoadFromAssetsPathFirst = !isThemeFile && assetsPath;
     if (pathOrUrl.startsWith(PRELOADED_PROTOCOL) ||
         pathOrUrl.startsWith('http')) {
@@ -413,7 +420,7 @@
               () => {
                 reject(new Error(this._timeout()));
               }, PLUGIN_LOADING_TIMEOUT_MS)),
-        ]).then(() => {
+        ]).finally(() => {
           if (timerId) clearTimeout(timerId);
         });
     }
@@ -442,4 +449,5 @@
 export let pluginLoader = new PluginLoader();
 export function _testOnly_resetPluginLoader() {
   pluginLoader = new PluginLoader();
+  return pluginLoader;
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
deleted file mode 100644
index c972f53..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.html
+++ /dev/null
@@ -1,570 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-plugin-host</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-js-api-interface></gr-js-api-interface>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-js-api-interface.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {PRELOADED_PROTOCOL, PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
-import {pluginLoader} from './gr-plugin-loader.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {_testOnly_flushPreinstalls} from './gr-gerrit.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-plugin-loader tests', () => {
-  let plugin;
-  let sandbox;
-  let url;
-  let sendStub;
-
-  setup(() => {
-    window.clock = sinon.useFakeTimers();
-    sandbox = sinon.sandbox.create();
-    sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
-    stub('gr-rest-api-interface', {
-      getAccount() {
-        return Promise.resolve({name: 'Judy Hopps'});
-      },
-      send(...args) {
-        return sendStub(...args);
-      },
-    });
-    sandbox.stub(document.body, 'appendChild');
-    fixture('basic');
-    url = window.location.origin;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-    window.clock.restore();
-    resetPlugins();
-  });
-
-  test('reuse plugin for install calls', () => {
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-
-    let otherPlugin;
-    pluginApi.install(p => { otherPlugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    assert.strictEqual(plugin, otherPlugin);
-  });
-
-  test('flushes preinstalls if provided', () => {
-    assert.doesNotThrow(() => {
-      _testOnly_flushPreinstalls();
-    });
-    window.Gerrit.flushPreinstalls = sandbox.stub();
-    _testOnly_flushPreinstalls();
-    assert.isTrue(window.Gerrit.flushPreinstalls.calledOnce);
-    delete window.Gerrit.flushPreinstalls;
-  });
-
-  test('versioning', () => {
-    const callback = sandbox.spy();
-    pluginApi.install(callback, '0.0pre-alpha');
-    assert(callback.notCalled);
-  });
-
-  test('report pluginsLoaded', done => {
-    stub('gr-reporting', {
-      pluginsLoaded() {
-        done();
-      },
-    });
-    pluginLoader.loadPlugins([]);
-  });
-
-  test('arePluginsLoaded', done => {
-    assert.isFalse(pluginLoader.arePluginsLoaded());
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/bar/static/test.js',
-    ];
-
-    pluginLoader.loadPlugins(plugins);
-    assert.isFalse(pluginLoader.arePluginsLoaded());
-    // Timeout on loading plugins
-    window.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
-
-    flush(() => {
-      assert.isTrue(pluginLoader.arePluginsLoaded());
-      done();
-    });
-  });
-
-  test('plugins installed successfully', done => {
-    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
-      pluginApi.install(() => void 0, undefined, url);
-    });
-    const pluginsLoadedStub = sandbox.stub();
-    stub('gr-reporting', {
-      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-    });
-
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/bar/static/test.js',
-    ];
-    pluginLoader.loadPlugins(plugins);
-
-    flush(() => {
-      assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
-      assert.isTrue(pluginLoader.arePluginsLoaded());
-      done();
-    });
-  });
-
-  test('isPluginEnabled and isPluginLoaded', done => {
-    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
-      pluginApi.install(() => void 0, undefined, url);
-    });
-    const pluginsLoadedStub = sandbox.stub();
-    stub('gr-reporting', {
-      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-    });
-
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/bar/static/test.js',
-      'bar/static/test.js',
-    ];
-    pluginLoader.loadPlugins(plugins);
-    assert.isTrue(
-        plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
-    );
-
-    flush(() => {
-      assert.isTrue(pluginLoader.arePluginsLoaded());
-      assert.isTrue(
-          plugins.every(plugin => pluginLoader.isPluginLoaded(plugin))
-      );
-
-      done();
-    });
-  });
-
-  test('plugins installed mixed result, 1 fail 1 succeed', done => {
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/bar/static/test.js',
-    ];
-
-    const alertStub = sandbox.stub();
-    document.addEventListener('show-alert', alertStub);
-
-    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
-      pluginApi.install(() => {
-        if (url === plugins[0]) {
-          throw new Error('failed');
-        }
-      }, undefined, url);
-    });
-
-    const pluginsLoadedStub = sandbox.stub();
-    stub('gr-reporting', {
-      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-    });
-
-    pluginLoader.loadPlugins(plugins);
-
-    flush(() => {
-      assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
-      assert.isTrue(pluginLoader.arePluginsLoaded());
-      assert.isTrue(alertStub.calledOnce);
-      done();
-    });
-  });
-
-  test('isPluginEnabled and isPluginLoaded for mixed results', done => {
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/bar/static/test.js',
-    ];
-
-    const alertStub = sandbox.stub();
-    document.addEventListener('show-alert', alertStub);
-
-    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
-      pluginApi.install(() => {
-        if (url === plugins[0]) {
-          throw new Error('failed');
-        }
-      }, undefined, url);
-    });
-
-    const pluginsLoadedStub = sandbox.stub();
-    stub('gr-reporting', {
-      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-    });
-
-    pluginLoader.loadPlugins(plugins);
-    assert.isTrue(
-        plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
-    );
-
-    flush(() => {
-      assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
-      assert.isTrue(pluginLoader.arePluginsLoaded());
-      assert.isTrue(alertStub.calledOnce);
-      assert.isTrue(pluginLoader.isPluginLoaded(plugins[1]));
-      assert.isFalse(pluginLoader.isPluginLoaded(plugins[0]));
-      done();
-    });
-  });
-
-  test('plugins installed all failed', done => {
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/bar/static/test.js',
-    ];
-
-    const alertStub = sandbox.stub();
-    document.addEventListener('show-alert', alertStub);
-
-    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
-      pluginApi.install(() => {
-        throw new Error('failed');
-      }, undefined, url);
-    });
-
-    const pluginsLoadedStub = sandbox.stub();
-    stub('gr-reporting', {
-      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-    });
-
-    pluginLoader.loadPlugins(plugins);
-
-    flush(() => {
-      assert.isTrue(pluginsLoadedStub.calledWithExactly([]));
-      assert.isTrue(pluginLoader.arePluginsLoaded());
-      assert.isTrue(alertStub.calledTwice);
-      done();
-    });
-  });
-
-  test('plugins installed failed becasue of wrong version', done => {
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/bar/static/test.js',
-    ];
-
-    const alertStub = sandbox.stub();
-    document.addEventListener('show-alert', alertStub);
-
-    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
-      pluginApi.install(() => {
-      }, url === plugins[0] ? '' : 'alpha', url);
-    });
-
-    const pluginsLoadedStub = sandbox.stub();
-    stub('gr-reporting', {
-      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-    });
-
-    pluginLoader.loadPlugins(plugins);
-
-    flush(() => {
-      assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo']));
-      assert.isTrue(pluginLoader.arePluginsLoaded());
-      assert.isTrue(alertStub.calledOnce);
-      done();
-    });
-  });
-
-  test('multiple assets for same plugin installed successfully', done => {
-    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
-      pluginApi.install(() => void 0, undefined, url);
-    });
-    const pluginsLoadedStub = sandbox.stub();
-    stub('gr-reporting', {
-      pluginsLoaded: (...args) => pluginsLoadedStub(...args),
-    });
-
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/foo/static/test2.js',
-      'http://test.com/plugins/bar/static/test.js',
-    ];
-    pluginLoader.loadPlugins(plugins);
-
-    flush(() => {
-      assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
-      assert.isTrue(pluginLoader.arePluginsLoaded());
-      done();
-    });
-  });
-
-  suite('plugin path and url', () => {
-    let importHtmlPluginStub;
-    let loadJsPluginStub;
-    setup(() => {
-      importHtmlPluginStub = sandbox.stub();
-      sandbox.stub(pluginLoader, '_loadHtmlPlugin', url => {
-        importHtmlPluginStub(url);
-      });
-      loadJsPluginStub = sandbox.stub();
-      sandbox.stub(pluginLoader, '_createScriptTag', url => {
-        loadJsPluginStub(url);
-      });
-    });
-
-    test('invalid plugin path', () => {
-      const failToLoadStub = sandbox.stub();
-      sandbox.stub(pluginLoader, '_failToLoad', (...args) => {
-        failToLoadStub(...args);
-      });
-
-      pluginLoader.loadPlugins([
-        'foo/bar',
-      ]);
-
-      assert.isTrue(failToLoadStub.calledOnce);
-      assert.isTrue(failToLoadStub.calledWithExactly(
-          'Unrecognized plugin path foo/bar',
-          'foo/bar'
-      ));
-    });
-
-    test('relative path for plugins', () => {
-      pluginLoader.loadPlugins([
-        'foo/bar.js',
-        'foo/bar.html',
-      ]);
-
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(`${url}/foo/bar.html`)
-      );
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`${url}/foo/bar.js`)
-      );
-    });
-
-    test('relative path should honor getBaseUrl', () => {
-      const testUrl = '/test';
-      sandbox.stub(BaseUrlBehavior, 'getBaseUrl', () => testUrl);
-
-      pluginLoader.loadPlugins([
-        'foo/bar.js',
-        'foo/bar.html',
-      ]);
-
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(
-              `${url}${testUrl}/foo/bar.html`
-          )
-      );
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`${url}${testUrl}/foo/bar.js`)
-      );
-    });
-
-    test('absolute path for plugins', () => {
-      pluginLoader.loadPlugins([
-        'http://e.com/foo/bar.js',
-        'http://e.com/foo/bar.html',
-      ]);
-
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
-      );
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`)
-      );
-    });
-  });
-
-  suite('With ASSETS_PATH', () => {
-    let importHtmlPluginStub;
-    let loadJsPluginStub;
-    setup(() => {
-      window.ASSETS_PATH = 'https://cdn.com';
-      importHtmlPluginStub = sandbox.stub();
-      sandbox.stub(pluginLoader, '_loadHtmlPlugin', url => {
-        importHtmlPluginStub(url);
-      });
-      loadJsPluginStub = sandbox.stub();
-      sandbox.stub(pluginLoader, '_createScriptTag', url => {
-        loadJsPluginStub(url);
-      });
-    });
-
-    teardown(() => {
-      window.ASSETS_PATH = '';
-    });
-
-    test('Should try load plugins from assets path instead', () => {
-      pluginLoader.loadPlugins([
-        'foo/bar.js',
-        'foo/bar.html',
-      ]);
-
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
-      );
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
-    });
-
-    test('Should honor original path if exists', () => {
-      pluginLoader.loadPlugins([
-        'http://e.com/foo/bar.html',
-        'http://e.com/foo/bar.js',
-      ]);
-
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
-      );
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`));
-    });
-
-    test('Should try replace current host with assetsPath', () => {
-      const host = window.location.origin;
-      pluginLoader.loadPlugins([
-        `${host}/foo/bar.html`,
-        `${host}/foo/bar.js`,
-      ]);
-
-      assert.isTrue(importHtmlPluginStub.calledOnce);
-      assert.isTrue(
-          importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
-      );
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
-    });
-  });
-
-  test('adds js plugins will call the body', () => {
-    pluginLoader.loadPlugins([
-      'http://e.com/foo/bar.js',
-      'http://e.com/bar/foo.js',
-    ]);
-    assert.isTrue(document.body.appendChild.calledTwice);
-  });
-
-  test('can call awaitPluginsLoaded multiple times', done => {
-    const plugins = [
-      'http://e.com/foo/bar.js',
-      'http://e.com/bar/foo.js',
-    ];
-
-    let installed = false;
-    function pluginCallback(url) {
-      if (url === plugins[1]) {
-        installed = true;
-      }
-    }
-    sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
-      pluginApi.install(() => pluginCallback(url), undefined, url);
-    });
-
-    pluginLoader.loadPlugins(plugins);
-
-    pluginLoader.awaitPluginsLoaded().then(() => {
-      assert.isTrue(installed);
-
-      pluginLoader.awaitPluginsLoaded().then(() => {
-        done();
-      });
-    });
-  });
-
-  suite('preloaded plugins', () => {
-    test('skips preloaded plugins when load plugins', () => {
-      const importHtmlPluginStub = sandbox.stub();
-      sandbox.stub(pluginLoader, '_importHtmlPlugin', url => {
-        importHtmlPluginStub(url);
-      });
-      const loadJsPluginStub = sandbox.stub();
-      sandbox.stub(pluginLoader, '_loadJsPlugin', url => {
-        loadJsPluginStub(url);
-      });
-
-      window.Gerrit._preloadedPlugins = {
-        foo: () => void 0,
-        bar: () => void 0,
-      };
-
-      pluginLoader.loadPlugins([
-        'http://e.com/plugins/foo.js',
-        'plugins/bar.html',
-        'http://e.com/plugins/test/foo.js',
-      ]);
-
-      assert.isTrue(importHtmlPluginStub.notCalled);
-      assert.isTrue(loadJsPluginStub.calledOnce);
-    });
-
-    test('isPluginPreloaded', () => {
-      window.Gerrit._preloadedPlugins = {baz: ()=>{}};
-      assert.isFalse(pluginLoader.isPluginPreloaded('plugins/foo/bar'));
-      assert.isFalse(pluginLoader.isPluginPreloaded('http://a.com/42'));
-      assert.isTrue(
-          pluginLoader.isPluginPreloaded(PRELOADED_PROTOCOL + 'baz')
-      );
-      window.Gerrit._preloadedPlugins = null;
-    });
-
-    test('preloaded plugins are installed', () => {
-      const installStub = sandbox.stub();
-      window.Gerrit._preloadedPlugins = {foo: installStub};
-      pluginLoader.installPreloadedPlugins();
-      assert.isTrue(installStub.called);
-      const pluginApi = installStub.lastCall.args[0];
-      assert.strictEqual(pluginApi.getPluginName(), 'foo');
-    });
-
-    test('installing preloaded plugin', () => {
-      let plugin;
-      pluginApi.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
-      assert.strictEqual(plugin.getPluginName(), 'foo');
-      assert.strictEqual(plugin.url('/some/thing.html'),
-          `${window.location.origin}/plugins/foo/some/thing.html`);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
new file mode 100644
index 0000000..1e6938e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
@@ -0,0 +1,543 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-js-api-interface.js';
+import {PRELOADED_PROTOCOL, PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
+import {_testOnly_resetPluginLoader} from './gr-plugin-loader.js';
+import {resetPlugins, stubBaseUrl} from '../../../test/test-utils.js';
+import {_testOnly_flushPreinstalls} from './gr-gerrit.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const basicFixture = fixtureFromElement('gr-js-api-interface');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-plugin-loader tests', () => {
+  let plugin;
+
+  let url;
+  let sendStub;
+  let pluginLoader;
+
+  setup(() => {
+    window.clock = sinon.useFakeTimers();
+
+    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
+    stub('gr-rest-api-interface', {
+      getAccount() {
+        return Promise.resolve({name: 'Judy Hopps'});
+      },
+      send(...args) {
+        return sendStub(...args);
+      },
+    });
+    pluginLoader = _testOnly_resetPluginLoader();
+    sinon.stub(document.body, 'appendChild');
+    basicFixture.instantiate();
+    url = window.location.origin;
+  });
+
+  teardown(() => {
+    window.clock.restore();
+    resetPlugins();
+  });
+
+  test('reuse plugin for install calls', () => {
+    pluginApi.install(p => { plugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+
+    let otherPlugin;
+    pluginApi.install(p => { otherPlugin = p; }, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    assert.strictEqual(plugin, otherPlugin);
+  });
+
+  test('flushes preinstalls if provided', () => {
+    assert.doesNotThrow(() => {
+      _testOnly_flushPreinstalls();
+    });
+    window.Gerrit.flushPreinstalls = sinon.stub();
+    _testOnly_flushPreinstalls();
+    assert.isTrue(window.Gerrit.flushPreinstalls.calledOnce);
+    delete window.Gerrit.flushPreinstalls;
+  });
+
+  test('versioning', () => {
+    const callback = sinon.spy();
+    pluginApi.install(callback, '0.0pre-alpha');
+    assert(callback.notCalled);
+  });
+
+  test('report pluginsLoaded', done => {
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+    pluginsLoadedStub.reset();
+    window.Gerrit._loadPlugins([]);
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.called);
+      done();
+    });
+  });
+
+  test('arePluginsLoaded', done => {
+    assert.isFalse(pluginLoader.arePluginsLoaded());
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    pluginLoader.loadPlugins(plugins);
+    assert.isFalse(pluginLoader.arePluginsLoaded());
+    // Timeout on loading plugins
+    window.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
+    flush(() => {
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      done();
+    });
+  });
+
+  test('plugins installed successfully', done => {
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => void 0, undefined, url);
+    });
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+    pluginLoader.loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      done();
+    });
+  });
+
+  test('isPluginEnabled and isPluginLoaded', done => {
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => void 0, undefined, url);
+    });
+
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+      'bar/static/test.js',
+    ];
+    pluginLoader.loadPlugins(plugins);
+    assert.isTrue(
+        plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
+    );
+
+    flush(() => {
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      assert.isTrue(
+          plugins.every(plugin => pluginLoader.isPluginLoaded(plugin))
+      );
+
+      done();
+    });
+  });
+
+  test('plugins installed mixed result, 1 fail 1 succeed', done => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sinon.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => {
+        if (url === plugins[0]) {
+          throw new Error('failed');
+        }
+      }, undefined, url);
+    });
+
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+
+    pluginLoader.loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      assert.isTrue(alertStub.calledOnce);
+      done();
+    });
+  });
+
+  test('isPluginEnabled and isPluginLoaded for mixed results', done => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sinon.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => {
+        if (url === plugins[0]) {
+          throw new Error('failed');
+        }
+      }, undefined, url);
+    });
+
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+
+    pluginLoader.loadPlugins(plugins);
+    assert.isTrue(
+        plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
+    );
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      assert.isTrue(alertStub.calledOnce);
+      assert.isTrue(pluginLoader.isPluginLoaded(plugins[1]));
+      assert.isFalse(pluginLoader.isPluginLoaded(plugins[0]));
+      done();
+    });
+  });
+
+  test('plugins installed all failed', done => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sinon.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => {
+        throw new Error('failed');
+      }, undefined, url);
+    });
+
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+
+    pluginLoader.loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly([]));
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      assert.isTrue(alertStub.calledTwice);
+      done();
+    });
+  });
+
+  test('plugins installed failed becasue of wrong version', done => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sinon.stub();
+    document.addEventListener('show-alert', alertStub);
+
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => {
+      }, url === plugins[0] ? '' : 'alpha', url);
+    });
+
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+
+    pluginLoader.loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo']));
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      assert.isTrue(alertStub.calledOnce);
+      done();
+    });
+  });
+
+  test('multiple assets for same plugin installed successfully', done => {
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => void 0, undefined, url);
+    });
+    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
+        'pluginsLoaded');
+
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/foo/static/test2.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+    pluginLoader.loadPlugins(plugins);
+
+    flush(() => {
+      assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+      assert.isTrue(pluginLoader.arePluginsLoaded());
+      done();
+    });
+  });
+
+  suite('plugin path and url', () => {
+    let importHtmlPluginStub;
+    let loadJsPluginStub;
+    setup(() => {
+      importHtmlPluginStub = sinon.stub();
+      sinon.stub(pluginLoader, '_loadHtmlPlugin').callsFake( url => {
+        importHtmlPluginStub(url);
+      });
+      loadJsPluginStub = sinon.stub();
+      sinon.stub(pluginLoader, '_createScriptTag').callsFake( url => {
+        loadJsPluginStub(url);
+      });
+    });
+
+    test('invalid plugin path', () => {
+      const failToLoadStub = sinon.stub();
+      sinon.stub(pluginLoader, '_failToLoad').callsFake((...args) => {
+        failToLoadStub(...args);
+      });
+
+      pluginLoader.loadPlugins([
+        'foo/bar',
+      ]);
+
+      assert.isTrue(failToLoadStub.calledOnce);
+      assert.isTrue(failToLoadStub.calledWithExactly(
+          'Unrecognized plugin path foo/bar',
+          'foo/bar'
+      ));
+    });
+
+    test('relative path for plugins', () => {
+      pluginLoader.loadPlugins([
+        'foo/bar.js',
+        'foo/bar.html',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`${url}/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`${url}/foo/bar.js`)
+      );
+    });
+
+    test('relative path should honor getBaseUrl', () => {
+      const testUrl = '/test';
+      stubBaseUrl(testUrl);
+
+      pluginLoader.loadPlugins([
+        'foo/bar.js',
+        'foo/bar.html',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(
+              `${url}${testUrl}/foo/bar.html`
+          )
+      );
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`${url}${testUrl}/foo/bar.js`)
+      );
+    });
+
+    test('absolute path for plugins', () => {
+      pluginLoader.loadPlugins([
+        'http://e.com/foo/bar.js',
+        'http://e.com/foo/bar.html',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`)
+      );
+    });
+  });
+
+  suite('With ASSETS_PATH', () => {
+    let importHtmlPluginStub;
+    let loadJsPluginStub;
+    setup(() => {
+      window.ASSETS_PATH = 'https://cdn.com';
+      importHtmlPluginStub = sinon.stub();
+      sinon.stub(pluginLoader, '_loadHtmlPlugin').callsFake( url => {
+        importHtmlPluginStub(url);
+      });
+      loadJsPluginStub = sinon.stub();
+      sinon.stub(pluginLoader, '_createScriptTag').callsFake( url => {
+        loadJsPluginStub(url);
+      });
+    });
+
+    teardown(() => {
+      window.ASSETS_PATH = '';
+    });
+
+    test('Should try load plugins from assets path instead', () => {
+      pluginLoader.loadPlugins([
+        'foo/bar.js',
+        'foo/bar.html',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
+    });
+
+    test('Should honor original path if exists', () => {
+      pluginLoader.loadPlugins([
+        'http://e.com/foo/bar.html',
+        'http://e.com/foo/bar.js',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`http://e.com/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`));
+    });
+
+    test('Should try replace current host with assetsPath', () => {
+      const host = window.location.origin;
+      pluginLoader.loadPlugins([
+        `${host}/foo/bar.html`,
+        `${host}/foo/bar.js`,
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.calledOnce);
+      assert.isTrue(
+          importHtmlPluginStub.calledWithExactly(`https://cdn.com/foo/bar.html`)
+      );
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
+    });
+  });
+
+  test('adds js plugins will call the body', () => {
+    pluginLoader.loadPlugins([
+      'http://e.com/foo/bar.js',
+      'http://e.com/bar/foo.js',
+    ]);
+    assert.isTrue(document.body.appendChild.calledTwice);
+  });
+
+  test('can call awaitPluginsLoaded multiple times', done => {
+    const plugins = [
+      'http://e.com/foo/bar.js',
+      'http://e.com/bar/foo.js',
+    ];
+
+    let installed = false;
+    function pluginCallback(url) {
+      if (url === plugins[1]) {
+        installed = true;
+      }
+    }
+    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+      pluginApi.install(() => pluginCallback(url), undefined, url);
+    });
+
+    pluginLoader.loadPlugins(plugins);
+
+    pluginLoader.awaitPluginsLoaded().then(() => {
+      assert.isTrue(installed);
+
+      pluginLoader.awaitPluginsLoaded().then(() => {
+        done();
+      });
+    });
+  });
+
+  suite('preloaded plugins', () => {
+    teardown(() => {
+      window.Gerrit._preloadedPlugins = null;
+    });
+    test('skips preloaded plugins when load plugins', () => {
+      const importHtmlPluginStub = sinon.stub();
+      sinon.stub(pluginLoader, '_importHtmlPlugin').callsFake( url => {
+        importHtmlPluginStub(url);
+      });
+      const loadJsPluginStub = sinon.stub();
+      sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
+        loadJsPluginStub(url);
+      });
+
+      window.Gerrit._preloadedPlugins = {
+        foo: () => void 0,
+        bar: () => void 0,
+      };
+
+      pluginLoader.loadPlugins([
+        'http://e.com/plugins/foo.js',
+        'plugins/bar.html',
+        'http://e.com/plugins/test/foo.js',
+      ]);
+
+      assert.isTrue(importHtmlPluginStub.notCalled);
+      assert.isTrue(loadJsPluginStub.calledOnce);
+    });
+
+    test('isPluginPreloaded', () => {
+      window.Gerrit._preloadedPlugins = {baz: ()=>{}};
+      assert.isFalse(pluginLoader.isPluginPreloaded('plugins/foo/bar'));
+      assert.isFalse(pluginLoader.isPluginPreloaded('http://a.com/42'));
+      assert.isTrue(
+          pluginLoader.isPluginPreloaded(PRELOADED_PROTOCOL + 'baz')
+      );
+    });
+
+    test('preloaded plugins are installed', () => {
+      const installStub = sinon.stub();
+      window.Gerrit._preloadedPlugins = {foo: installStub};
+      pluginLoader.installPreloadedPlugins();
+      assert.isTrue(installStub.called);
+      const pluginApi = installStub.lastCall.args[0];
+      assert.strictEqual(pluginApi.getPluginName(), 'foo');
+    });
+
+    test('installing preloaded plugin', () => {
+      let plugin;
+      pluginApi.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
+      assert.strictEqual(plugin.getPluginName(), 'foo');
+      assert.strictEqual(plugin.url('/some/thing.html'),
+          `${window.location.origin}/plugins/foo/some/thing.html`);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
index d84cd834..31ff8ee 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
@@ -17,6 +17,10 @@
 
 let restApi;
 
+export function _testOnlyResetRestApi() {
+  restApi = null;
+}
+
 function getRestApi() {
   if (!restApi) {
     restApi = document.createElement('gr-rest-api-interface');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
deleted file mode 100644
index fcc3b669..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
+++ /dev/null
@@ -1,160 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-plugin-rest-api</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-js-api-interface.js';
-import {GrPluginRestApi} from './gr-plugin-rest-api.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-plugin-rest-api tests', () => {
-  let instance;
-  let sandbox;
-  let getResponseObjectStub;
-  let sendStub;
-  let restApiStub;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
-    sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
-    restApiStub = {
-      getAccount: () => Promise.resolve({name: 'Judy Hopps'}),
-      getResponseObject: getResponseObjectStub,
-      send: sendStub,
-      getLoggedIn: sandbox.stub(),
-      getVersion: sandbox.stub(),
-      getConfig: sandbox.stub(),
-    };
-    stub('gr-rest-api-interface', Object.keys(restApiStub).reduce((a, k) => {
-      a[k] = (...args) => restApiStub[k](...args);
-      return a;
-    }, {}));
-    pluginApi.install(p => {}, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    instance = new GrPluginRestApi();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('fetch', () => {
-    const payload = {foo: 'foo'};
-    return instance.fetch('HTTP_METHOD', '/url', payload).then(r => {
-      assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
-      assert.equal(r.status, 200);
-      assert.isFalse(getResponseObjectStub.called);
-    });
-  });
-
-  test('send', () => {
-    const payload = {foo: 'foo'};
-    const response = {bar: 'bar'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return instance.send('HTTP_METHOD', '/url', payload).then(r => {
-      assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('get', () => {
-    const response = {foo: 'foo'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return instance.get('/url').then(r => {
-      assert.isTrue(sendStub.calledWith('GET', '/url'));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('post', () => {
-    const payload = {foo: 'foo'};
-    const response = {bar: 'bar'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return instance.post('/url', payload).then(r => {
-      assert.isTrue(sendStub.calledWith('POST', '/url', payload));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('put', () => {
-    const payload = {foo: 'foo'};
-    const response = {bar: 'bar'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return instance.put('/url', payload).then(r => {
-      assert.isTrue(sendStub.calledWith('PUT', '/url', payload));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('delete works', () => {
-    const response = {status: 204};
-    sendStub.returns(Promise.resolve(response));
-    return instance.delete('/url').then(r => {
-      assert.isTrue(sendStub.calledWith('DELETE', '/url'));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('delete fails', () => {
-    sendStub.returns(Promise.resolve(
-        {status: 400, text() { return Promise.resolve('text'); }}));
-    return instance.delete('/url').then(r => {
-      throw new Error('Should not resolve');
-    })
-        .catch(err => {
-          assert.isTrue(sendStub.calledWith('DELETE', '/url'));
-          assert.equal('text', err.message);
-        });
-  });
-
-  test('getLoggedIn', () => {
-    restApiStub.getLoggedIn.returns(Promise.resolve(true));
-    return instance.getLoggedIn().then(result => {
-      assert.isTrue(restApiStub.getLoggedIn.calledOnce);
-      assert.isTrue(result);
-    });
-  });
-
-  test('getVersion', () => {
-    restApiStub.getVersion.returns(Promise.resolve('foo bar'));
-    return instance.getVersion().then(result => {
-      assert.isTrue(restApiStub.getVersion.calledOnce);
-      assert.equal(result, 'foo bar');
-    });
-  });
-
-  test('getConfig', () => {
-    restApiStub.getConfig.returns(Promise.resolve('foo bar'));
-    return instance.getConfig().then(result => {
-      assert.isTrue(restApiStub.getConfig.calledOnce);
-      assert.equal(result, 'foo bar');
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
new file mode 100644
index 0000000..53aaa1e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
@@ -0,0 +1,145 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-js-api-interface.js';
+import {GrPluginRestApi} from './gr-plugin-rest-api.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-plugin-rest-api tests', () => {
+  let instance;
+
+  let getResponseObjectStub;
+  let sendStub;
+  let restApiStub;
+
+  setup(() => {
+    getResponseObjectStub = sinon.stub().returns(Promise.resolve());
+    sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
+    restApiStub = {
+      getAccount: () => Promise.resolve({name: 'Judy Hopps'}),
+      getResponseObject: getResponseObjectStub,
+      send: sendStub,
+      getLoggedIn: sinon.stub(),
+      getVersion: sinon.stub(),
+      getConfig: sinon.stub(),
+    };
+    stub('gr-rest-api-interface', Object.keys(restApiStub).reduce((a, k) => {
+      a[k] = (...args) => restApiStub[k](...args);
+      return a;
+    }, {}));
+    pluginApi.install(p => {}, '0.1',
+        'http://test.com/plugins/testplugin/static/test.js');
+    instance = new GrPluginRestApi();
+  });
+
+  test('fetch', () => {
+    const payload = {foo: 'foo'};
+    return instance.fetch('HTTP_METHOD', '/url', payload).then(r => {
+      assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
+      assert.equal(r.status, 200);
+      assert.isFalse(getResponseObjectStub.called);
+    });
+  });
+
+  test('send', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return instance.send('HTTP_METHOD', '/url', payload).then(r => {
+      assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('get', () => {
+    const response = {foo: 'foo'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return instance.get('/url').then(r => {
+      assert.isTrue(sendStub.calledWith('GET', '/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('post', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return instance.post('/url', payload).then(r => {
+      assert.isTrue(sendStub.calledWith('POST', '/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('put', () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.returns(Promise.resolve(response));
+    return instance.put('/url', payload).then(r => {
+      assert.isTrue(sendStub.calledWith('PUT', '/url', payload));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('delete works', () => {
+    const response = {status: 204};
+    sendStub.returns(Promise.resolve(response));
+    return instance.delete('/url').then(r => {
+      assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+      assert.strictEqual(r, response);
+    });
+  });
+
+  test('delete fails', () => {
+    sendStub.returns(Promise.resolve(
+        {status: 400, text() { return Promise.resolve('text'); }}));
+    return instance.delete('/url').then(r => {
+      throw new Error('Should not resolve');
+    })
+        .catch(err => {
+          assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+          assert.equal('text', err.message);
+        });
+  });
+
+  test('getLoggedIn', () => {
+    restApiStub.getLoggedIn.returns(Promise.resolve(true));
+    return instance.getLoggedIn().then(result => {
+      assert.isTrue(restApiStub.getLoggedIn.calledOnce);
+      assert.isTrue(result);
+    });
+  });
+
+  test('getVersion', () => {
+    restApiStub.getVersion.returns(Promise.resolve('foo bar'));
+    return instance.getVersion().then(result => {
+      assert.isTrue(restApiStub.getVersion.calledOnce);
+      assert.equal(result, 'foo bar');
+    });
+  });
+
+  test('getConfig', () => {
+    restApiStub.getConfig.returns(Promise.resolve('foo bar'));
+    return instance.getConfig().then(result => {
+      assert.isTrue(restApiStub.getConfig.calledOnce);
+      assert.equal(result, 'foo bar');
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index 9d79462..1924c49 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -15,7 +15,8 @@
  * limitations under the License.
  */
 
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
+import {getSharedApiEl} from '../../../utils/dom-util.js';
 import {GrAttributeHelper} from '../../plugins/gr-attribute-helper/gr-attribute-helper.js';
 import {GrChangeActionsInterface} from './gr-change-actions-js-api.js';
 import {GrChangeReplyInterface} from './gr-change-reply-js-api.js';
@@ -31,45 +32,44 @@
 import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
 import {GrStylesApi} from '../../plugins/gr-styles-api/gr-styles-api.js';
 import {GrPluginActionContext} from './gr-plugin-action-context.js';
-import {pluginEndpoints} from './gr-plugin-endpoints.js';
+import {getPluginEndpoints} from './gr-plugin-endpoints.js';
 
-import {PRELOADED_PROTOCOL, getPluginNameFromUrl, send} from './gr-api-utils.js';
-import {deprecatedDelete} from './gr-gerrit.js';
+import {
+  PRELOADED_PROTOCOL,
+  getPluginNameFromUrl,
+  send,
+} from './gr-api-utils.js';
 
-(function(window) {
-  'use strict';
+const PANEL_ENDPOINTS_MAPPING = {
+  CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK: 'change-view-integration',
+  CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK: 'change-metadata-item',
+};
 
-  const PANEL_ENDPOINTS_MAPPING = {
-    CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK: 'change-view-integration',
-    CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK: 'change-metadata-item',
-  };
+/**
+ * Plugin-provided custom components can affect content in extension
+ * points using one of following methods:
+ * - DECORATE: custom component is set with `content` attribute and may
+ *   decorate (e.g. style) DOM element.
+ * - REPLACE: contents of extension point are replaced with the custom
+ *   component.
+ * - STYLE: custom component is a shared styles module that is inserted
+ *   into the extension point.
+ */
+const EndpointType = {
+  DECORATE: 'decorate',
+  REPLACE: 'replace',
+  STYLE: 'style',
+};
 
-  /**
-   * Plugin-provided custom components can affect content in extension
-   * points using one of following methods:
-   * - DECORATE: custom component is set with `content` attribute and may
-   *   decorate (e.g. style) DOM element.
-   * - REPLACE: contents of extension point are replaced with the custom
-   *   component.
-   * - STYLE: custom component is a shared styles module that is inserted
-   *   into the extension point.
-   */
-  const EndpointType = {
-    DECORATE: 'decorate',
-    REPLACE: 'replace',
-    STYLE: 'style',
-  };
-
-  /**
-   * @constructor
-   * @param {string=} opt_url
-   */
-  function Plugin(opt_url) {
+export class Plugin {
+  constructor(opt_url) {
     this._domHooks = new GrDomHooksManager(this);
 
     if (!opt_url) {
-      console.warn('Plugin not being loaded from /plugins base path.',
-          'Unable to determine name.');
+      console.warn(
+          'Plugin not being loaded from /plugins base path.',
+          'Unable to determine name.'
+      );
       return this;
     }
     this.deprecated = {
@@ -84,29 +84,31 @@
 
     this._url = new URL(opt_url);
     this._name = getPluginNameFromUrl(this._url);
+    this.sharedApiElement = getSharedApiEl();
   }
 
-  Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
-
-  Plugin.prototype._name = '';
-
-  Plugin.prototype.getPluginName = function() {
+  getPluginName() {
     return this._name;
-  };
+  }
 
-  Plugin.prototype.registerStyleModule = function(endpoint, moduleName) {
-    pluginEndpoints.registerModule(
-        this, {endpoint, type: EndpointType.STYLE, moduleName});
-  };
+  registerStyleModule(endpoint, moduleName) {
+    getPluginEndpoints().registerModule(this, {
+      endpoint,
+      type: EndpointType.STYLE,
+      moduleName,
+    });
+  }
 
   /**
    * Registers an endpoint for the plugin.
    */
-  Plugin.prototype.registerCustomComponent = function(
-      endpointName, opt_moduleName, opt_options) {
-    return this._registerCustomComponent(endpointName, opt_moduleName,
-        opt_options);
-  };
+  registerCustomComponent(endpointName, opt_moduleName, opt_options) {
+    return this._registerCustomComponent(
+        endpointName,
+        opt_moduleName,
+        opt_options
+    );
+  }
 
   /**
    * Registers a dynamic endpoint for the plugin.
@@ -114,125 +116,151 @@
    * Dynamic plugins are registered by specific prefix, such as
    * 'change-list-header'.
    */
-  Plugin.prototype.registerDynamicCustomComponent = function(
-      endpointName, opt_moduleName, opt_options) {
+  registerDynamicCustomComponent(endpointName, opt_moduleName, opt_options) {
     const fullEndpointName = `${endpointName}-${this.getPluginName()}`;
-    return this._registerCustomComponent(fullEndpointName, opt_moduleName,
-        opt_options, endpointName);
-  };
+    return this._registerCustomComponent(
+        fullEndpointName,
+        opt_moduleName,
+        opt_options,
+        endpointName
+    );
+  }
 
-  Plugin.prototype._registerCustomComponent = function(
-      endpoint, opt_moduleName, opt_options, dynamicEndpoint) {
-    const type = opt_options && opt_options.replace ?
-      EndpointType.REPLACE : EndpointType.DECORATE;
-    const slot = opt_options && opt_options.slot || '';
+  _registerCustomComponent(
+      endpoint,
+      opt_moduleName,
+      opt_options,
+      dynamicEndpoint
+  ) {
+    const type =
+      opt_options && opt_options.replace
+        ? EndpointType.REPLACE
+        : EndpointType.DECORATE;
+    const slot = (opt_options && opt_options.slot) || '';
     const domHook = this._domHooks.getDomHook(endpoint, opt_moduleName);
     const moduleName = opt_moduleName || domHook.getModuleName();
-    pluginEndpoints.registerModule(
-        this, {slot, endpoint, type, moduleName, domHook, dynamicEndpoint});
+    getPluginEndpoints().registerModule(this, {
+      slot,
+      endpoint,
+      type,
+      moduleName,
+      domHook,
+      dynamicEndpoint,
+    });
     return domHook.getPublicAPI();
-  };
+  }
 
   /**
    * Returns instance of DOM hook API for endpoint. Creates a placeholder
    * element for the first call.
    */
-  Plugin.prototype.hook = function(endpointName, opt_options) {
+  hook(endpointName, opt_options) {
     return this.registerCustomComponent(endpointName, undefined, opt_options);
-  };
+  }
 
-  Plugin.prototype.getServerInfo = function() {
+  getServerInfo() {
     return document.createElement('gr-rest-api-interface').getConfig();
-  };
+  }
 
-  Plugin.prototype.on = function(eventName, callback) {
-    Plugin._sharedAPIElement.addEventCallback(eventName, callback);
-  };
+  on(eventName, callback) {
+    this.sharedApiElement.addEventCallback(eventName, callback);
+  }
 
-  Plugin.prototype.url = function(opt_path) {
+  url(opt_path) {
     const relPath = '/plugins/' + this._name + (opt_path || '/');
-    const sameOriginPath = window.location.origin +
-      `${BaseUrlBehavior.getBaseUrl()}${relPath}`;
+    const sameOriginPath = window.location.origin + `${getBaseUrl()}${relPath}`;
     if (window.location.origin === this._url.origin) {
       // Plugin loaded from the same origin as gr-app, getBaseUrl in effect.
       return sameOriginPath;
     } else if (this._url.protocol === PRELOADED_PROTOCOL) {
       // Plugin is preloaded, load plugin with ASSETS_PATH or location.origin
-      return window.ASSETS_PATH ? `${window.ASSETS_PATH}${relPath}` :
-        sameOriginPath;
+      return window.ASSETS_PATH
+        ? `${window.ASSETS_PATH}${relPath}`
+        : sameOriginPath;
     } else {
       // Plugin loaded from assets bundle, expect assets placed along with it.
       return this._url.href.split('/plugins/' + this._name)[0] + relPath;
     }
-  };
+  }
 
-  Plugin.prototype.screenUrl = function(opt_screenName) {
+  screenUrl(opt_screenName) {
     const origin = location.origin;
-    const base = BaseUrlBehavior.getBaseUrl();
+    const base = getBaseUrl();
     const tokenPart = opt_screenName ? '/' + opt_screenName : '';
     return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`;
-  };
+  }
 
-  Plugin.prototype._send = function(method, url, opt_callback, opt_payload) {
+  _send(method, url, opt_callback, opt_payload) {
     return send(method, this.url(url), opt_callback, opt_payload);
-  };
+  }
 
-  Plugin.prototype.get = function(url, opt_callback) {
+  get(url, opt_callback) {
     console.warn('.get() is deprecated! Use .restApi().get()');
     return this._send('GET', url, opt_callback);
-  };
+  }
 
-  Plugin.prototype.post = function(url, payload, opt_callback) {
+  post(url, payload, opt_callback) {
     console.warn('.post() is deprecated! Use .restApi().post()');
     return this._send('POST', url, opt_callback, payload);
-  };
+  }
 
-  Plugin.prototype.put = function(url, payload, opt_callback) {
+  put(url, payload, opt_callback) {
     console.warn('.put() is deprecated! Use .restApi().put()');
     return this._send('PUT', url, opt_callback, payload);
-  };
+  }
 
-  Plugin.prototype.delete = function(url, opt_callback) {
-    return deprecatedDelete(this.url(url), opt_callback);
-  };
+  delete(url, opt_callback) {
+    console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
+    return this.restApi()
+        .delete(this.url(url))
+        .then(res => {
+          if (opt_callback) {
+            opt_callback(res);
+          }
+          return res;
+        });
+  }
 
-  Plugin.prototype.annotationApi = function() {
+  annotationApi() {
     return new GrAnnotationActionsInterface(this);
-  };
+  }
 
-  Plugin.prototype.changeActions = function() {
-    return new GrChangeActionsInterface(this,
-        Plugin._sharedAPIElement.getElement(
-            Plugin._sharedAPIElement.Element.CHANGE_ACTIONS));
-  };
+  changeActions() {
+    return new GrChangeActionsInterface(
+        this,
+        this.sharedApiElement.getElement(
+            this.sharedApiElement.Element.CHANGE_ACTIONS
+        )
+    );
+  }
 
-  Plugin.prototype.changeReply = function() {
-    return new GrChangeReplyInterface(this);
-  };
+  changeReply() {
+    return new GrChangeReplyInterface(this, this.sharedApiElement);
+  }
 
-  Plugin.prototype.theme = function() {
+  theme() {
     return new GrThemeApi(this);
-  };
+  }
 
-  Plugin.prototype.project = function() {
+  project() {
     return new GrRepoApi(this);
-  };
+  }
 
-  Plugin.prototype.changeMetadata = function() {
+  changeMetadata() {
     return new GrChangeMetadataApi(this);
-  };
+  }
 
-  Plugin.prototype.admin = function() {
+  admin() {
     return new GrAdminApi(this);
-  };
+  }
 
-  Plugin.prototype.settings = function() {
+  settings() {
     return new GrSettingsApi(this);
-  };
+  }
 
-  Plugin.prototype.styles = function() {
+  styles() {
     return new GrStylesApi();
-  };
+  }
 
   /**
    * To make REST requests for plugin-provided endpoints, use
@@ -242,158 +270,172 @@
    *
    * @param {string=} opt_prefix url for subsequent .get(), .post() etc requests.
    */
-  Plugin.prototype.restApi = function(opt_prefix) {
+  restApi(opt_prefix) {
     return new GrPluginRestApi(opt_prefix);
-  };
+  }
 
-  Plugin.prototype.attributeHelper = function(element) {
+  attributeHelper(element) {
     return new GrAttributeHelper(element);
-  };
+  }
 
-  Plugin.prototype.eventHelper = function(element) {
+  eventHelper(element) {
     return new GrEventHelper(element);
-  };
+  }
 
-  Plugin.prototype.popup = function(moduleName) {
+  popup(moduleName) {
     if (typeof moduleName !== 'string') {
       console.error('.popup(element) deprecated, use .popup(moduleName)!');
       return;
     }
     const api = new GrPopupInterface(this, moduleName);
     return api.open();
-  };
+  }
 
-  Plugin.prototype.panel = function() {
-    console.error('.panel() is deprecated! ' +
-        'Use registerCustomComponent() instead.');
-  };
+  panel() {
+    console.error(
+        '.panel() is deprecated! ' + 'Use registerCustomComponent() instead.'
+    );
+  }
 
-  Plugin.prototype.settingsScreen = function() {
-    console.error('.settingsScreen() is deprecated! ' +
-        'Use .settings() instead.');
-  };
+  settingsScreen() {
+    console.error(
+        '.settingsScreen() is deprecated! ' + 'Use .settings() instead.'
+    );
+  }
 
-  Plugin.prototype.screen = function(screenName, opt_moduleName) {
+  screen(screenName, opt_moduleName) {
     if (opt_moduleName && typeof opt_moduleName !== 'string') {
-      console.error('.screen(pattern, callback) deprecated, use ' +
-          '.screen(screenName, opt_moduleName)!');
+      console.error(
+          '.screen(pattern, callback) deprecated, use ' +
+          '.screen(screenName, opt_moduleName)!'
+      );
       return;
     }
     return this.registerCustomComponent(
         this._getScreenName(screenName),
-        opt_moduleName);
-  };
+        opt_moduleName
+    );
+  }
 
-  Plugin.prototype._getScreenName = function(screenName) {
+  _getScreenName(screenName) {
     return `${this.getPluginName()}-screen-${screenName}`;
-  };
+  }
+}
 
-  const deprecatedAPI = {
-    _loadedGwt: ()=> {},
+// TODO: should be removed soon after all core plugins moved away from it.
+const deprecatedAPI = {
+  _loadedGwt: () => {},
 
-    install() {
-      console.log('Installing deprecated APIs is deprecated!');
-      for (const method in this.deprecated) {
-        if (method === 'install') continue;
-        this[method] = this.deprecated[method];
-      }
-    },
+  install() {
+    console.info('Installing deprecated APIs is deprecated!');
+    for (const method in this.deprecated) {
+      if (method === 'install') continue;
+      this[method] = this.deprecated[method];
+    }
+  },
 
-    popup(el) {
-      console.warn('plugin.deprecated.popup() is deprecated, ' +
-          'use plugin.popup() insted!');
-      if (!el) {
-        throw new Error('Popup contents not found');
-      }
-      const api = new GrPopupInterface(this);
-      api.open().then(api => api._getElement().appendChild(el));
-      return api;
-    },
+  popup(el) {
+    console.warn(
+        'plugin.deprecated.popup() is deprecated, '
+        + 'use plugin.popup() insted!'
+    );
+    if (!el) {
+      throw new Error('Popup contents not found');
+    }
+    const api = new GrPopupInterface(this);
+    api.open().then(api => api._getElement().appendChild(el));
+    return api;
+  },
 
-    onAction(type, action, callback) {
-      console.warn('plugin.deprecated.onAction() is deprecated,' +
-          ' use plugin.changeActions() instead!');
-      if (type !== 'change' && type !== 'revision') {
-        console.warn(`${type} actions are not supported.`);
+  onAction(type, action, callback) {
+    console.warn(
+        'plugin.deprecated.onAction() is deprecated,' +
+        ' use plugin.changeActions() instead!'
+    );
+    if (type !== 'change' && type !== 'revision') {
+      console.warn(`${type} actions are not supported.`);
+      return;
+    }
+    this.on('showchange', (change, revision) => {
+      const details = this.changeActions().getActionDetails(action);
+      if (!details) {
+        console.warn(
+            `${this.getPluginName()} onAction error: ${action} not found!`
+        );
         return;
       }
-      this.on('showchange', (change, revision) => {
-        const details = this.changeActions().getActionDetails(action);
-        if (!details) {
-          console.warn(
-              `${this.getPluginName()} onAction error: ${action} not found!`);
-          return;
-        }
-        this.changeActions().addTapListener(details.__key, () => {
-          callback(new GrPluginActionContext(this, details, change, revision));
-        });
+      this.changeActions().addTapListener(details.__key, () => {
+        callback(new GrPluginActionContext(this, details, change, revision));
       });
-    },
+    });
+  },
 
-    screen(pattern, callback) {
-      console.warn('plugin.deprecated.screen is deprecated,' +
-          ' use plugin.screen instead!');
-      if (pattern instanceof RegExp) {
-        console.error('deprecated.screen() does not support RegExp. ' +
-            'Please use strings for patterns.');
-        return;
-      }
-      this.hook(this._getScreenName(pattern))
-          .onAttached(el => {
-            el.style.display = 'none';
-            callback({
-              body: el,
-              token: el.token,
-              onUnload: () => {},
-              setTitle: () => {},
-              setWindowTitle: () => {},
-              show: () => {
-                el.style.display = 'initial';
-              },
-            });
-          });
-    },
-
-    settingsScreen(path, menu, callback) {
-      console.warn('.settingsScreen() is deprecated! Use .settings() instead.');
-      const hook = this.settings()
-          .title(menu)
-          .token(path)
-          .module('div')
-          .build();
-      hook.onAttached(el => {
-        el.style.display = 'none';
-        const body = el.querySelector('div');
-        callback({
-          body,
-          onUnload: () => {},
-          setTitle: () => {},
-          setWindowTitle: () => {},
-          show: () => {
-            el.style.display = 'initial';
-          },
-        });
+  screen(pattern, callback) {
+    console.warn(
+        'plugin.deprecated.screen is deprecated,'
+        + ' use plugin.screen instead!'
+    );
+    if (pattern instanceof RegExp) {
+      console.error(
+          'deprecated.screen() does not support RegExp. ' +
+          'Please use strings for patterns.'
+      );
+      return;
+    }
+    this.hook(this._getScreenName(pattern)).onAttached(el => {
+      el.style.display = 'none';
+      callback({
+        body: el,
+        token: el.token,
+        onUnload: () => {},
+        setTitle: () => {},
+        setWindowTitle: () => {},
+        show: () => {
+          el.style.display = 'initial';
+        },
       });
-    },
+    });
+  },
 
-    panel(extensionpoint, callback) {
-      console.warn('.panel() is deprecated! ' +
-          'Use registerCustomComponent() instead.');
-      const endpoint = PANEL_ENDPOINTS_MAPPING[extensionpoint];
-      if (!endpoint) {
-        console.warn(`.panel ${extensionpoint} not supported!`);
-        return;
-      }
-      this.hook(endpoint).onAttached(el => callback({
+  settingsScreen(path, menu, callback) {
+    console.warn('.settingsScreen() is deprecated! Use .settings() instead.');
+    const hook = this.settings().title(menu)
+        .token(path)
+        .module('div')
+        .build();
+    hook.onAttached(el => {
+      el.style.display = 'none';
+      const body = el.querySelector('div');
+      callback({
+        body,
+        onUnload: () => {},
+        setTitle: () => {},
+        setWindowTitle: () => {},
+        show: () => {
+          el.style.display = 'initial';
+        },
+      });
+    });
+  },
+
+  panel(extensionpoint, callback) {
+    console.warn(
+        '.panel() is deprecated! ' + 'Use registerCustomComponent() instead.'
+    );
+    const endpoint = PANEL_ENDPOINTS_MAPPING[extensionpoint];
+    if (!endpoint) {
+      console.warn(`.panel ${extensionpoint} not supported!`);
+      return;
+    }
+    this.hook(endpoint).onAttached(el =>
+      callback({
         body: el,
         p: {
           CHANGE_INFO: el.change,
           REVISION_INFO: el.revision,
         },
         onUnload: () => {},
-      }));
-    },
-  };
-
-  window.Plugin = Plugin;
-})(window);
+      })
+    );
+  },
+};
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
index 22bdce1..a6020cf 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
@@ -14,12 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/gr-voting-styles.js';
 import '../../../styles/shared-styles.js';
 import '../gr-account-label/gr-account-label.js';
-import '../gr-account-chip/gr-account-chip.js';
+import '../gr-account-link/gr-account-link.js';
 import '../gr-button/gr-button.js';
 import '../gr-icons/gr-icons.js';
 import '../gr-label/gr-label.js';
@@ -31,7 +29,7 @@
 import {htmlTemplate} from './gr-label-info_html.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrLabelInfo extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -131,7 +129,7 @@
 
   /**
    * Closure annotation for Polymer.prototype.splice is off.
-   * For now, supressing annotations.
+   * For now, suppressing annotations.
    *
    * @suppress {checkTypes} */
   _onDeleteVote(e) {
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.js
deleted file mode 100644
index 2a86669..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.js
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="gr-voting-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    .placeholder {
-      color: var(--deemphasized-text-color);
-      padding-top: var(--spacing-xs);
-    }
-    .hidden {
-      display: none;
-    }
-    .voteChip {
-      display: flex;
-      justify-content: center;
-      margin-right: var(--spacing-s);
-      padding: 0;
-      @apply --vote-chip-styles;
-      border-width: 0;
-    }
-    .max {
-      background-color: var(--vote-color-approved);
-    }
-    .min {
-      background-color: var(--vote-color-rejected);
-    }
-    .positive {
-      background-color: var(--vote-color-recommended);
-    }
-    .negative {
-      background-color: var(--vote-color-disliked);
-    }
-    .hidden {
-      display: none;
-    }
-    td {
-      vertical-align: top;
-    }
-    tr {
-      min-height: var(--line-height-normal);
-    }
-    gr-button {
-      vertical-align: top;
-      --gr-button: {
-        height: var(--line-height-normal);
-        width: var(--line-height-normal);
-        padding: 0;
-      }
-    }
-    gr-button[disabled] iron-icon {
-      color: var(--border-color);
-    }
-    gr-account-chip {
-      margin-right: var(--spacing-xs);
-    }
-    iron-icon {
-      height: calc(var(--line-height-normal) - 2px);
-      width: calc(var(--line-height-normal) - 2px);
-    }
-    .labelValueContainer:not(:first-of-type) td {
-      padding-top: var(--spacing-s);
-    }
-  </style>
-  <p
-    class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]"
-  >
-    No votes.
-  </p>
-  <table>
-    <template
-      is="dom-repeat"
-      items="[[_mapLabelInfo(labelInfo, account, change.labels.*)]]"
-      as="mappedLabel"
-    >
-      <tr class="labelValueContainer">
-        <td>
-          <gr-label
-            has-tooltip=""
-            title="[[_computeValueTooltip(labelInfo, mappedLabel.value)]]"
-            class$="[[mappedLabel.className]] voteChip"
-          >
-            [[mappedLabel.value]]
-          </gr-label>
-        </td>
-        <td>
-          <gr-account-chip
-            account="[[mappedLabel.account]]"
-            transparent-background=""
-          ></gr-account-chip>
-        </td>
-        <td>
-          <gr-button
-            link=""
-            aria-label="Remove"
-            on-click="_onDeleteVote"
-            tooltip="Remove vote"
-            data-account-id$="[[mappedLabel.account._account_id]]"
-            class$="deleteBtn [[_computeDeleteClass(mappedLabel.account, mutable, change)]]"
-          >
-            <iron-icon icon="gr-icons:delete"></iron-icon>
-          </gr-button>
-        </td>
-      </tr>
-    </template>
-  </table>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
new file mode 100644
index 0000000..66f3734
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
@@ -0,0 +1,122 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="gr-voting-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
+  <style include="shared-styles">
+    .placeholder {
+      color: var(--deemphasized-text-color);
+      padding-top: var(--spacing-xs);
+    }
+    .hidden {
+      display: none;
+    }
+    .voteChip {
+      display: flex;
+      justify-content: center;
+      margin-right: var(--spacing-s);
+      padding: 0;
+      @apply --vote-chip-styles;
+      border-width: 0;
+    }
+    .max {
+      background-color: var(--vote-color-approved);
+    }
+    .min {
+      background-color: var(--vote-color-rejected);
+    }
+    .positive {
+      background-color: var(--vote-color-recommended);
+    }
+    .negative {
+      background-color: var(--vote-color-disliked);
+    }
+    .hidden {
+      display: none;
+    }
+    td {
+      vertical-align: top;
+    }
+    tr {
+      min-height: var(--line-height-normal);
+    }
+    gr-button {
+      vertical-align: top;
+      --gr-button: {
+        height: var(--line-height-normal);
+        width: var(--line-height-normal);
+        padding: 0;
+      }
+    }
+    gr-button[disabled] iron-icon {
+      color: var(--border-color);
+    }
+    gr-account-link {
+      margin-right: var(--spacing-xs);
+    }
+    iron-icon {
+      height: calc(var(--line-height-normal) - 2px);
+      width: calc(var(--line-height-normal) - 2px);
+    }
+    .labelValueContainer:not(:first-of-type) td {
+      padding-top: var(--spacing-s);
+    }
+  </style>
+  <p
+    class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]"
+  >
+    No votes.
+  </p>
+  <table>
+    <template
+      is="dom-repeat"
+      items="[[_mapLabelInfo(labelInfo, account, change.labels.*)]]"
+      as="mappedLabel"
+    >
+      <tr class="labelValueContainer">
+        <td>
+          <gr-label
+            has-tooltip=""
+            title="[[_computeValueTooltip(labelInfo, mappedLabel.value)]]"
+            class$="[[mappedLabel.className]] voteChip"
+          >
+            [[mappedLabel.value]]
+          </gr-label>
+        </td>
+        <td>
+          <gr-account-link account="[[mappedLabel.account]]"></gr-account-link>
+        </td>
+        <td>
+          <gr-button
+            link=""
+            aria-label="Remove vote"
+            on-click="_onDeleteVote"
+            tooltip="Remove vote"
+            data-account-id$="[[mappedLabel.account._account_id]]"
+            class$="deleteBtn [[_computeDeleteClass(mappedLabel.account, mutable, change)]]"
+          >
+            <iron-icon icon="gr-icons:delete"></iron-icon>
+          </gr-button>
+        </td>
+      </tr>
+    </template>
+  </table>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
deleted file mode 100644
index d7ccc45..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.html
+++ /dev/null
@@ -1,249 +0,0 @@
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-label-info</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-label-info></gr-label-info>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-label-info.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {isHidden} from '../../../test/test-utils.js';
-suite('gr-account-link tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    // Needed to trigger computed bindings.
-    element.account = {};
-    element.change = {labels: {}};
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('remove reviewer votes', () => {
-    setup(() => {
-      sandbox.stub(element, '_computeValueTooltip').returns('');
-      element.account = {
-        _account_id: 1,
-        name: 'bojack',
-      };
-      const test = {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-      };
-      element.change = {
-        _number: 42,
-        change_id: 'the id',
-        actions: [],
-        topic: 'the topic',
-        status: 'NEW',
-        submit_type: 'CHERRY_PICK',
-        labels: {test},
-        removable_reviewers: [],
-      };
-      element.labelInfo = test;
-      element.label = 'test';
-
-      flushAsynchronousOperations();
-    });
-
-    test('_computeCanDeleteVote', () => {
-      element.mutable = false;
-      const button = element.shadowRoot
-          .querySelector('gr-button');
-      assert.isTrue(isHidden(button));
-      element.change.removable_reviewers = [element.account];
-      element.mutable = true;
-      assert.isFalse(isHidden(button));
-    });
-
-    test('deletes votes', () => {
-      const deleteResponse = Promise.resolve({ok: true});
-      const deleteStub = sandbox.stub(
-          element.$.restAPI, 'deleteVote').returns(deleteResponse);
-
-      element.change.removable_reviewers = [element.account];
-      element.change.labels.test.recommended = {_account_id: 1};
-      element.mutable = true;
-      const button = element.shadowRoot
-          .querySelector('gr-button');
-      MockInteractions.tap(button);
-      assert.isTrue(button.disabled);
-      return deleteResponse.then(() => {
-        assert.isFalse(button.disabled);
-        assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
-      });
-    });
-  });
-
-  suite('label color and order', () => {
-    test('valueless label rejected', () => {
-      element.labelInfo = {rejected: {name: 'someone'}};
-      flushAsynchronousOperations();
-      const labels = dom(element.root).querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('negative'));
-    });
-
-    test('valueless label approved', () => {
-      element.labelInfo = {approved: {name: 'someone'}};
-      flushAsynchronousOperations();
-      const labels = dom(element.root).querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('positive'));
-    });
-
-    test('-2 to +2', () => {
-      element.labelInfo = {
-        all: [
-          {value: 2, name: 'user 2'},
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 3'},
-          {value: -2, name: 'user 4'},
-        ],
-        values: {
-          '-2': 'Awful',
-          '-1': 'Don\'t submit as-is',
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-          '+2': 'Ready to submit',
-        },
-      };
-      flushAsynchronousOperations();
-      const labels = dom(element.root).querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-      assert.isTrue(labels[2].classList.contains('negative'));
-      assert.isTrue(labels[3].classList.contains('min'));
-    });
-
-    test('-1 to +1', () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 2'},
-        ],
-        values: {
-          '-1': 'Don\'t submit as-is',
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      flushAsynchronousOperations();
-      const labels = dom(element.root).querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('min'));
-    });
-
-    test('0 to +2', () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 2'},
-          {value: 2, name: 'user '},
-        ],
-        values: {
-          ' 0': 'Don\'t submit as-is',
-          '+1': 'No score',
-          '+2': 'Looks good to me',
-        },
-      };
-      flushAsynchronousOperations();
-      const labels = dom(element.root).querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-    });
-
-    test('self votes at top', () => {
-      element.account = {
-        _account_id: 1,
-        name: 'bojack',
-      };
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 1', _account_id: 2},
-          {value: -1, name: 'bojack', _account_id: 1},
-        ],
-        values: {
-          '-1': 'Don\'t submit as-is',
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      flushAsynchronousOperations();
-      const chips =
-          dom(element.root).querySelectorAll('gr-account-chip');
-      assert.equal(chips[0].account._account_id, element.account._account_id);
-    });
-  });
-
-  test('_computeValueTooltip', () => {
-    // Existing label.
-    let labelInfo = {values: {0: 'Baz'}};
-    let score = '0';
-    assert.equal(element._computeValueTooltip(labelInfo, score), 'Baz');
-
-    // Non-exsistent score.
-    score = '2';
-    assert.equal(element._computeValueTooltip(labelInfo, score), '');
-
-    // No values on label.
-    labelInfo = {values: {}};
-    score = '0';
-    assert.equal(element._computeValueTooltip(labelInfo, score), '');
-  });
-
-  test('placeholder', () => {
-    element.labelInfo = {};
-    assert.isFalse(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {all: []};
-    assert.isFalse(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {all: [{value: 1}]};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {rejected: []};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {values: [], rejected: [], all: [{value: 1}]};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {approved: []};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {values: [], approved: [], all: [{value: 1}]};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
new file mode 100644
index 0000000..5992eb2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
@@ -0,0 +1,231 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-label-info.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {isHidden} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-label-info');
+
+suite('gr-account-link tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    // Needed to trigger computed bindings.
+    element.account = {};
+    element.change = {labels: {}};
+  });
+
+  suite('remove reviewer votes', () => {
+    setup(() => {
+      sinon.stub(element, '_computeValueTooltip').returns('');
+      element.account = {
+        _account_id: 1,
+        name: 'bojack',
+      };
+      const test = {
+        all: [{_account_id: 1, name: 'bojack', value: 1}],
+        default_value: 0,
+        values: [],
+      };
+      element.change = {
+        _number: 42,
+        change_id: 'the id',
+        actions: [],
+        topic: 'the topic',
+        status: 'NEW',
+        submit_type: 'CHERRY_PICK',
+        labels: {test},
+        removable_reviewers: [],
+      };
+      element.labelInfo = test;
+      element.label = 'test';
+
+      flushAsynchronousOperations();
+    });
+
+    test('_computeCanDeleteVote', () => {
+      element.mutable = false;
+      const button = element.shadowRoot
+          .querySelector('gr-button');
+      assert.isTrue(isHidden(button));
+      element.change.removable_reviewers = [element.account];
+      element.mutable = true;
+      assert.isFalse(isHidden(button));
+    });
+
+    test('deletes votes', () => {
+      const deleteResponse = Promise.resolve({ok: true});
+      const deleteStub = sinon.stub(
+          element.$.restAPI, 'deleteVote').returns(deleteResponse);
+
+      element.change.removable_reviewers = [element.account];
+      element.change.labels.test.recommended = {_account_id: 1};
+      element.mutable = true;
+      const button = element.shadowRoot
+          .querySelector('gr-button');
+      MockInteractions.tap(button);
+      assert.isTrue(button.disabled);
+      return deleteResponse.then(() => {
+        assert.isFalse(button.disabled);
+        assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
+      });
+    });
+  });
+
+  suite('label color and order', () => {
+    test('valueless label rejected', () => {
+      element.labelInfo = {rejected: {name: 'someone'}};
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('negative'));
+    });
+
+    test('valueless label approved', () => {
+      element.labelInfo = {approved: {name: 'someone'}};
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('positive'));
+    });
+
+    test('-2 to +2', () => {
+      element.labelInfo = {
+        all: [
+          {value: 2, name: 'user 2'},
+          {value: 1, name: 'user 1'},
+          {value: -1, name: 'user 3'},
+          {value: -2, name: 'user 4'},
+        ],
+        values: {
+          '-2': 'Awful',
+          '-1': 'Don\'t submit as-is',
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+          '+2': 'Ready to submit',
+        },
+      };
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('positive'));
+      assert.isTrue(labels[2].classList.contains('negative'));
+      assert.isTrue(labels[3].classList.contains('min'));
+    });
+
+    test('-1 to +1', () => {
+      element.labelInfo = {
+        all: [
+          {value: 1, name: 'user 1'},
+          {value: -1, name: 'user 2'},
+        ],
+        values: {
+          '-1': 'Don\'t submit as-is',
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+        },
+      };
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('min'));
+    });
+
+    test('0 to +2', () => {
+      element.labelInfo = {
+        all: [
+          {value: 1, name: 'user 2'},
+          {value: 2, name: 'user '},
+        ],
+        values: {
+          ' 0': 'Don\'t submit as-is',
+          '+1': 'No score',
+          '+2': 'Looks good to me',
+        },
+      };
+      flushAsynchronousOperations();
+      const labels = dom(element.root).querySelectorAll('gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('positive'));
+    });
+
+    test('self votes at top', () => {
+      element.account = {
+        _account_id: 1,
+        name: 'bojack',
+      };
+      element.labelInfo = {
+        all: [
+          {value: 1, name: 'user 1', _account_id: 2},
+          {value: -1, name: 'bojack', _account_id: 1},
+        ],
+        values: {
+          '-1': 'Don\'t submit as-is',
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+        },
+      };
+      flushAsynchronousOperations();
+      const chips =
+          dom(element.root).querySelectorAll('gr-account-link');
+      assert.equal(chips[0].account._account_id, element.account._account_id);
+    });
+  });
+
+  test('_computeValueTooltip', () => {
+    // Existing label.
+    let labelInfo = {values: {0: 'Baz'}};
+    let score = '0';
+    assert.equal(element._computeValueTooltip(labelInfo, score), 'Baz');
+
+    // Non-existent score.
+    score = '2';
+    assert.equal(element._computeValueTooltip(labelInfo, score), '');
+
+    // No values on label.
+    labelInfo = {values: {}};
+    score = '0';
+    assert.equal(element._computeValueTooltip(labelInfo, score), '');
+  });
+
+  test('placeholder', () => {
+    element.labelInfo = {};
+    assert.isFalse(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {all: []};
+    assert.isFalse(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {all: [{value: 1}]};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {rejected: []};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {values: [], rejected: [], all: [{value: 1}]};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {approved: []};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+    element.labelInfo = {values: [], approved: [], all: [{value: 1}]};
+    assert.isTrue(isHidden(element.shadowRoot
+        .querySelector('.placeholder')));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
index 014e85e..5df6b58 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
@@ -14,23 +14,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-label_html.js';
-import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrLabel extends mixinBehaviors( [
-  TooltipBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrLabel extends TooltipMixin(
+    GestureEventListeners(
+        LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-label'; }
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.js b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.js
deleted file mode 100644
index c4310fc..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
new file mode 100644
index 0000000..94196df
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
index f585347..d17f7d8 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../gr-autocomplete/gr-autocomplete.js';
 import '../../../styles/shared-styles.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
@@ -23,7 +21,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-labeled-autocomplete_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrLabeledAutocomplete extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.js
deleted file mode 100644
index 615a525..0000000
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      width: 12em;
-    }
-    #container {
-      background: var(--chip-background-color);
-      border-radius: 1em;
-      padding: var(--spacing-m);
-    }
-    #header {
-      color: var(--deemphasized-text-color);
-      font-weight: var(--font-weight-bold);
-      font-size: var(--font-size-small);
-    }
-    #body {
-      display: flex;
-    }
-    #trigger {
-      color: var(--deemphasized-text-color);
-      cursor: pointer;
-      padding-left: var(--spacing-s);
-    }
-    #trigger:hover {
-      color: var(--primary-text-color);
-    }
-  </style>
-  <div id="container">
-    <div id="header">[[label]]</div>
-    <div id="body">
-      <gr-autocomplete
-        id="autocomplete"
-        threshold="[[_autocompleteThreshold]]"
-        query="[[query]]"
-        disabled="[[disabled]]"
-        placeholder="[[placeholder]]"
-        borderless=""
-      ></gr-autocomplete>
-      <div id="trigger" on-click="_handleTriggerClick">▼</div>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts
new file mode 100644
index 0000000..fa50624
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      width: 12em;
+    }
+    #container {
+      background: var(--chip-background-color);
+      border-radius: 1em;
+      padding: var(--spacing-m);
+    }
+    #header {
+      color: var(--deemphasized-text-color);
+      font-weight: var(--font-weight-bold);
+      font-size: var(--font-size-small);
+    }
+    #body {
+      display: flex;
+    }
+    #trigger {
+      color: var(--deemphasized-text-color);
+      cursor: pointer;
+      padding-left: var(--spacing-s);
+    }
+    #trigger:hover {
+      color: var(--primary-text-color);
+    }
+  </style>
+  <div id="container">
+    <div id="header">[[label]]</div>
+    <div id="body">
+      <gr-autocomplete
+        id="autocomplete"
+        threshold="[[_autocompleteThreshold]]"
+        query="[[query]]"
+        disabled="[[disabled]]"
+        placeholder="[[placeholder]]"
+        borderless=""
+      ></gr-autocomplete>
+      <div id="trigger" on-click="_handleTriggerClick">▼</div>
+    </div>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
deleted file mode 100644
index 99a038e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html
+++ /dev/null
@@ -1,62 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-labeled-autocomplete</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-labeled-autocomplete></gr-labeled-autocomplete>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-labeled-autocomplete.js';
-suite('gr-labeled-autocomplete tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  test('tapping trigger focuses autocomplete', () => {
-    const e = {stopPropagation: () => undefined};
-    sandbox.stub(e, 'stopPropagation');
-    sandbox.stub(element.$.autocomplete, 'focus');
-    element._handleTriggerClick(e);
-    assert.isTrue(e.stopPropagation.calledOnce);
-    assert.isTrue(element.$.autocomplete.focus.calledOnce);
-  });
-
-  test('setText', () => {
-    sandbox.stub(element.$.autocomplete, 'setText');
-    element.setText('foo-bar');
-    assert.isTrue(element.$.autocomplete.setText.calledWith('foo-bar'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.js
new file mode 100644
index 0000000..3e904a2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.js
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-labeled-autocomplete.js';
+
+const basicFixture = fixtureFromElement('gr-labeled-autocomplete');
+
+suite('gr-labeled-autocomplete tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('tapping trigger focuses autocomplete', () => {
+    const e = {stopPropagation: () => undefined};
+    sinon.stub(e, 'stopPropagation');
+    sinon.stub(element.$.autocomplete, 'focus');
+    element._handleTriggerClick(e);
+    assert.isTrue(e.stopPropagation.calledOnce);
+    assert.isTrue(element.$.autocomplete.focus.calledOnce);
+  });
+
+  test('setText', () => {
+    sinon.stub(element.$.autocomplete, 'setText');
+    element.setText('foo-bar');
+    assert.isTrue(element.$.autocomplete.setText.calledWith('foo-bar'));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
index 9bd8a11..5762c39 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
@@ -14,20 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../gr-js-api-interface/gr-js-api-interface.js';
-import {importHref} from '../../../scripts/import-href.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-lib-loader_html.js';
 
+// preloaded in PolyGerritIndexHtml.soy
 const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
-const DARK_THEME_PATH = 'styles/themes/dark-theme.html';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrLibLoader extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
@@ -78,28 +75,6 @@
   }
 
   /**
-   * Loads the dark theme document. Returns a promise that resolves with a
-   * custom-style DOM element.
-   *
-   * @return {!Promise<Element>}
-   * @suppress {checkTypes}
-   */
-  getDarkTheme() {
-    return new Promise((resolve, reject) => {
-      importHref(
-          this._getLibRoot() + DARK_THEME_PATH, () => {
-            const module = document.createElement('style');
-            module.setAttribute('include', 'dark-theme');
-            const cs = document.createElement('custom-style');
-            cs.appendChild(module);
-
-            resolve(cs);
-          },
-          reject);
-    });
-  }
-
-  /**
    * Execute callbacks awaiting the HLJS lib load.
    */
   _onHLJSLibLoaded() {
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.js
deleted file mode 100644
index 204aa87..0000000
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.ts
new file mode 100644
index 0000000..f34f99e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_html.ts
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
deleted file mode 100644
index f2e5e3d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.html
+++ /dev/null
@@ -1,148 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-lib-loader</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-lib-loader></gr-lib-loader>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-lib-loader.js';
-suite('gr-lib-loader tests', () => {
-  let sandbox;
-  let element;
-  let resolveLoad;
-  let loadStub;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-
-    loadStub = sandbox.stub(element, '_loadScript', () =>
-      new Promise(resolve => resolveLoad = resolve)
-    );
-
-    // Assert preconditions:
-    assert.isFalse(element._hljsState.loading);
-  });
-
-  teardown(() => {
-    if (window.hljs) {
-      delete window.hljs;
-    }
-    sandbox.restore();
-
-    // Because the element state is a singleton, clean it up.
-    element._hljsState.configured = false;
-    element._hljsState.loading = false;
-    element._hljsState.callbacks = [];
-  });
-
-  test('only load once', done => {
-    sandbox.stub(element, '_getHLJSUrl').returns('');
-    const firstCallHandler = sinon.stub();
-    element.getHLJS().then(firstCallHandler);
-
-    // It should now be in the loading state.
-    assert.isTrue(loadStub.called);
-    assert.isTrue(element._hljsState.loading);
-    assert.isFalse(firstCallHandler.called);
-
-    const secondCallHandler = sinon.stub();
-    element.getHLJS().then(secondCallHandler);
-
-    // No change in state.
-    assert.isTrue(element._hljsState.loading);
-    assert.isFalse(firstCallHandler.called);
-    assert.isFalse(secondCallHandler.called);
-
-    // Now load the library.
-    resolveLoad();
-    flush(() => {
-      // The state should be loaded and both handlers called.
-      assert.isFalse(element._hljsState.loading);
-      assert.isTrue(firstCallHandler.called);
-      assert.isTrue(secondCallHandler.called);
-      done();
-    });
-  });
-
-  suite('preloaded', () => {
-    let hljsStub;
-
-    setup(() => {
-      hljsStub = {
-        configure: sinon.stub(),
-      };
-      window.hljs = hljsStub;
-    });
-
-    teardown(() => {
-      delete window.hljs;
-    });
-
-    test('returns hljs', done => {
-      const firstCallHandler = sinon.stub();
-      element.getHLJS().then(firstCallHandler);
-      flush(() => {
-        assert.isTrue(firstCallHandler.called);
-        assert.isTrue(firstCallHandler.calledWith(hljsStub));
-        done();
-      });
-    });
-
-    test('configures hljs', done => {
-      element.getHLJS().then(() => {
-        assert.isTrue(window.hljs.configure.calledOnce);
-        done();
-      });
-    });
-  });
-
-  suite('_getHLJSUrl', () => {
-    suite('checking _getLibRoot', () => {
-      let root;
-
-      setup(() => {
-        sandbox.stub(element, '_getLibRoot', () => root);
-      });
-
-      test('with no root', () => {
-        assert.isNull(element._getHLJSUrl());
-      });
-
-      test('with root', () => {
-        root = 'test-root.com/';
-        assert.equal(element._getHLJSUrl(),
-            'test-root.com/bower_components/highlightjs/highlight.min.js');
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
new file mode 100644
index 0000000..4231d71
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
@@ -0,0 +1,130 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-lib-loader.js';
+
+const basicFixture = fixtureFromElement('gr-lib-loader');
+
+suite('gr-lib-loader tests', () => {
+  let element;
+  let resolveLoad;
+  let loadStub;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    loadStub = sinon.stub(element, '_loadScript').callsFake(() =>
+      new Promise(resolve => resolveLoad = resolve)
+    );
+
+    // Assert preconditions:
+    assert.isFalse(element._hljsState.loading);
+  });
+
+  teardown(() => {
+    if (window.hljs) {
+      delete window.hljs;
+    }
+
+    // Because the element state is a singleton, clean it up.
+    element._hljsState.configured = false;
+    element._hljsState.loading = false;
+    element._hljsState.callbacks = [];
+  });
+
+  test('only load once', done => {
+    sinon.stub(element, '_getHLJSUrl').returns('');
+    const firstCallHandler = sinon.stub();
+    element.getHLJS().then(firstCallHandler);
+
+    // It should now be in the loading state.
+    assert.isTrue(loadStub.called);
+    assert.isTrue(element._hljsState.loading);
+    assert.isFalse(firstCallHandler.called);
+
+    const secondCallHandler = sinon.stub();
+    element.getHLJS().then(secondCallHandler);
+
+    // No change in state.
+    assert.isTrue(element._hljsState.loading);
+    assert.isFalse(firstCallHandler.called);
+    assert.isFalse(secondCallHandler.called);
+
+    // Now load the library.
+    resolveLoad();
+    flush(() => {
+      // The state should be loaded and both handlers called.
+      assert.isFalse(element._hljsState.loading);
+      assert.isTrue(firstCallHandler.called);
+      assert.isTrue(secondCallHandler.called);
+      done();
+    });
+  });
+
+  suite('preloaded', () => {
+    let hljsStub;
+
+    setup(() => {
+      hljsStub = {
+        configure: sinon.stub(),
+      };
+      window.hljs = hljsStub;
+    });
+
+    teardown(() => {
+      delete window.hljs;
+    });
+
+    test('returns hljs', done => {
+      const firstCallHandler = sinon.stub();
+      element.getHLJS().then(firstCallHandler);
+      flush(() => {
+        assert.isTrue(firstCallHandler.called);
+        assert.isTrue(firstCallHandler.calledWith(hljsStub));
+        done();
+      });
+    });
+
+    test('configures hljs', done => {
+      element.getHLJS().then(() => {
+        assert.isTrue(window.hljs.configure.calledOnce);
+        done();
+      });
+    });
+  });
+
+  suite('_getHLJSUrl', () => {
+    suite('checking _getLibRoot', () => {
+      let root;
+
+      setup(() => {
+        sinon.stub(element, '_getLibRoot').callsFake(() => root);
+      });
+
+      test('with no root', () => {
+        assert.isNull(element._getHLJSUrl());
+      });
+
+      test('with root', () => {
+        root = 'test-root.com/';
+        assert.equal(element._getHLJSUrl(),
+            'test-root.com/bower_components/highlightjs/highlight.min.js');
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
index b7bfbf3..803f802 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
@@ -14,14 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-limited-text_html.js';
-import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin.js';
 
 /**
  * The gr-limited-text element is for displaying text with a maximum length
@@ -29,13 +26,11 @@
  * configured limit, then an ellipsis indicates that the text was truncated
  * and a tooltip containing the full text is enabled.
  *
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrLimitedText extends mixinBehaviors( [
-  TooltipBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrLimitedText extends TooltipMixin(
+    GestureEventListeners(
+        LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-limited-text'; }
@@ -43,7 +38,10 @@
   static get properties() {
     return {
     /** The un-truncated text to display. */
-      text: String,
+      text: {
+        type: String,
+        value: '',
+      },
 
       /** The maximum length for the text to display before truncating. */
       limit: {
@@ -51,7 +49,12 @@
         value: null,
       },
 
-      /** Boolean property used by TooltipBehavior. */
+      tooltip: {
+        type: String,
+        value: '',
+      },
+
+      /** Boolean property used by TooltipMixin. */
       hasTooltip: {
         type: Boolean,
         value: false,
@@ -65,20 +68,12 @@
         type: Boolean,
         value: false,
       },
-
-      /**
-       * The maximum number of characters to display in the tooltop.
-       */
-      tooltipLimit: {
-        type: Number,
-        value: 1024,
-      },
     };
   }
 
   static get observers() {
     return [
-      '_updateTitle(text, limit, tooltipLimit)',
+      '_updateTitle(text, tooltip, limit)',
     ];
   }
 
@@ -86,17 +81,22 @@
    * The text or limit have changed. Recompute whether a tooltip needs to be
    * enabled.
    */
-  _updateTitle(text, limit, tooltipLimit) {
+  _updateTitle(text, tooltip, limit) {
     // Polymer 2: check for undefined
-    if ([text, limit, tooltipLimit].some(arg => arg === undefined)) {
+    if ([text, limit, tooltip].includes(undefined)) {
       return;
     }
 
-    this.hasTooltip = !!limit && !!text && text.length > limit;
+    this.hasTooltip = !!tooltip || (!!limit && text.length > limit);
     if (this.hasTooltip && !this.disableTooltip) {
-      this.setAttribute('title', text.substr(0, tooltipLimit));
+      // Combine the text and title if over-length
+      if (limit && text.length > limit) {
+        this.title = `${text}${tooltip? ` (${tooltip})` : ''}`;
+      } else {
+        this.title = tooltip;
+      }
     } else {
-      this.removeAttribute('title');
+      this.title = '';
     }
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.js
deleted file mode 100644
index 6bcce8c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html` [[_computeDisplayText(text, limit)]] `;
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.ts
new file mode 100644
index 0000000..b942d07
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.ts
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html` [[_computeDisplayText(text, limit)]] `;
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
deleted file mode 100644
index 889b786..0000000
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
+++ /dev/null
@@ -1,103 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-limited-text</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-limited-text></gr-limited-text>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-limited-text.js';
-suite('gr-limited-text tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_updateTitle', () => {
-    const updateSpy = sandbox.spy(element, '_updateTitle');
-    element.text = 'abc 123';
-    flushAsynchronousOperations();
-    assert.isTrue(updateSpy.calledOnce);
-    assert.isNotOk(element.getAttribute('title'));
-    assert.isFalse(element.hasTooltip);
-
-    element.limit = 10;
-    flushAsynchronousOperations();
-    assert.isTrue(updateSpy.calledTwice);
-    assert.isNotOk(element.getAttribute('title'));
-    assert.isFalse(element.hasTooltip);
-
-    element.limit = 3;
-    flushAsynchronousOperations();
-    assert.isTrue(updateSpy.calledThrice);
-    assert.equal(element.getAttribute('title'), 'abc 123');
-    assert.isTrue(element.hasTooltip);
-
-    element.tooltipLimit = 3;
-    flushAsynchronousOperations();
-    assert.equal(element.getAttribute('title'), 'abc');
-
-    element.tooltipLimit = 1024;
-    element.limit = 100;
-    flushAsynchronousOperations();
-    assert.equal(updateSpy.callCount, 6);
-    assert.isNotOk(element.getAttribute('title'));
-    assert.isFalse(element.hasTooltip);
-
-    element.limit = null;
-    flushAsynchronousOperations();
-    assert.equal(updateSpy.callCount, 7);
-    assert.isNotOk(element.getAttribute('title'));
-    assert.isFalse(element.hasTooltip);
-  });
-
-  test('_computeDisplayText', () => {
-    assert.equal(element._computeDisplayText('foo bar', 100), 'foo bar');
-    assert.equal(element._computeDisplayText('foo bar', 4), 'foo…');
-    assert.equal(element._computeDisplayText('foo bar', null), 'foo bar');
-  });
-
-  test('when disable tooltip', () => {
-    sandbox.spy(element, '_updateTitle');
-    element.text = 'abcdefghijklmn';
-    element.disableTooltip = true;
-    element.limit = 10;
-    flushAsynchronousOperations();
-    assert.equal(element.getAttribute('title'), null);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
new file mode 100644
index 0000000..6e27eeb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-limited-text.js';
+
+const basicFixture = fixtureFromElement('gr-limited-text');
+
+suite('gr-limited-text tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('tooltip without title input', () => {
+    const updateSpy = sinon.spy(element, '_updateTitle');
+    element.text = 'abc 123';
+    flushAsynchronousOperations();
+    assert.isTrue(updateSpy.calledOnce);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+
+    element.limit = 10;
+    flushAsynchronousOperations();
+    assert.isTrue(updateSpy.calledTwice);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+
+    element.limit = 3;
+    flushAsynchronousOperations();
+    assert.equal(updateSpy.callCount, 3);
+    assert.equal(element.getAttribute('title'), 'abc 123');
+    assert.equal(element.title, 'abc 123');
+    assert.isTrue(element.hasTooltip);
+
+    element.limit = 100;
+    flushAsynchronousOperations();
+    assert.equal(updateSpy.callCount, 4);
+    assert.isFalse(element.hasTooltip);
+
+    element.limit = null;
+    flushAsynchronousOperations();
+    assert.equal(updateSpy.callCount, 5);
+    assert.isNotOk(element.getAttribute('title'));
+    assert.isFalse(element.hasTooltip);
+  });
+
+  test('with tooltip input', () => {
+    const updateSpy = sinon.spy(element, '_updateTitle');
+    element.tooltip = 'abc 123';
+    flushAsynchronousOperations();
+    assert.isTrue(updateSpy.calledOnce);
+    assert.isTrue(element.hasTooltip);
+    assert.equal(element.getAttribute('title'), 'abc 123');
+    assert.equal(element.title, 'abc 123');
+
+    element.text = 'abc';
+    flushAsynchronousOperations();
+    assert.equal(element.getAttribute('title'), 'abc 123');
+    assert.isTrue(element.hasTooltip);
+
+    element.text = 'abcdef';
+    element.limit = 3;
+    flushAsynchronousOperations();
+    assert.equal(element.getAttribute('title'), 'abcdef (abc 123)');
+    assert.isTrue(element.hasTooltip);
+  });
+
+  test('_computeDisplayText', () => {
+    assert.equal(element._computeDisplayText('foo bar', 100), 'foo bar');
+    assert.equal(element._computeDisplayText('foo bar', 4), 'foo…');
+    assert.equal(element._computeDisplayText('foo bar', null), 'foo bar');
+  });
+
+  test('when disable tooltip', () => {
+    sinon.spy(element, '_updateTitle');
+    element.text = 'abcdefghijklmn';
+    element.disableTooltip = true;
+    element.limit = 10;
+    flushAsynchronousOperations();
+    assert.equal(element.getAttribute('title'), '');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
index 077ca74..46588ef 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 
 import '../gr-button/gr-button.js';
 import '../gr-icons/gr-icons.js';
@@ -26,7 +25,7 @@
 import {htmlTemplate} from './gr-linked-chip_html.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
 class GrLinkedChip extends GestureEventListeners(
     LegacyElementMixin(PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.js
deleted file mode 100644
index f1f5f46..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.js
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      overflow: hidden;
-    }
-    .container {
-      align-items: center;
-      background: var(--chip-background-color);
-      border-radius: 0.75em;
-      display: inline-flex;
-      padding: 0 var(--spacing-m);
-    }
-    gr-button.remove {
-      --gr-remove-button-style: {
-        border: 0;
-        color: var(--deemphasized-text-color);
-        font-weight: var(--font-weight-normal);
-        height: 0.6em;
-        line-height: 10px;
-        margin-left: var(--spacing-xs);
-        padding: 0;
-        text-decoration: none;
-      }
-    }
-
-    gr-button.remove:hover,
-    gr-button.remove:focus {
-      --gr-button: {
-        @apply --gr-remove-button-style;
-        color: #333;
-      }
-    }
-    gr-button.remove {
-      --gr-button: {
-        @apply --gr-remove-button-style;
-      }
-    }
-    .transparentBackground,
-    gr-button.transparentBackground {
-      background-color: transparent;
-    }
-    :host([disabled]) {
-      opacity: 0.6;
-      pointer-events: none;
-    }
-    a {
-      color: var(--linked-chip-text-color);
-    }
-    iron-icon {
-      height: 1.2rem;
-      width: 1.2rem;
-    }
-  </style>
-  <div class$="container [[_getBackgroundClass(transparentBackground)]]">
-    <a href$="[[href]]">
-      <gr-limited-text limit="[[limit]]" text="[[text]]"></gr-limited-text>
-    </a>
-    <gr-button
-      id="remove"
-      link=""
-      hidden$="[[!removable]]"
-      hidden=""
-      class$="remove [[_getBackgroundClass(transparentBackground)]]"
-      on-click="_handleRemoveTap"
-    >
-      <iron-icon icon="gr-icons:close"></iron-icon>
-    </gr-button>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
new file mode 100644
index 0000000..a335db7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
@@ -0,0 +1,88 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+      overflow: hidden;
+    }
+    .container {
+      align-items: center;
+      background: var(--chip-background-color);
+      border-radius: 0.75em;
+      display: inline-flex;
+      padding: 0 var(--spacing-m);
+    }
+    gr-button.remove {
+      --gr-remove-button-style: {
+        border: 0;
+        color: var(--deemphasized-text-color);
+        font-weight: var(--font-weight-normal);
+        height: 0.6em;
+        line-height: 10px;
+        margin-left: var(--spacing-xs);
+        padding: 0;
+        text-decoration: none;
+      }
+    }
+
+    gr-button.remove:hover,
+    gr-button.remove:focus {
+      --gr-button: {
+        @apply --gr-remove-button-style;
+        color: #333;
+      }
+    }
+    gr-button.remove {
+      --gr-button: {
+        @apply --gr-remove-button-style;
+      }
+    }
+    .transparentBackground,
+    gr-button.transparentBackground {
+      background-color: transparent;
+    }
+    :host([disabled]) {
+      opacity: 0.6;
+      pointer-events: none;
+    }
+    a {
+      color: var(--linked-chip-text-color);
+    }
+    iron-icon {
+      height: 1.2rem;
+      width: 1.2rem;
+    }
+  </style>
+  <div class$="container [[_getBackgroundClass(transparentBackground)]]">
+    <a href$="[[href]]">
+      <gr-limited-text limit="[[limit]]" text="[[text]]"></gr-limited-text>
+    </a>
+    <gr-button
+      id="remove"
+      link=""
+      hidden$="[[!removable]]"
+      hidden=""
+      class$="remove [[_getBackgroundClass(transparentBackground)]]"
+      on-click="_handleRemoveTap"
+    >
+      <iron-icon icon="gr-icons:close"></iron-icon>
+    </gr-button>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
deleted file mode 100644
index c8de3df..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
+++ /dev/null
@@ -1,65 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-linked-chip</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<!-- Can't use absolute path below for mock-interaction.js.
-Web component tester(wct) has a built-in http server and it serves "/components" directory (which is
-actually /node_modules directory). Also, wct patches some files to load modules from /components.
-With the absolute path, browser tries to load dom-module from 2 different places (/component/... and
-/node_modules/...) though this is actually the same file. This leads to a run-time error.
--->
-<script src="../../../node_modules/iron-test-helpers/mock-interactions.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-linked-chip></gr-linked-chip>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-linked-chip.js';
-suite('gr-linked-chip tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('remove fired', () => {
-    const spy = sandbox.spy();
-    element.addEventListener('remove', spy);
-    flushAsynchronousOperations();
-    MockInteractions.tap(element.$.remove);
-    assert.isTrue(spy.called);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js
new file mode 100644
index 0000000..b111e84
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-linked-chip.js';
+
+const basicFixture = fixtureFromElement('gr-linked-chip');
+
+suite('gr-linked-chip tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('remove fired', () => {
+    const spy = sinon.spy();
+    element.addEventListener('remove', spy);
+    flushAsynchronousOperations();
+    MockInteractions.tap(element.$.remove);
+    assert.isTrue(spy.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
index 6ea4b78..b969d1d 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
@@ -26,7 +24,7 @@
 import {GrLinkTextParser} from './link-text-parser.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrLinkedText extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.js
deleted file mode 100644
index 59bed1e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([pre]) span {
-      white-space: var(--linked-text-white-space, pre-wrap);
-      word-wrap: var(--linked-text-word-wrap, break-word);
-    }
-    :host([disabled]) a {
-      color: inherit;
-      text-decoration: none;
-      pointer-events: none;
-    }
-  </style>
-  <span id="output"></span>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
new file mode 100644
index 0000000..4bdc1ab
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    :host([pre]) span {
+      white-space: var(--linked-text-white-space, pre-wrap);
+      word-wrap: var(--linked-text-word-wrap, break-word);
+    }
+    :host([disabled]) a {
+      color: inherit;
+      text-decoration: none;
+      pointer-events: none;
+    }
+  </style>
+  <span id="output"></span>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
deleted file mode 100644
index 4fa4390..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
+++ /dev/null
@@ -1,376 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2015 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-linked-text</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-linked-text>
-      <div id="output"></div>
-    </gr-linked-text>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-linked-text.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-suite('gr-linked-text tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    sandbox.stub(GerritNav, 'mapCommentlinks', x => x);
-    element.config = {
-      ph: {
-        match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
-        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
-      },
-      prefixsameinlinkandpattern: {
-        match: '([Hh][Tt][Tt][Pp]example)\\s*#?(\\d+)',
-        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
-      },
-      changeid: {
-        match: '(I[0-9a-f]{8,40})',
-        link: '#/q/$1',
-      },
-      changeid2: {
-        match: 'Change-Id: +(I[0-9a-f]{8,40})',
-        link: '#/q/$1',
-      },
-      googlesearch: {
-        match: 'google:(.+)',
-        link: 'https://bing.com/search?q=$1', // html should supercede link.
-        html: '<a href="https://google.com/search?q=$1">$1</a>',
-      },
-      hashedhtml: {
-        match: 'hash:(.+)',
-        html: '<a href="#/awesomesauce">$1</a>',
-      },
-      baseurl: {
-        match: 'test (.+)',
-        html: '<a href="/r/awesomesauce">$1</a>',
-      },
-      anotatstartwithbaseurl: {
-        match: 'a test (.+)',
-        html: '[Lookup: <a href="/r/awesomesauce">$1</a>]',
-      },
-      disabledconfig: {
-        match: 'foo:(.+)',
-        link: 'https://google.com/search?q=$1',
-        enabled: false,
-      },
-    };
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('URL pattern was parsed and linked.', () => {
-    // Regular inline link.
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-    element.content = url;
-    const linkEl = element.$.output.childNodes[0];
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.rel, 'noopener');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, url);
-  });
-
-  test('Bug pattern was parsed and linked', () => {
-    // "Issue/Bug" pattern.
-    element.content = 'Issue 3650';
-
-    let linkEl = element.$.output.childNodes[0];
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'Issue 3650');
-
-    element.content = 'Bug 3650';
-    linkEl = element.$.output.childNodes[0];
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.rel, 'noopener');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'Bug 3650');
-  });
-
-  test('Pattern with same prefix as link was correctly parsed', () => {
-    // Pattern starts with the same prefix (`http`) as the url.
-    element.content = 'httpexample 3650';
-
-    assert.equal(element.$.output.childNodes.length, 1);
-    const linkEl = element.$.output.childNodes[0];
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'httpexample 3650');
-  });
-
-  test('Change-Id pattern was parsed and linked', () => {
-    // "Change-Id:" pattern.
-    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-    const prefix = 'Change-Id: ';
-    element.content = prefix + changeID;
-
-    const textNode = element.$.output.childNodes[0];
-    const linkEl = element.$.output.childNodes[1];
-    assert.equal(textNode.textContent, prefix);
-    const url = '/q/' + changeID;
-    assert.isFalse(linkEl.hasAttribute('target'));
-    // Since url is a path, the host is added automatically.
-    assert.isTrue(linkEl.href.endsWith(url));
-    assert.equal(linkEl.textContent, changeID);
-  });
-
-  test('Change-Id pattern was parsed and linked with base url', () => {
-    window.CANONICAL_PATH = '/r';
-
-    // "Change-Id:" pattern.
-    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-    const prefix = 'Change-Id: ';
-    element.content = prefix + changeID;
-
-    const textNode = element.$.output.childNodes[0];
-    const linkEl = element.$.output.childNodes[1];
-    assert.equal(textNode.textContent, prefix);
-    const url = '/r/q/' + changeID;
-    assert.isFalse(linkEl.hasAttribute('target'));
-    // Since url is a path, the host is added automatically.
-    assert.isTrue(linkEl.href.endsWith(url));
-    assert.equal(linkEl.textContent, changeID);
-  });
-
-  test('Multiple matches', () => {
-    element.content = 'Issue 3650\nIssue 3450';
-    const linkEl1 = element.$.output.childNodes[0];
-    const linkEl2 = element.$.output.childNodes[2];
-
-    assert.equal(linkEl1.target, '_blank');
-    assert.equal(linkEl1.href,
-        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650');
-    assert.equal(linkEl1.textContent, 'Issue 3650');
-
-    assert.equal(linkEl2.target, '_blank');
-    assert.equal(linkEl2.href,
-        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450');
-    assert.equal(linkEl2.textContent, 'Issue 3450');
-  });
-
-  test('Change-Id pattern parsed before bug pattern', () => {
-    // "Change-Id:" pattern.
-    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-    const prefix = 'Change-Id: ';
-
-    // "Issue/Bug" pattern.
-    const bug = 'Issue 3650';
-
-    const changeUrl = '/q/' + changeID;
-    const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-
-    element.content = prefix + changeID + bug;
-
-    const textNode = element.$.output.childNodes[0];
-    const changeLinkEl = element.$.output.childNodes[1];
-    const bugLinkEl = element.$.output.childNodes[2];
-
-    assert.equal(textNode.textContent, prefix);
-
-    assert.isFalse(changeLinkEl.hasAttribute('target'));
-    assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
-    assert.equal(changeLinkEl.textContent, changeID);
-
-    assert.equal(bugLinkEl.target, '_blank');
-    assert.equal(bugLinkEl.href, bugUrl);
-    assert.equal(bugLinkEl.textContent, 'Issue 3650');
-  });
-
-  test('html field in link config', () => {
-    element.content = 'google:do a barrel roll';
-    const linkEl = element.$.output.childNodes[0];
-    assert.equal(linkEl.getAttribute('href'),
-        'https://google.com/search?q=do a barrel roll');
-    assert.equal(linkEl.textContent, 'do a barrel roll');
-  });
-
-  test('removing hash from links', () => {
-    element.content = 'hash:foo';
-    const linkEl = element.$.output.childNodes[0];
-    assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
-    assert.equal(linkEl.textContent, 'foo');
-  });
-
-  test('html with base url', () => {
-    window.CANONICAL_PATH = '/r';
-
-    element.content = 'test foo';
-    const linkEl = element.$.output.childNodes[0];
-    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-    assert.equal(linkEl.textContent, 'foo');
-  });
-
-  test('a is not at start', () => {
-    window.CANONICAL_PATH = '/r';
-
-    element.content = 'a test foo';
-    const linkEl = element.$.output.childNodes[1];
-    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-    assert.equal(linkEl.textContent, 'foo');
-  });
-
-  test('hash html with base url', () => {
-    window.CANONICAL_PATH = '/r';
-
-    element.content = 'hash:foo';
-    const linkEl = element.$.output.childNodes[0];
-    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-    assert.equal(linkEl.textContent, 'foo');
-  });
-
-  test('disabled config', () => {
-    element.content = 'foo:baz';
-    assert.equal(element.$.output.innerHTML, 'foo:baz');
-  });
-
-  test('R=email labels link correctly', () => {
-    element.removeZeroWidthSpace = true;
-    element.content = 'R=\u200Btest@google.com';
-    assert.equal(element.$.output.textContent, 'R=test@google.com');
-    assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
-  });
-
-  test('CC=email labels link correctly', () => {
-    element.removeZeroWidthSpace = true;
-    element.content = 'CC=\u200Btest@google.com';
-    assert.equal(element.$.output.textContent, 'CC=test@google.com');
-    assert.equal(element.$.output.innerHTML.match(/(CC=<a)/g).length, 1);
-  });
-
-  test('only {http,https,mailto} protocols are linkified', () => {
-    element.content = 'xx mailto:test@google.com yy';
-    let links = element.$.output.querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
-    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
-
-    element.content = 'xx http://google.com yy';
-    links = element.$.output.querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'http://google.com');
-    assert.equal(links[0].innerHTML, 'http://google.com');
-
-    element.content = 'xx https://google.com yy';
-    links = element.$.output.querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'https://google.com');
-    assert.equal(links[0].innerHTML, 'https://google.com');
-
-    element.content = 'xx ssh://google.com yy';
-    links = element.$.output.querySelectorAll('a');
-    assert.equal(links.length, 0);
-
-    element.content = 'xx ftp://google.com yy';
-    links = element.$.output.querySelectorAll('a');
-    assert.equal(links.length, 0);
-  });
-
-  test('links without leading whitespace are linkified', () => {
-    element.content = 'xx abcmailto:test@google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx abc');
-    let links = element.$.output.querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
-    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
-
-    element.content = 'xx defhttp://google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx def');
-    links = element.$.output.querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'http://google.com');
-    assert.equal(links[0].innerHTML, 'http://google.com');
-
-    element.content = 'xx qwehttps://google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx qwe');
-    links = element.$.output.querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'https://google.com');
-    assert.equal(links[0].innerHTML, 'https://google.com');
-
-    // Non-latin character
-    element.content = 'xx абвhttps://google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx абв');
-    links = element.$.output.querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'https://google.com');
-    assert.equal(links[0].innerHTML, 'https://google.com');
-
-    element.content = 'xx ssh://google.com yy';
-    links = element.$.output.querySelectorAll('a');
-    assert.equal(links.length, 0);
-
-    element.content = 'xx ftp://google.com yy';
-    links = element.$.output.querySelectorAll('a');
-    assert.equal(links.length, 0);
-  });
-
-  test('overlapping links', () => {
-    element.config = {
-      b1: {
-        match: '(B:\\s*)(\\d+)',
-        html: '$1<a href="ftp://foo/$2">$2</a>',
-      },
-      b2: {
-        match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
-        html: '$1<a href="ftp://foo/$2">$2</a>',
-      },
-    };
-    element.content = '- B: 123, 45';
-    const links = dom(element.root).querySelectorAll('a');
-
-    assert.equal(links.length, 2);
-    assert.equal(element.shadowRoot
-        .querySelector('span').textContent, '- B: 123, 45');
-
-    assert.equal(links[0].href, 'ftp://foo/123');
-    assert.equal(links[0].textContent, '123');
-
-    assert.equal(links[1].href, 'ftp://foo/45');
-    assert.equal(links[1].textContent, '45');
-  });
-
-  test('_contentOrConfigChanged called with config', () => {
-    const contentStub = sandbox.stub(element, '_contentChanged');
-    const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
-    element.content = 'some text';
-    assert.isTrue(contentStub.called);
-    assert.isTrue(contentConfigStub.called);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js
new file mode 100644
index 0000000..a295d1a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js
@@ -0,0 +1,366 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-linked-text.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-linked-text>
+      <div id="output"></div>
+    </gr-linked-text>
+`);
+
+suite('gr-linked-text tests', () => {
+  let element;
+
+  let originalCanonicalPath;
+
+  setup(() => {
+    originalCanonicalPath = window.CANONICAL_PATH;
+    element = basicFixture.instantiate();
+
+    sinon.stub(GerritNav, 'mapCommentlinks').value( x => x);
+    element.config = {
+      ph: {
+        match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
+        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
+      },
+      prefixsameinlinkandpattern: {
+        match: '([Hh][Tt][Tt][Pp]example)\\s*#?(\\d+)',
+        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
+      },
+      changeid: {
+        match: '(I[0-9a-f]{8,40})',
+        link: '#/q/$1',
+      },
+      changeid2: {
+        match: 'Change-Id: +(I[0-9a-f]{8,40})',
+        link: '#/q/$1',
+      },
+      googlesearch: {
+        match: 'google:(.+)',
+        link: 'https://bing.com/search?q=$1', // html should supercede link.
+        html: '<a href="https://google.com/search?q=$1">$1</a>',
+      },
+      hashedhtml: {
+        match: 'hash:(.+)',
+        html: '<a href="#/awesomesauce">$1</a>',
+      },
+      baseurl: {
+        match: 'test (.+)',
+        html: '<a href="/r/awesomesauce">$1</a>',
+      },
+      anotatstartwithbaseurl: {
+        match: 'a test (.+)',
+        html: '[Lookup: <a href="/r/awesomesauce">$1</a>]',
+      },
+      disabledconfig: {
+        match: 'foo:(.+)',
+        link: 'https://google.com/search?q=$1',
+        enabled: false,
+      },
+    };
+  });
+
+  teardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  test('URL pattern was parsed and linked.', () => {
+    // Regular inline link.
+    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    element.content = url;
+    const linkEl = element.$.output.childNodes[0];
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.rel, 'noopener');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, url);
+  });
+
+  test('Bug pattern was parsed and linked', () => {
+    // "Issue/Bug" pattern.
+    element.content = 'Issue 3650';
+
+    let linkEl = element.$.output.childNodes[0];
+    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, 'Issue 3650');
+
+    element.content = 'Bug 3650';
+    linkEl = element.$.output.childNodes[0];
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.rel, 'noopener');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, 'Bug 3650');
+  });
+
+  test('Pattern with same prefix as link was correctly parsed', () => {
+    // Pattern starts with the same prefix (`http`) as the url.
+    element.content = 'httpexample 3650';
+
+    assert.equal(element.$.output.childNodes.length, 1);
+    const linkEl = element.$.output.childNodes[0];
+    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+    assert.equal(linkEl.target, '_blank');
+    assert.equal(linkEl.href, url);
+    assert.equal(linkEl.textContent, 'httpexample 3650');
+  });
+
+  test('Change-Id pattern was parsed and linked', () => {
+    // "Change-Id:" pattern.
+    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+    const prefix = 'Change-Id: ';
+    element.content = prefix + changeID;
+
+    const textNode = element.$.output.childNodes[0];
+    const linkEl = element.$.output.childNodes[1];
+    assert.equal(textNode.textContent, prefix);
+    const url = '/q/' + changeID;
+    assert.isFalse(linkEl.hasAttribute('target'));
+    // Since url is a path, the host is added automatically.
+    assert.isTrue(linkEl.href.endsWith(url));
+    assert.equal(linkEl.textContent, changeID);
+  });
+
+  test('Change-Id pattern was parsed and linked with base url', () => {
+    window.CANONICAL_PATH = '/r';
+
+    // "Change-Id:" pattern.
+    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+    const prefix = 'Change-Id: ';
+    element.content = prefix + changeID;
+
+    const textNode = element.$.output.childNodes[0];
+    const linkEl = element.$.output.childNodes[1];
+    assert.equal(textNode.textContent, prefix);
+    const url = '/r/q/' + changeID;
+    assert.isFalse(linkEl.hasAttribute('target'));
+    // Since url is a path, the host is added automatically.
+    assert.isTrue(linkEl.href.endsWith(url));
+    assert.equal(linkEl.textContent, changeID);
+  });
+
+  test('Multiple matches', () => {
+    element.content = 'Issue 3650\nIssue 3450';
+    const linkEl1 = element.$.output.childNodes[0];
+    const linkEl2 = element.$.output.childNodes[2];
+
+    assert.equal(linkEl1.target, '_blank');
+    assert.equal(linkEl1.href,
+        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650');
+    assert.equal(linkEl1.textContent, 'Issue 3650');
+
+    assert.equal(linkEl2.target, '_blank');
+    assert.equal(linkEl2.href,
+        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450');
+    assert.equal(linkEl2.textContent, 'Issue 3450');
+  });
+
+  test('Change-Id pattern parsed before bug pattern', () => {
+    // "Change-Id:" pattern.
+    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
+    const prefix = 'Change-Id: ';
+
+    // "Issue/Bug" pattern.
+    const bug = 'Issue 3650';
+
+    const changeUrl = '/q/' + changeID;
+    const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
+
+    element.content = prefix + changeID + bug;
+
+    const textNode = element.$.output.childNodes[0];
+    const changeLinkEl = element.$.output.childNodes[1];
+    const bugLinkEl = element.$.output.childNodes[2];
+
+    assert.equal(textNode.textContent, prefix);
+
+    assert.isFalse(changeLinkEl.hasAttribute('target'));
+    assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
+    assert.equal(changeLinkEl.textContent, changeID);
+
+    assert.equal(bugLinkEl.target, '_blank');
+    assert.equal(bugLinkEl.href, bugUrl);
+    assert.equal(bugLinkEl.textContent, 'Issue 3650');
+  });
+
+  test('html field in link config', () => {
+    element.content = 'google:do a barrel roll';
+    const linkEl = element.$.output.childNodes[0];
+    assert.equal(linkEl.getAttribute('href'),
+        'https://google.com/search?q=do a barrel roll');
+    assert.equal(linkEl.textContent, 'do a barrel roll');
+  });
+
+  test('removing hash from links', () => {
+    element.content = 'hash:foo';
+    const linkEl = element.$.output.childNodes[0];
+    assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('html with base url', () => {
+    window.CANONICAL_PATH = '/r';
+
+    element.content = 'test foo';
+    const linkEl = element.$.output.childNodes[0];
+    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('a is not at start', () => {
+    window.CANONICAL_PATH = '/r';
+
+    element.content = 'a test foo';
+    const linkEl = element.$.output.childNodes[1];
+    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('hash html with base url', () => {
+    window.CANONICAL_PATH = '/r';
+
+    element.content = 'hash:foo';
+    const linkEl = element.$.output.childNodes[0];
+    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
+    assert.equal(linkEl.textContent, 'foo');
+  });
+
+  test('disabled config', () => {
+    element.content = 'foo:baz';
+    assert.equal(element.$.output.innerHTML, 'foo:baz');
+  });
+
+  test('R=email labels link correctly', () => {
+    element.removeZeroWidthSpace = true;
+    element.content = 'R=\u200Btest@google.com';
+    assert.equal(element.$.output.textContent, 'R=test@google.com');
+    assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
+  });
+
+  test('CC=email labels link correctly', () => {
+    element.removeZeroWidthSpace = true;
+    element.content = 'CC=\u200Btest@google.com';
+    assert.equal(element.$.output.textContent, 'CC=test@google.com');
+    assert.equal(element.$.output.innerHTML.match(/(CC=<a)/g).length, 1);
+  });
+
+  test('only {http,https,mailto} protocols are linkified', () => {
+    element.content = 'xx mailto:test@google.com yy';
+    let links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
+
+    element.content = 'xx http://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'http://google.com');
+    assert.equal(links[0].innerHTML, 'http://google.com');
+
+    element.content = 'xx https://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'https://google.com');
+    assert.equal(links[0].innerHTML, 'https://google.com');
+
+    element.content = 'xx ssh://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 0);
+
+    element.content = 'xx ftp://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 0);
+  });
+
+  test('links without leading whitespace are linkified', () => {
+    element.content = 'xx abcmailto:test@google.com yy';
+    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx abc');
+    let links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
+
+    element.content = 'xx defhttp://google.com yy';
+    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx def');
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'http://google.com');
+    assert.equal(links[0].innerHTML, 'http://google.com');
+
+    element.content = 'xx qwehttps://google.com yy';
+    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx qwe');
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'https://google.com');
+    assert.equal(links[0].innerHTML, 'https://google.com');
+
+    // Non-latin character
+    element.content = 'xx абвhttps://google.com yy';
+    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx абв');
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 1);
+    assert.equal(links[0].getAttribute('href'), 'https://google.com');
+    assert.equal(links[0].innerHTML, 'https://google.com');
+
+    element.content = 'xx ssh://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 0);
+
+    element.content = 'xx ftp://google.com yy';
+    links = element.$.output.querySelectorAll('a');
+    assert.equal(links.length, 0);
+  });
+
+  test('overlapping links', () => {
+    element.config = {
+      b1: {
+        match: '(B:\\s*)(\\d+)',
+        html: '$1<a href="ftp://foo/$2">$2</a>',
+      },
+      b2: {
+        match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
+        html: '$1<a href="ftp://foo/$2">$2</a>',
+      },
+    };
+    element.content = '- B: 123, 45';
+    const links = dom(element.root).querySelectorAll('a');
+
+    assert.equal(links.length, 2);
+    assert.equal(element.shadowRoot
+        .querySelector('span').textContent, '- B: 123, 45');
+
+    assert.equal(links[0].href, 'ftp://foo/123');
+    assert.equal(links[0].textContent, '123');
+
+    assert.equal(links[1].href, 'ftp://foo/45');
+    assert.equal(links[1].textContent, '45');
+  });
+
+  test('_contentOrConfigChanged called with config', () => {
+    const contentStub = sinon.stub(element, '_contentChanged');
+    const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
+    element.content = 'some text';
+    assert.isTrue(contentStub.called);
+    assert.isTrue(contentConfigStub.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
deleted file mode 100644
index 6f8a88a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ /dev/null
@@ -1,356 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-
-/**
- * Pattern describing URLs with supported protocols.
- *
- * @type {RegExp}
- */
-const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
-
-/**
- * Construct a parser for linkifying text. Will linkify plain URLs that appear
- * in the text as well as custom links if any are specified in the linkConfig
- * parameter.
- *
- * @constructor
- * @param {Object|null|undefined} linkConfig Comment links as specified by the
- *     commentlinks field on a project config.
- * @param {Function} callback The callback to be fired when an intermediate
- *     parse result is emitted. The callback is passed text and href strings
- *     if a link is to be created, or a document fragment otherwise.
- * @param {boolean|undefined} opt_removeZeroWidthSpace If true, zero-width
- *     spaces will be removed from R=<email> and CC=<email> expressions.
- */
-export function GrLinkTextParser(linkConfig, callback,
-    opt_removeZeroWidthSpace) {
-  this.linkConfig = linkConfig;
-  this.callback = callback;
-  this.removeZeroWidthSpace = opt_removeZeroWidthSpace;
-  this.baseUrl = BaseUrlBehavior.getBaseUrl();
-  Object.preventExtensions(this);
-}
-
-/**
- * Emit a callback to create a link element.
- *
- * @param {string} text The text of the link.
- * @param {string} href The URL to use as the href of the link.
- */
-GrLinkTextParser.prototype.addText = function(text, href) {
-  if (!text) { return; }
-  this.callback(text, href);
-};
-
-/**
- * Given the source text and a list of CommentLinkItem objects that were
- * generated by the commentlinks config, emit parsing callbacks.
- *
- * @param {string} text The chuml of source text over which the outputArray
- *     items range.
- * @param {!Array<Gerrit.CommentLinkItem>} outputArray The list of items to add
- *     resulting from commentlink matches.
- */
-GrLinkTextParser.prototype.processLinks = function(text, outputArray) {
-  this.sortArrayReverse(outputArray);
-  const fragment = document.createDocumentFragment();
-  let cursor = text.length;
-
-  // Start inserting linkified URLs from the end of the String. That way, the
-  // string positions of the items don't change as we iterate through.
-  outputArray.forEach(item => {
-    // Add any text between the current linkified item and the item added
-    // before if it exists.
-    if (item.position + item.length !== cursor) {
-      fragment.insertBefore(
-          document.createTextNode(
-              text.slice(item.position + item.length, cursor)),
-          fragment.firstChild);
-    }
-    fragment.insertBefore(item.html, fragment.firstChild);
-    cursor = item.position;
-  });
-
-  // Add the beginning portion at the end.
-  if (cursor !== 0) {
-    fragment.insertBefore(
-        document.createTextNode(text.slice(0, cursor)), fragment.firstChild);
-  }
-
-  this.callback(null, null, fragment);
-};
-
-/**
- * Sort the given array of CommentLinkItems such that the positions are in
- * reverse order.
- *
- * @param {!Array<Gerrit.CommentLinkItem>} outputArray
- */
-GrLinkTextParser.prototype.sortArrayReverse = function(outputArray) {
-  outputArray.sort((a, b) => b.position - a.position);
-};
-
-/**
- * Create a CommentLinkItem and append it to the given output array. This
- * method can be called in either of two ways:
- * - With `text` and `href` parameters provided, and the `html` parameter
- *   passed as `null`. In this case, the new CommentLinkItem will be a link
- *   element with the given text and href value.
- * - With the `html` paremeter provided, and the `text` and `href` parameters
- *   passed as `null`. In this case, the string of HTML will be parsed and the
- *   first resulting node will be used as the resulting content.
- *
- * @param {string|null} text The text to use if creating a link.
- * @param {string|null} href The href to use as the URL if creating a link.
- * @param {string|null} html The html to parse and use as the result.
- * @param {number} position The position inside the source text where the item
- *     starts.
- * @param {number} length The number of characters in the source text
- *     represented by the item.
- * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
- *     new item is to be appended.
- */
-GrLinkTextParser.prototype.addItem =
-    function(text, href, html, position, length, outputArray) {
-      let htmlOutput = '';
-
-      if (href) {
-        const a = document.createElement('a');
-        a.href = href;
-        a.textContent = text;
-        a.target = '_blank';
-        a.rel = 'noopener';
-        htmlOutput = a;
-      } else if (html) {
-        const fragment = document.createDocumentFragment();
-        // Create temporary div to hold the nodes in.
-        const div = document.createElement('div');
-        div.innerHTML = html;
-        while (div.firstChild) {
-          fragment.appendChild(div.firstChild);
-        }
-        htmlOutput = fragment;
-      }
-
-      outputArray.push({
-        html: htmlOutput,
-        position,
-        length,
-      });
-    };
-
-/**
- * Create a CommentLinkItem for a link and append it to the given output
- * array.
- *
- * @param {string|null} text The text for the link.
- * @param {string|null} href The href to use as the URL of the link.
- * @param {number} position The position inside the source text where the link
- *     starts.
- * @param {number} length The number of characters in the source text
- *     represented by the link.
- * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
- *     new item is to be appended.
- */
-GrLinkTextParser.prototype.addLink =
-    function(text, href, position, length, outputArray) {
-      if (!text || this.hasOverlap(position, length, outputArray)) { return; }
-      if (!!this.baseUrl && href.startsWith('/') &&
-           !href.startsWith(this.baseUrl)) {
-        href = this.baseUrl + href;
-      }
-      this.addItem(text, href, null, position, length, outputArray);
-    };
-
-/**
- * Create a CommentLinkItem specified by an HTMl string and append it to the
- * given output array.
- *
- * @param {string|null} html The html to parse and use as the result.
- * @param {number} position The position inside the source text where the item
- *     starts.
- * @param {number} length The number of characters in the source text
- *     represented by the item.
- * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
- *     new item is to be appended.
- */
-GrLinkTextParser.prototype.addHTML =
-    function(html, position, length, outputArray) {
-      if (this.hasOverlap(position, length, outputArray)) { return; }
-      if (!!this.baseUrl && html.match(/<a href=\"\//g) &&
-           !new RegExp(`<a href="${this.baseUrl}`, 'g').test(html)) {
-        html = html.replace(/<a href=\"\//g, `<a href=\"${this.baseUrl}\/`);
-      }
-      this.addItem(null, null, html, position, length, outputArray);
-    };
-
-/**
- * Does the given range overlap with anything already in the item list.
- *
- * @param {number} position
- * @param {number} length
- * @param {!Array<Gerrit.CommentLinkItem>} outputArray
- */
-GrLinkTextParser.prototype.hasOverlap =
-    function(position, length, outputArray) {
-      const endPosition = position + length;
-      for (let i = 0; i < outputArray.length; i++) {
-        const arrayItemStart = outputArray[i].position;
-        const arrayItemEnd = outputArray[i].position + outputArray[i].length;
-        if ((position >= arrayItemStart && position < arrayItemEnd) ||
-      (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
-      (position === arrayItemStart && position === arrayItemEnd)) {
-          return true;
-        }
-      }
-      return false;
-    };
-
-/**
- * Parse the given source text and emit callbacks for the items that are
- * parsed.
- *
- * @param {string} text
- */
-GrLinkTextParser.prototype.parse = function(text) {
-  if (text) {
-    linkify(text, {
-      callback: this.parseChunk.bind(this),
-    });
-  }
-};
-
-/**
- * Callback that is pased into the linkify function. ba-linkify will call this
- * method in either of two ways:
- * - With both a `text` and `href` parameter provided: this indicates that
- *   ba-linkify has found a plain URL and wants it linkified.
- * - With only a `text` parameter provided: this represents the non-link
- *   content that lies between the links the library has found.
- *
- * @param {string} text
- * @param {string|null|undefined} href
- */
-GrLinkTextParser.prototype.parseChunk = function(text, href) {
-  // TODO(wyatta) switch linkify sequence, see issue 5526.
-  if (this.removeZeroWidthSpace) {
-    // Remove the zero-width space added in gr-change-view.
-    text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
-  }
-
-  // If the href is provided then ba-linkify has recognized it as a URL. If
-  // the source text does not include a protocol, the protocol will be added
-  // by ba-linkify. Create the link if the href is provided and its protocol
-  // matches the expected pattern.
-  if (href) {
-    const result = URL_PROTOCOL_PATTERN.exec(href);
-    if (result) {
-      const prefixText = result[1];
-      if (prefixText.length > 0) {
-        // Fix for simple cases from
-        // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
-        // When leading whitespace is missed before link,
-        // linkify add this text before link as a schema name to href.
-        // We suppose, that prefixText just a single word
-        // before link and add this word as is, without processing
-        // any patterns in it.
-        this.parseLinks(prefixText, []);
-        text = text.substring(prefixText.length);
-        href = href.substring(prefixText.length);
-      }
-      this.addText(text, href);
-      return;
-    }
-  }
-  // For the sections of text that lie between the links found by
-  // ba-linkify, we search for the project-config-specified link patterns.
-  this.parseLinks(text, this.linkConfig);
-};
-
-/**
- * Walk over the given source text to find matches for comemntlink patterns
- * and emit parse result callbacks.
- *
- * @param {string} text The raw source text.
- * @param {Object|null|undefined} patterns A comment links specification
- *   object.
- */
-GrLinkTextParser.prototype.parseLinks = function(text, patterns) {
-  // The outputArray is used to store all of the matches found for all
-  // patterns.
-  const outputArray = [];
-  for (const p in patterns) {
-    if (patterns[p].enabled != null && patterns[p].enabled == false) {
-      continue;
-    }
-    // PolyGerrit doesn't use hash-based navigation like the GWT UI.
-    // Account for this.
-    if (patterns[p].html) {
-      patterns[p].html =
-          patterns[p].html.replace(/<a href=\"#\//g, '<a href="/');
-    } else if (patterns[p].link) {
-      if (patterns[p].link[0] == '#') {
-        patterns[p].link = patterns[p].link.substr(1);
-      }
-    }
-
-    const pattern = new RegExp(patterns[p].match, 'g');
-
-    let match;
-    let textToCheck = text;
-    let susbtrIndex = 0;
-
-    while ((match = pattern.exec(textToCheck)) != null) {
-      textToCheck = textToCheck.substr(match.index + match[0].length);
-      let result = match[0].replace(pattern,
-          patterns[p].html || patterns[p].link);
-
-      if (patterns[p].html) {
-        let i;
-        // Skip portion of replacement string that is equal to original to
-        // allow overlapping patterns.
-        for (i = 0; i < result.length; i++) {
-          if (result[i] !== match[0][i]) { break; }
-        }
-        result = result.slice(i);
-
-        this.addHTML(
-            result,
-            susbtrIndex + match.index + i,
-            match[0].length - i,
-            outputArray);
-      } else if (patterns[p].link) {
-        this.addLink(
-            match[0],
-            result,
-            susbtrIndex + match.index,
-            match[0].length,
-            outputArray);
-      } else {
-        throw Error('linkconfig entry ' + p +
-            ' doesn’t contain a link or html attribute.');
-      }
-
-      // Update the substring location so we know where we are in relation to
-      // the initial full text string.
-      susbtrIndex = susbtrIndex + match.index + match[0].length;
-    }
-  }
-  this.processLinks(text, outputArray);
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
new file mode 100644
index 0000000..8993ad3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
@@ -0,0 +1,431 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {getBaseUrl} from '../../../utils/url-util';
+
+/**
+ * Pattern describing URLs with supported protocols.
+ */
+const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
+
+export type LinkTextParserCallback = ((text: string, href: string) => void) &
+  ((text: null, href: null, fragment: DocumentFragment) => void);
+
+export interface CommentLinkItem {
+  position: number;
+  length: number;
+  html: HTMLAnchorElement | DocumentFragment;
+}
+
+export interface Pattern {
+  enabled: boolean | null;
+  match: string;
+  html?: string;
+  link?: string;
+}
+
+export class GrLinkTextParser {
+  private readonly baseUrl = getBaseUrl();
+
+  /**
+   * Construct a parser for linkifying text. Will linkify plain URLs that appear
+   * in the text as well as custom links if any are specified in the linkConfig
+   * parameter.
+   *
+   * @constructor
+   * @param linkConfig Comment links as specified by the commentlinks field on a
+   *     project config.
+   * @param callback The callback to be fired when an intermediate parse result
+   *     is emitted. The callback is passed text and href strings if a link is to
+   *     be created, or a document fragment otherwise.
+   * @param removeZeroWidthSpace If true, zero-width spaces will be removed from
+   *     R=<email> and CC=<email> expressions.
+   */
+  constructor(
+    private readonly linkConfig: Pattern[],
+    private readonly callback: LinkTextParserCallback,
+    private readonly removeZeroWidthSpace?: boolean
+  ) {
+    Object.preventExtensions(this);
+  }
+
+  /**
+   * Emit a callback to create a link element.
+   *
+   * @param text The text of the link.
+   * @param href The URL to use as the href of the link.
+   */
+  addText(text: string, href: string) {
+    if (!text) {
+      return;
+    }
+    this.callback(text, href);
+  }
+
+  /**
+   * Given the source text and a list of CommentLinkItem objects that were
+   * generated by the commentlinks config, emit parsing callbacks.
+   *
+   * @param text The chuml of source text over which the outputArray items range.
+   * @param outputArray The list of items to add resulting from commentlink
+   *     matches.
+   */
+  processLinks(text: string, outputArray: CommentLinkItem[]) {
+    this.sortArrayReverse(outputArray);
+    const fragment = document.createDocumentFragment();
+    let cursor = text.length;
+
+    // Start inserting linkified URLs from the end of the String. That way, the
+    // string positions of the items don't change as we iterate through.
+    outputArray.forEach(item => {
+      // Add any text between the current linkified item and the item added
+      // before if it exists.
+      if (item.position + item.length !== cursor) {
+        fragment.insertBefore(
+          document.createTextNode(
+            text.slice(item.position + item.length, cursor)
+          ),
+          fragment.firstChild
+        );
+      }
+      fragment.insertBefore(item.html, fragment.firstChild);
+      cursor = item.position;
+    });
+
+    // Add the beginning portion at the end.
+    if (cursor !== 0) {
+      fragment.insertBefore(
+        document.createTextNode(text.slice(0, cursor)),
+        fragment.firstChild
+      );
+    }
+
+    this.callback(null, null, fragment);
+  }
+
+  /**
+   * Sort the given array of CommentLinkItems such that the positions are in
+   * reverse order.
+   */
+  sortArrayReverse(outputArray: CommentLinkItem[]) {
+    outputArray.sort((a, b) => b.position - a.position);
+  }
+
+  addItem(
+    text: string,
+    href: string,
+    html: null,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ): void;
+
+  addItem(
+    text: null,
+    href: null,
+    html: string,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ): void;
+
+  /**
+   * Create a CommentLinkItem and append it to the given output array. This
+   * method can be called in either of two ways:
+   * - With `text` and `href` parameters provided, and the `html` parameter
+   *   passed as `null`. In this case, the new CommentLinkItem will be a link
+   *   element with the given text and href value.
+   * - With the `html` paremeter provided, and the `text` and `href` parameters
+   *   passed as `null`. In this case, the string of HTML will be parsed and the
+   *   first resulting node will be used as the resulting content.
+   *
+   * @param text The text to use if creating a link.
+   * @param href The href to use as the URL if creating a link.
+   * @param html The html to parse and use as the result.
+   * @param  position The position inside the source text where the item
+   *     starts.
+   * @param length The number of characters in the source text
+   *     represented by the item.
+   * @param outputArray The array to which the
+   *     new item is to be appended.
+   */
+  addItem(
+    text: string | null,
+    href: string | null,
+    html: string | null,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ): void {
+    if (href) {
+      const a = document.createElement('a');
+      a.setAttribute('href', href);
+      a.textContent = text;
+      a.target = '_blank';
+      a.rel = 'noopener';
+      outputArray.push({
+        html: a,
+        position,
+        length,
+      });
+    } else if (html) {
+      // addItem has 2 overloads. If href is null, then html
+      // can't be null.
+      // TODO(TS): remove if(html) and keep else block without condition
+      const fragment = document.createDocumentFragment();
+      // Create temporary div to hold the nodes in.
+      const div = document.createElement('div');
+      div.innerHTML = html;
+      while (div.firstChild) {
+        fragment.appendChild(div.firstChild);
+      }
+      outputArray.push({
+        html: fragment,
+        position,
+        length,
+      });
+    }
+  }
+
+  /**
+   * Create a CommentLinkItem for a link and append it to the given output
+   * array.
+   *
+   * @param text The text for the link.
+   * @param href The href to use as the URL of the link.
+   * @param position The position inside the source text where the link
+   *     starts.
+   * @param length The number of characters in the source text
+   *     represented by the link.
+   * @param outputArray The array to which the
+   *     new item is to be appended.
+   */
+  addLink(
+    text: string,
+    href: string,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ) {
+    // TODO(TS): remove !test condition
+    if (!text || this.hasOverlap(position, length, outputArray)) {
+      return;
+    }
+    if (
+      !!this.baseUrl &&
+      href.startsWith('/') &&
+      !href.startsWith(this.baseUrl)
+    ) {
+      href = this.baseUrl + href;
+    }
+    this.addItem(text, href, null, position, length, outputArray);
+  }
+
+  /**
+   * Create a CommentLinkItem specified by an HTMl string and append it to the
+   * given output array.
+   *
+   * @param html The html to parse and use as the result.
+   * @param position The position inside the source text where the item
+   *     starts.
+   * @param length The number of characters in the source text
+   *     represented by the item.
+   * @param outputArray The array to which the
+   *     new item is to be appended.
+   */
+  addHTML(
+    html: string,
+    position: number,
+    length: number,
+    outputArray: CommentLinkItem[]
+  ) {
+    if (this.hasOverlap(position, length, outputArray)) {
+      return;
+    }
+    if (
+      !!this.baseUrl &&
+      html.match(/<a href="\//g) &&
+      !new RegExp(`<a href="${this.baseUrl}`, 'g').test(html)
+    ) {
+      html = html.replace(/<a href="\//g, `<a href="${this.baseUrl}/`);
+    }
+    this.addItem(null, null, html, position, length, outputArray);
+  }
+
+  /**
+   * Does the given range overlap with anything already in the item list.
+   */
+  hasOverlap(position: number, length: number, outputArray: CommentLinkItem[]) {
+    const endPosition = position + length;
+    for (let i = 0; i < outputArray.length; i++) {
+      const arrayItemStart = outputArray[i].position;
+      const arrayItemEnd = outputArray[i].position + outputArray[i].length;
+      if (
+        (position >= arrayItemStart && position < arrayItemEnd) ||
+        (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
+        (position === arrayItemStart && position === arrayItemEnd)
+      ) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Parse the given source text and emit callbacks for the items that are
+   * parsed.
+   */
+  parse(text?: string) {
+    if (text) {
+      window.linkify(text, {
+        callback: this.parseChunk.bind(this),
+      });
+    }
+  }
+
+  /**
+   * Callback that is pased into the linkify function. ba-linkify will call this
+   * method in either of two ways:
+   * - With both a `text` and `href` parameter provided: this indicates that
+   *   ba-linkify has found a plain URL and wants it linkified.
+   * - With only a `text` parameter provided: this represents the non-link
+   *   content that lies between the links the library has found.
+   *
+   */
+  parseChunk(text: string, href?: string) {
+    // TODO(wyatta) switch linkify sequence, see issue 5526.
+    if (this.removeZeroWidthSpace) {
+      // Remove the zero-width space added in gr-change-view.
+      text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
+    }
+
+    // If the href is provided then ba-linkify has recognized it as a URL. If
+    // the source text does not include a protocol, the protocol will be added
+    // by ba-linkify. Create the link if the href is provided and its protocol
+    // matches the expected pattern.
+    if (href) {
+      const result = URL_PROTOCOL_PATTERN.exec(href);
+      if (result) {
+        const prefixText = result[1];
+        if (prefixText.length > 0) {
+          // Fix for simple cases from
+          // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
+          // When leading whitespace is missed before link,
+          // linkify add this text before link as a schema name to href.
+          // We suppose, that prefixText just a single word
+          // before link and add this word as is, without processing
+          // any patterns in it.
+          this.parseLinks(prefixText, []);
+          text = text.substring(prefixText.length);
+          href = href.substring(prefixText.length);
+        }
+        this.addText(text, href);
+        return;
+      }
+    }
+    // For the sections of text that lie between the links found by
+    // ba-linkify, we search for the project-config-specified link patterns.
+    this.parseLinks(text, this.linkConfig);
+  }
+
+  /**
+   * Walk over the given source text to find matches for comemntlink patterns
+   * and emit parse result callbacks.
+   *
+   * @param text The raw source text.
+   * @param patterns A comment links specification object.
+   */
+  parseLinks(text: string, patterns: Pattern[]) {
+    // The outputArray is used to store all of the matches found for all
+    // patterns.
+    const outputArray: CommentLinkItem[] = [];
+    for (const p in patterns) {
+      // TODO(TS): it seems, the following line can be rewritten as:
+      // if(enabled === false || enabled === 0 || enabled === '')
+      // Should be double-checked before update
+      // eslint-disable-next-line eqeqeq
+      if (patterns[p].enabled != null && patterns[p].enabled == false) {
+        continue;
+      }
+      // PolyGerrit doesn't use hash-based navigation like the GWT UI.
+      // Account for this.
+      const html = patterns[p].html;
+      const link = patterns[p].link;
+      if (html) {
+        patterns[p].html = html.replace(/<a href="#\//g, '<a href="/');
+      } else if (link) {
+        if (link[0] === '#') {
+          patterns[p].link = link.substr(1);
+        }
+      }
+
+      const pattern = new RegExp(patterns[p].match, 'g');
+
+      let match;
+      let textToCheck = text;
+      let susbtrIndex = 0;
+
+      while ((match = pattern.exec(textToCheck))) {
+        textToCheck = textToCheck.substr(match.index + match[0].length);
+        let result = match[0].replace(
+          pattern,
+          // Either html or link has a value. Otherwise an exception is thrown
+          // in the code below.
+          (patterns[p].html || patterns[p].link)!
+        );
+
+        if (patterns[p].html) {
+          let i;
+          // Skip portion of replacement string that is equal to original to
+          // allow overlapping patterns.
+          for (i = 0; i < result.length; i++) {
+            if (result[i] !== match[0][i]) {
+              break;
+            }
+          }
+          result = result.slice(i);
+
+          this.addHTML(
+            result,
+            susbtrIndex + match.index + i,
+            match[0].length - i,
+            outputArray
+          );
+        } else if (patterns[p].link) {
+          this.addLink(
+            match[0],
+            result,
+            susbtrIndex + match.index,
+            match[0].length,
+            outputArray
+          );
+        } else {
+          throw Error(
+            'linkconfig entry ' +
+              p +
+              ' doesn’t contain a link or html attribute.'
+          );
+        }
+
+        // Update the substring location so we know where we are in relation to
+        // the initial full text string.
+        susbtrIndex = susbtrIndex + match.index + match[0].length;
+      }
+    }
+    this.processLinks(text, outputArray);
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
index 7a44c67..94fc135 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
@@ -14,32 +14,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-input/iron-input.js';
 import '@polymer/iron-icon/iron-icon.js';
 import '../../../styles/shared-styles.js';
 import '../gr-button/gr-button.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-list-view_html.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util.js';
 import page from 'page/page.mjs';
 
 const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrListView extends mixinBehaviors( [
-  BaseUrlBehavior,
-  URLEncodingBehavior,
-], GestureEventListeners(
+class GrListView extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-list-view'; }
@@ -77,7 +70,7 @@
     this.debounce('reload', () => {
       if (filter) {
         return page.show(`${this.path}/q/filter:` +
-            this.encodeURL(filter, false));
+            encodeURL(filter, false));
       }
       page.show(this.path);
     }, REQUEST_DEBOUNCE_INTERVAL_MS);
@@ -93,9 +86,9 @@
     // Offset could be a string when passed from the router.
     offset = +(offset || 0);
     const newOffset = Math.max(0, offset + (itemsPerPage * direction));
-    let href = this.getBaseUrl() + path;
+    let href = getBaseUrl() + path;
     if (filter) {
-      href += '/q/filter:' + this.encodeURL(filter, false);
+      href += '/q/filter:' + encodeURL(filter, false);
     }
     if (newOffset > 0) {
       href += ',' + newOffset;
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.js
deleted file mode 100644
index ff73d4d8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    #filter {
-      max-width: 25em;
-    }
-    #filter:focus {
-      outline: none;
-    }
-    #topContainer {
-      align-items: center;
-      display: flex;
-      height: 3rem;
-      justify-content: space-between;
-      margin: 0 var(--spacing-l);
-    }
-    #createNewContainer:not(.show) {
-      display: none;
-    }
-    a {
-      color: var(--primary-text-color);
-      text-decoration: none;
-    }
-    a:hover {
-      text-decoration: underline;
-    }
-    nav {
-      align-items: center;
-      display: flex;
-      height: 3rem;
-      justify-content: flex-end;
-      margin-right: 20px;
-    }
-    nav,
-    iron-icon {
-      color: var(--deemphasized-text-color);
-    }
-    iron-icon {
-      height: 1.85rem;
-      margin-left: 16px;
-      width: 1.85rem;
-    }
-  </style>
-  <div id="topContainer">
-    <div class="filterContainer">
-      <label>Filter:</label>
-      <iron-input type="text" bind-value="{{filter}}">
-        <input
-          is="iron-input"
-          type="text"
-          id="filter"
-          bind-value="{{filter}}"
-        />
-      </iron-input>
-    </div>
-    <div id="createNewContainer" class$="[[_computeCreateClass(createNew)]]">
-      <gr-button primary="" link="" id="createNew" on-click="_createNewItem">
-        Create New
-      </gr-button>
-    </div>
-  </div>
-  <slot></slot>
-  <nav>
-    Page [[_computePage(offset, itemsPerPage)]]
-    <a
-      id="prevArrow"
-      href$="[[_computeNavLink(offset, -1, itemsPerPage, filter, path)]]"
-      hidden$="[[_hidePrevArrow(loading, offset)]]"
-      hidden=""
-    >
-      <iron-icon icon="gr-icons:chevron-left"></iron-icon>
-    </a>
-    <a
-      id="nextArrow"
-      href$="[[_computeNavLink(offset, 1, itemsPerPage, filter, path)]]"
-      hidden$="[[_hideNextArrow(loading, items)]]"
-      hidden=""
-    >
-      <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-    </a>
-  </nav>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.ts
new file mode 100644
index 0000000..75ee667
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.ts
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    #filter {
+      max-width: 25em;
+    }
+    #filter:focus {
+      outline: none;
+    }
+    #topContainer {
+      align-items: center;
+      display: flex;
+      height: 3rem;
+      justify-content: space-between;
+      margin: 0 var(--spacing-l);
+    }
+    #createNewContainer:not(.show) {
+      display: none;
+    }
+    a {
+      color: var(--primary-text-color);
+      text-decoration: none;
+    }
+    a:hover {
+      text-decoration: underline;
+    }
+    nav {
+      align-items: center;
+      display: flex;
+      height: 3rem;
+      justify-content: flex-end;
+      margin-right: 20px;
+    }
+    nav,
+    iron-icon {
+      color: var(--deemphasized-text-color);
+    }
+    iron-icon {
+      height: 1.85rem;
+      margin-left: 16px;
+      width: 1.85rem;
+    }
+  </style>
+  <div id="topContainer">
+    <div class="filterContainer">
+      <label>Filter:</label>
+      <iron-input type="text" bind-value="{{filter}}">
+        <input
+          is="iron-input"
+          type="text"
+          id="filter"
+          bind-value="{{filter}}"
+        />
+      </iron-input>
+    </div>
+    <div id="createNewContainer" class$="[[_computeCreateClass(createNew)]]">
+      <gr-button primary="" link="" id="createNew" on-click="_createNewItem">
+        Create New
+      </gr-button>
+    </div>
+  </div>
+  <slot></slot>
+  <nav>
+    Page [[_computePage(offset, itemsPerPage)]]
+    <a
+      id="prevArrow"
+      href$="[[_computeNavLink(offset, -1, itemsPerPage, filter, path)]]"
+      hidden$="[[_hidePrevArrow(loading, offset)]]"
+      hidden=""
+    >
+      <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+    </a>
+    <a
+      id="nextArrow"
+      href$="[[_computeNavLink(offset, 1, itemsPerPage, filter, path)]]"
+      hidden$="[[_hideNextArrow(loading, items)]]"
+      hidden=""
+    >
+      <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+    </a>
+  </nav>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
deleted file mode 100644
index 55aab82..0000000
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
+++ /dev/null
@@ -1,166 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-list-view</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-list-view></gr-list-view>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-list-view.js';
-import page from 'page/page.mjs';
-
-suite('gr-list-view tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('_computeNavLink', () => {
-    const offset = 25;
-    const projectsPerPage = 25;
-    let filter = 'test';
-    const path = '/admin/projects';
-
-    sandbox.stub(element, 'getBaseUrl', () => '');
-
-    assert.equal(
-        element._computeNavLink(offset, 1, projectsPerPage, filter, path),
-        '/admin/projects/q/filter:test,50');
-
-    assert.equal(
-        element._computeNavLink(offset, -1, projectsPerPage, filter, path),
-        '/admin/projects/q/filter:test');
-
-    assert.equal(
-        element._computeNavLink(offset, 1, projectsPerPage, null, path),
-        '/admin/projects,50');
-
-    assert.equal(
-        element._computeNavLink(offset, -1, projectsPerPage, null, path),
-        '/admin/projects');
-
-    filter = 'plugins/';
-    assert.equal(
-        element._computeNavLink(offset, 1, projectsPerPage, filter, path),
-        '/admin/projects/q/filter:plugins%252F,50');
-  });
-
-  test('_onValueChange', done => {
-    element.path = '/admin/projects';
-    sandbox.stub(page, 'show', url => {
-      assert.equal(url, '/admin/projects/q/filter:test');
-      done();
-    });
-    element.filter = 'test';
-  });
-
-  test('_filterChanged not reload when swap between falsy values', () => {
-    sandbox.stub(element, '_debounceReload');
-    element.filter = null;
-    element.filter = undefined;
-    element.filter = '';
-    assert.isFalse(element._debounceReload.called);
-  });
-
-  test('next button', done => {
-    element.itemsPerPage = 25;
-    let projects = new Array(26);
-
-    flush(() => {
-      let loading;
-      assert.isFalse(element._hideNextArrow(loading, projects));
-      loading = true;
-      assert.isTrue(element._hideNextArrow(loading, projects));
-      loading = false;
-      assert.isFalse(element._hideNextArrow(loading, projects));
-      element._projects = [];
-      assert.isTrue(element._hideNextArrow(loading, element._projects));
-      projects = new Array(4);
-      assert.isTrue(element._hideNextArrow(loading, projects));
-      done();
-    });
-  });
-
-  test('prev button', () => {
-    assert.isTrue(element._hidePrevArrow(true, 0));
-    flush(() => {
-      let offset = 0;
-      assert.isTrue(element._hidePrevArrow(false, offset));
-      offset = 5;
-      assert.isFalse(element._hidePrevArrow(false, offset));
-    });
-  });
-
-  test('createNew link appears correctly', () => {
-    assert.isFalse(element.shadowRoot
-        .querySelector('#createNewContainer').classList
-        .contains('show'));
-    element.createNew = true;
-    flushAsynchronousOperations();
-    assert.isTrue(element.shadowRoot
-        .querySelector('#createNewContainer').classList
-        .contains('show'));
-  });
-
-  test('fires create clicked event when button tapped', () => {
-    const clickHandler = sandbox.stub();
-    element.addEventListener('create-clicked', clickHandler);
-    element.createNew = true;
-    flushAsynchronousOperations();
-    MockInteractions.tap(element.shadowRoot.querySelector('#createNew'));
-    assert.isTrue(clickHandler.called);
-  });
-
-  test('next/prev links change when path changes', () => {
-    const BRANCHES_PATH = '/path/to/branches';
-    const TAGS_PATH = '/path/to/tags';
-    sandbox.stub(element, '_computeNavLink');
-    element.offset = 0;
-    element.itemsPerPage = 25;
-    element.filter = '';
-    element.path = BRANCHES_PATH;
-    assert.equal(element._computeNavLink.lastCall.args[4], BRANCHES_PATH);
-    element.path = TAGS_PATH;
-    assert.equal(element._computeNavLink.lastCall.args[4], TAGS_PATH);
-  });
-
-  test('_computePage', () => {
-    assert.equal(element._computePage(0, 25), 1);
-    assert.equal(element._computePage(50, 25), 3);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
new file mode 100644
index 0000000..7782629
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
@@ -0,0 +1,146 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-list-view.js';
+import page from 'page/page.mjs';
+import {stubBaseUrl} from '../../../test/test-utils.js';
+
+const basicFixture = fixtureFromElement('gr-list-view');
+
+suite('gr-list-view tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_computeNavLink', () => {
+    const offset = 25;
+    const projectsPerPage = 25;
+    let filter = 'test';
+    const path = '/admin/projects';
+
+    stubBaseUrl('');
+
+    assert.equal(
+        element._computeNavLink(offset, 1, projectsPerPage, filter, path),
+        '/admin/projects/q/filter:test,50');
+
+    assert.equal(
+        element._computeNavLink(offset, -1, projectsPerPage, filter, path),
+        '/admin/projects/q/filter:test');
+
+    assert.equal(
+        element._computeNavLink(offset, 1, projectsPerPage, null, path),
+        '/admin/projects,50');
+
+    assert.equal(
+        element._computeNavLink(offset, -1, projectsPerPage, null, path),
+        '/admin/projects');
+
+    filter = 'plugins/';
+    assert.equal(
+        element._computeNavLink(offset, 1, projectsPerPage, filter, path),
+        '/admin/projects/q/filter:plugins%252F,50');
+  });
+
+  test('_onValueChange', done => {
+    element.path = '/admin/projects';
+    sinon.stub(page, 'show').callsFake( url => {
+      assert.equal(url, '/admin/projects/q/filter:test');
+      done();
+    });
+    element.filter = 'test';
+  });
+
+  test('_filterChanged not reload when swap between falsy values', () => {
+    sinon.stub(element, '_debounceReload');
+    element.filter = null;
+    element.filter = undefined;
+    element.filter = '';
+    assert.isFalse(element._debounceReload.called);
+  });
+
+  test('next button', done => {
+    element.itemsPerPage = 25;
+    let projects = new Array(26);
+
+    flush(() => {
+      let loading;
+      assert.isFalse(element._hideNextArrow(loading, projects));
+      loading = true;
+      assert.isTrue(element._hideNextArrow(loading, projects));
+      loading = false;
+      assert.isFalse(element._hideNextArrow(loading, projects));
+      element._projects = [];
+      assert.isTrue(element._hideNextArrow(loading, element._projects));
+      projects = new Array(4);
+      assert.isTrue(element._hideNextArrow(loading, projects));
+      done();
+    });
+  });
+
+  test('prev button', () => {
+    assert.isTrue(element._hidePrevArrow(true, 0));
+    flush(() => {
+      let offset = 0;
+      assert.isTrue(element._hidePrevArrow(false, offset));
+      offset = 5;
+      assert.isFalse(element._hidePrevArrow(false, offset));
+    });
+  });
+
+  test('createNew link appears correctly', () => {
+    assert.isFalse(element.shadowRoot
+        .querySelector('#createNewContainer').classList
+        .contains('show'));
+    element.createNew = true;
+    flushAsynchronousOperations();
+    assert.isTrue(element.shadowRoot
+        .querySelector('#createNewContainer').classList
+        .contains('show'));
+  });
+
+  test('fires create clicked event when button tapped', () => {
+    const clickHandler = sinon.stub();
+    element.addEventListener('create-clicked', clickHandler);
+    element.createNew = true;
+    flushAsynchronousOperations();
+    MockInteractions.tap(element.shadowRoot.querySelector('#createNew'));
+    assert.isTrue(clickHandler.called);
+  });
+
+  test('next/prev links change when path changes', () => {
+    const BRANCHES_PATH = '/path/to/branches';
+    const TAGS_PATH = '/path/to/tags';
+    sinon.stub(element, '_computeNavLink');
+    element.offset = 0;
+    element.itemsPerPage = 25;
+    element.filter = '';
+    element.path = BRANCHES_PATH;
+    assert.equal(element._computeNavLink.lastCall.args[4], BRANCHES_PATH);
+    element.path = TAGS_PATH;
+    assert.equal(element._computeNavLink.lastCall.args[4], TAGS_PATH);
+  });
+
+  test('_computePage', () => {
+    assert.equal(element._computePage(0, 25), 1);
+    assert.equal(element._computePage(50, 25), 3);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
index a5a3fb4..083b802 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
@@ -14,28 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
-import {IronOverlayBehaviorImpl, IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior.js';
+import {IronOverlayBehaviorImpl} from '@polymer/iron-overlay-behavior/iron-overlay-behavior.js';
 import '../../../styles/shared-styles.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-overlay_html.js';
+import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin.js';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
 const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrOverlay extends mixinBehaviors( [
-  IronOverlayBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrOverlay extends IronOverlayMixin(GestureEventListeners(
+    LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-overlay'; }
@@ -62,14 +57,16 @@
 
   /** @override */
   created() {
+    this._boundHandleClose = () => this.close();
     super.created();
     this.addEventListener('iron-overlay-closed',
-        () => this._close());
+        () => this._overlayClosed());
     this.addEventListener('iron-overlay-cancelled',
-        () => this._close());
+        () => this._overlayClosed());
   }
 
   open(...args) {
+    window.addEventListener('popstate', this._boundHandleClose);
     return new Promise((resolve, reject) => {
       IronOverlayBehaviorImpl.open.apply(this, args);
       if (this._isMobile()) {
@@ -86,7 +83,9 @@
     return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
   }
 
-  _close() {
+  // called after iron-overlay is closed. Does not actually close the overlay
+  _overlayClosed() {
+    window.removeEventListener('popstate', this._boundHandleClose);
     if (this._fullScreenOpen) {
       this.dispatchEvent(new CustomEvent('fullscreen-overlay-closed', {
         composed: true, bubbles: true,
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.js
deleted file mode 100644
index 7123adb..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      background: var(--dialog-background-color);
-      border-radius: var(--border-radius);
-      box-shadow: var(--elevation-level-5);
-    }
-
-    @media screen and (max-width: 50em) {
-      :host {
-        height: 100%;
-        left: 0;
-        position: fixed;
-        right: 0;
-        top: 0;
-        border-radius: 0;
-        box-shadow: none;
-      }
-    }
-  </style>
-  <slot></slot>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
new file mode 100644
index 0000000..730eeac
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      background: var(--dialog-background-color);
+      border-radius: var(--border-radius);
+      box-shadow: var(--elevation-level-5);
+    }
+
+    @media screen and (max-width: 50em) {
+      :host {
+        height: 100%;
+        left: 0;
+        position: fixed;
+        right: 0;
+        top: 0;
+        border-radius: 0;
+        box-shadow: none;
+      }
+    }
+  </style>
+  <slot></slot>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
deleted file mode 100644
index d43c739..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
+++ /dev/null
@@ -1,91 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-overlay</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-overlay>
-      <div>content</div>
-    </gr-overlay>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-overlay.js';
-suite('gr-overlay tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('events are fired on fullscreen view', done => {
-    sandbox.stub(element, '_isMobile').returns(true);
-    const openHandler = sandbox.stub();
-    const closeHandler = sandbox.stub();
-    element.addEventListener('fullscreen-overlay-opened', openHandler);
-    element.addEventListener('fullscreen-overlay-closed', closeHandler);
-
-    element.open().then(() => {
-      assert.isTrue(element._isMobile.called);
-      assert.isTrue(element._fullScreenOpen);
-      assert.isTrue(openHandler.called);
-
-      element._close();
-      assert.isFalse(element._fullScreenOpen);
-      assert.isTrue(closeHandler.called);
-      done();
-    });
-  });
-
-  test('events are not fired on desktop view', done => {
-    sandbox.stub(element, '_isMobile').returns(false);
-    const openHandler = sandbox.stub();
-    const closeHandler = sandbox.stub();
-    element.addEventListener('fullscreen-overlay-opened', openHandler);
-    element.addEventListener('fullscreen-overlay-closed', closeHandler);
-
-    element.open().then(() => {
-      assert.isTrue(element._isMobile.called);
-      assert.isFalse(element._fullScreenOpen);
-      assert.isFalse(openHandler.called);
-
-      element._close();
-      assert.isFalse(element._fullScreenOpen);
-      assert.isFalse(closeHandler.called);
-      done();
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
new file mode 100644
index 0000000..7fcdd28
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
@@ -0,0 +1,88 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-overlay.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-overlay>
+      <div>content</div>
+    </gr-overlay>
+`);
+
+suite('gr-overlay tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('popstate listener is attached on open and removed on close', () => {
+    const addEventListenerStub = sinon.stub(window, 'addEventListener');
+    const removeEventListenerStub = sinon.stub(window, 'removeEventListener');
+    element.open();
+    assert.isTrue(addEventListenerStub.called);
+    assert.equal(addEventListenerStub.lastCall.args[0], 'popstate');
+    assert.equal(addEventListenerStub.lastCall.args[1],
+        element._boundHandleClose);
+    element._overlayClosed();
+    assert.isTrue(removeEventListenerStub.called);
+    assert.equal(removeEventListenerStub.lastCall.args[0], 'popstate');
+    assert.equal(removeEventListenerStub.lastCall.args[1],
+        element._boundHandleClose);
+  });
+
+  test('events are fired on fullscreen view', done => {
+    sinon.stub(element, '_isMobile').returns(true);
+    const openHandler = sinon.stub();
+    const closeHandler = sinon.stub();
+    element.addEventListener('fullscreen-overlay-opened', openHandler);
+    element.addEventListener('fullscreen-overlay-closed', closeHandler);
+
+    element.open().then(() => {
+      assert.isTrue(element._isMobile.called);
+      assert.isTrue(element._fullScreenOpen);
+      assert.isTrue(openHandler.called);
+
+      element._overlayClosed();
+      assert.isFalse(element._fullScreenOpen);
+      assert.isTrue(closeHandler.called);
+      done();
+    });
+  });
+
+  test('events are not fired on desktop view', done => {
+    sinon.stub(element, '_isMobile').returns(false);
+    const openHandler = sinon.stub();
+    const closeHandler = sinon.stub();
+    element.addEventListener('fullscreen-overlay-opened', openHandler);
+    element.addEventListener('fullscreen-overlay-closed', closeHandler);
+
+    element.open().then(() => {
+      assert.isTrue(element._isMobile.called);
+      assert.isFalse(element._fullScreenOpen);
+      assert.isFalse(openHandler.called);
+
+      element._overlayClosed();
+      assert.isFalse(element._fullScreenOpen);
+      assert.isFalse(closeHandler.called);
+      done();
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
index 8366463..f191981 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
@@ -14,14 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
 import '../../../styles/shared-styles.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-page-nav_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrPageNav extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.js
deleted file mode 100644
index c5e9142..0000000
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    #nav {
-      background-color: var(--table-header-background-color);
-      border: 1px solid var(--border-color);
-      border-top: none;
-      height: 100%;
-      position: absolute;
-      top: 0;
-      width: 14em;
-    }
-    #nav.pinned {
-      position: fixed;
-    }
-    @media only screen and (max-width: 53em) {
-      #nav {
-        display: none;
-      }
-    }
-  </style>
-  <nav id="nav">
-    <slot></slot>
-  </nav>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts
new file mode 100644
index 0000000..facc4f8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    #nav {
+      background-color: var(--table-header-background-color);
+      border: 1px solid var(--border-color);
+      border-top: none;
+      height: 100%;
+      position: absolute;
+      top: 0;
+      width: 14em;
+    }
+    #nav.pinned {
+      position: fixed;
+    }
+    @media only screen and (max-width: 53em) {
+      #nav {
+        display: none;
+      }
+    }
+  </style>
+  <nav id="nav">
+    <slot></slot>
+  </nav>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
deleted file mode 100644
index a54d7a3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
+++ /dev/null
@@ -1,89 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-page-nav</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/page/page.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-page-nav>
-      <ul>
-        <li>item</li>
-      </ul>
-    </gr-page-nav>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-page-nav.js';
-suite('gr-page-nav tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    flushAsynchronousOperations();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('header is not pinned just below top', () => {
-    sandbox.stub(element, '_getOffsetParent', () => 0);
-    sandbox.stub(element, '_getOffsetTop', () => 10);
-    sandbox.stub(element, '_getScrollY', () => 5);
-    element._handleBodyScroll();
-    assert.isFalse(element.$.nav.classList.contains('pinned'));
-  });
-
-  test('header is pinned when scroll down the page', () => {
-    sandbox.stub(element, '_getOffsetParent', () => 0);
-    sandbox.stub(element, '_getOffsetTop', () => 10);
-    sandbox.stub(element, '_getScrollY', () => 25);
-    window.scrollY = 100;
-    element._handleBodyScroll();
-    assert.isTrue(element.$.nav.classList.contains('pinned'));
-  });
-
-  test('header is not pinned just below top with header set', () => {
-    element._headerHeight = 20;
-    sandbox.stub(element, '_getScrollY', () => 15);
-    window.scrollY = 100;
-    element._handleBodyScroll();
-    assert.isFalse(element.$.nav.classList.contains('pinned'));
-  });
-
-  test('header is pinned when scroll down the page with header set', () => {
-    element._headerHeight = 20;
-    sandbox.stub(element, '_getScrollY', () => 25);
-    window.scrollY = 100;
-    element._handleBodyScroll();
-    assert.isTrue(element.$.nav.classList.contains('pinned'));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js
new file mode 100644
index 0000000..2e40b27
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-page-nav.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-page-nav>
+      <ul>
+        <li>item</li>
+      </ul>
+    </gr-page-nav>
+`);
+
+suite('gr-page-nav tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    flushAsynchronousOperations();
+  });
+
+  test('header is not pinned just below top', () => {
+    sinon.stub(element, '_getOffsetParent').callsFake(() => 0);
+    sinon.stub(element, '_getOffsetTop').callsFake(() => 10);
+    sinon.stub(element, '_getScrollY').callsFake(() => 5);
+    element._handleBodyScroll();
+    assert.isFalse(element.$.nav.classList.contains('pinned'));
+  });
+
+  test('header is pinned when scroll down the page', () => {
+    sinon.stub(element, '_getOffsetParent').callsFake(() => 0);
+    sinon.stub(element, '_getOffsetTop').callsFake(() => 10);
+    sinon.stub(element, '_getScrollY').callsFake(() => 25);
+    window.scrollY = 100;
+    element._handleBodyScroll();
+    assert.isTrue(element.$.nav.classList.contains('pinned'));
+  });
+
+  test('header is not pinned just below top with header set', () => {
+    element._headerHeight = 20;
+    sinon.stub(element, '_getScrollY').callsFake(() => 15);
+    window.scrollY = 100;
+    element._handleBodyScroll();
+    assert.isFalse(element.$.nav.classList.contains('pinned'));
+  });
+
+  test('header is pinned when scroll down the page with header set', () => {
+    element._headerHeight = 20;
+    sinon.stub(element, '_getScrollY').callsFake(() => 25);
+    window.scrollY = 100;
+    element._handleBodyScroll();
+    assert.isTrue(element.$.nav.classList.contains('pinned'));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
index c499f56..746fcf8 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
@@ -14,31 +14,26 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '@polymer/iron-icon/iron-icon.js';
 import '../../../styles/shared-styles.js';
 import '../gr-icons/gr-icons.js';
 import '../gr-labeled-autocomplete/gr-labeled-autocomplete.js';
 import '../gr-rest-api-interface/gr-rest-api-interface.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-repo-branch-picker_html.js';
-import {URLEncodingBehavior} from '../../../behaviors/gr-url-encoding-behavior/gr-url-encoding-behavior.js';
+import {singleDecodeURL} from '../../../utils/url-util.js';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrRepoBranchPicker extends mixinBehaviors( [
-  URLEncodingBehavior,
-], GestureEventListeners(
+class GrRepoBranchPicker extends GestureEventListeners(
     LegacyElementMixin(
-        PolymerElement))) {
+        PolymerElement)) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-repo-branch-picker'; }
@@ -102,7 +97,7 @@
     return res.map(repo => {
       return {
         name: repo.name,
-        value: this.singleDecodeURL(repo.id),
+        value: singleDecodeURL(repo.id),
       };
     });
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.js
deleted file mode 100644
index 0ce885a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    gr-labeled-autocomplete,
-    iron-icon {
-      display: inline-block;
-    }
-    iron-icon {
-      margin-bottom: var(--spacing-l);
-    }
-  </style>
-  <div>
-    <gr-labeled-autocomplete
-      id="repoInput"
-      label="Repository"
-      placeholder="Select repo"
-      on-commit="_repoCommitted"
-      query="[[_repoQuery]]"
-    >
-    </gr-labeled-autocomplete>
-    <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-    <gr-labeled-autocomplete
-      id="branchInput"
-      label="Branch"
-      placeholder="Select branch"
-      disabled="[[_branchDisabled]]"
-      on-commit="_branchCommitted"
-      query="[[_query]]"
-    >
-    </gr-labeled-autocomplete>
-  </div>
-  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts
new file mode 100644
index 0000000..934b3cb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: block;
+    }
+    gr-labeled-autocomplete,
+    iron-icon {
+      display: inline-block;
+    }
+    iron-icon {
+      margin-bottom: var(--spacing-l);
+    }
+  </style>
+  <div>
+    <gr-labeled-autocomplete
+      id="repoInput"
+      label="Repository"
+      placeholder="Select repo"
+      on-commit="_repoCommitted"
+      query="[[_repoQuery]]"
+    >
+    </gr-labeled-autocomplete>
+    <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+    <gr-labeled-autocomplete
+      id="branchInput"
+      label="Branch"
+      placeholder="Select branch"
+      disabled="[[_branchDisabled]]"
+      on-commit="_branchCommitted"
+      query="[[_query]]"
+    >
+    </gr-labeled-autocomplete>
+  </div>
+  <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
deleted file mode 100644
index 67b82f9..0000000
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html
+++ /dev/null
@@ -1,143 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-repo-branch-picker</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-repo-branch-picker></gr-repo-branch-picker>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-repo-branch-picker.js';
-suite('gr-repo-branch-picker tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-  });
-
-  teardown(() => { sandbox.restore(); });
-
-  suite('_getRepoSuggestions', () => {
-    setup(() => {
-      sandbox.stub(element.$.restAPI, 'getRepos')
-          .returns(Promise.resolve([
-            {
-              id: 'plugins%2Favatars-external',
-              name: 'plugins/avatars-external',
-            }, {
-              id: 'plugins%2Favatars-gravatar',
-              name: 'plugins/avatars-gravatar',
-            }, {
-              id: 'plugins%2Favatars%2Fexternal',
-              name: 'plugins/avatars/external',
-            }, {
-              id: 'plugins%2Favatars%2Fgravatar',
-              name: 'plugins/avatars/gravatar',
-            },
-          ]));
-    });
-
-    test('converts to suggestion objects', () => {
-      const input = 'plugins/avatars';
-      return element._getRepoSuggestions(input).then(suggestions => {
-        assert.isTrue(element.$.restAPI.getRepos.calledWith(input));
-        const unencodedNames = [
-          'plugins/avatars-external',
-          'plugins/avatars-gravatar',
-          'plugins/avatars/external',
-          'plugins/avatars/gravatar',
-        ];
-        assert.deepEqual(suggestions.map(s => s.name), unencodedNames);
-        assert.deepEqual(suggestions.map(s => s.value), unencodedNames);
-      });
-    });
-  });
-
-  suite('_getRepoBranchesSuggestions', () => {
-    setup(() => {
-      sandbox.stub(element.$.restAPI, 'getRepoBranches')
-          .returns(Promise.resolve([
-            {ref: 'refs/heads/stable-2.10'},
-            {ref: 'refs/heads/stable-2.11'},
-            {ref: 'refs/heads/stable-2.12'},
-            {ref: 'refs/heads/stable-2.13'},
-            {ref: 'refs/heads/stable-2.14'},
-            {ref: 'refs/heads/stable-2.15'},
-          ]));
-    });
-
-    test('converts to suggestion objects', () => {
-      const repo = 'gerrit';
-      const branchInput = 'stable-2.1';
-      element.repo = repo;
-      return element._getRepoBranchesSuggestions(branchInput)
-          .then(suggestions => {
-            assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
-                branchInput, repo, 15));
-            const refNames = [
-              'stable-2.10',
-              'stable-2.11',
-              'stable-2.12',
-              'stable-2.13',
-              'stable-2.14',
-              'stable-2.15',
-            ];
-            assert.deepEqual(suggestions.map(s => s.name), refNames);
-            assert.deepEqual(suggestions.map(s => s.value), refNames);
-          });
-    });
-
-    test('filters out ref prefix', () => {
-      const repo = 'gerrit';
-      const branchInput = 'refs/heads/stable-2.1';
-      element.repo = repo;
-      return element._getRepoBranchesSuggestions(branchInput)
-          .then(suggestions => {
-            assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
-                'stable-2.1', repo, 15));
-          });
-    });
-
-    test('does not query when repo is unset', done => {
-      element
-          ._getRepoBranchesSuggestions('')
-          .then(() => {
-            assert.isFalse(element.$.restAPI.getRepoBranches.called);
-            element.repo = 'gerrit';
-            return element._getRepoBranchesSuggestions('');
-          })
-          .then(() => {
-            assert.isTrue(element.$.restAPI.getRepoBranches.called);
-            done();
-          });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
new file mode 100644
index 0000000..bfdbe41
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
@@ -0,0 +1,126 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-repo-branch-picker.js';
+
+const basicFixture = fixtureFromElement('gr-repo-branch-picker');
+
+suite('gr-repo-branch-picker tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('_getRepoSuggestions', () => {
+    setup(() => {
+      sinon.stub(element.$.restAPI, 'getRepos')
+          .returns(Promise.resolve([
+            {
+              id: 'plugins%2Favatars-external',
+              name: 'plugins/avatars-external',
+            }, {
+              id: 'plugins%2Favatars-gravatar',
+              name: 'plugins/avatars-gravatar',
+            }, {
+              id: 'plugins%2Favatars%2Fexternal',
+              name: 'plugins/avatars/external',
+            }, {
+              id: 'plugins%2Favatars%2Fgravatar',
+              name: 'plugins/avatars/gravatar',
+            },
+          ]));
+    });
+
+    test('converts to suggestion objects', () => {
+      const input = 'plugins/avatars';
+      return element._getRepoSuggestions(input).then(suggestions => {
+        assert.isTrue(element.$.restAPI.getRepos.calledWith(input));
+        const unencodedNames = [
+          'plugins/avatars-external',
+          'plugins/avatars-gravatar',
+          'plugins/avatars/external',
+          'plugins/avatars/gravatar',
+        ];
+        assert.deepEqual(suggestions.map(s => s.name), unencodedNames);
+        assert.deepEqual(suggestions.map(s => s.value), unencodedNames);
+      });
+    });
+  });
+
+  suite('_getRepoBranchesSuggestions', () => {
+    setup(() => {
+      sinon.stub(element.$.restAPI, 'getRepoBranches')
+          .returns(Promise.resolve([
+            {ref: 'refs/heads/stable-2.10'},
+            {ref: 'refs/heads/stable-2.11'},
+            {ref: 'refs/heads/stable-2.12'},
+            {ref: 'refs/heads/stable-2.13'},
+            {ref: 'refs/heads/stable-2.14'},
+            {ref: 'refs/heads/stable-2.15'},
+          ]));
+    });
+
+    test('converts to suggestion objects', () => {
+      const repo = 'gerrit';
+      const branchInput = 'stable-2.1';
+      element.repo = repo;
+      return element._getRepoBranchesSuggestions(branchInput)
+          .then(suggestions => {
+            assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
+                branchInput, repo, 15));
+            const refNames = [
+              'stable-2.10',
+              'stable-2.11',
+              'stable-2.12',
+              'stable-2.13',
+              'stable-2.14',
+              'stable-2.15',
+            ];
+            assert.deepEqual(suggestions.map(s => s.name), refNames);
+            assert.deepEqual(suggestions.map(s => s.value), refNames);
+          });
+    });
+
+    test('filters out ref prefix', () => {
+      const repo = 'gerrit';
+      const branchInput = 'refs/heads/stable-2.1';
+      element.repo = repo;
+      return element._getRepoBranchesSuggestions(branchInput)
+          .then(suggestions => {
+            assert.isTrue(element.$.restAPI.getRepoBranches.calledWith(
+                'stable-2.1', repo, 15));
+          });
+    });
+
+    test('does not query when repo is unset', done => {
+      element
+          ._getRepoBranchesSuggestions('')
+          .then(() => {
+            assert.isFalse(element.$.restAPI.getRepoBranches.called);
+            element.repo = 'gerrit';
+            return element._getRepoBranchesSuggestions('');
+          })
+          .then(() => {
+            assert.isTrue(element.$.restAPI.getRepoBranches.called);
+            done();
+          });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
deleted file mode 100644
index 6663f07..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
+++ /dev/null
@@ -1,273 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {gerritEventEmitter} from '../gr-event-emitter/gr-event-emitter.js';
-
-const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
-const MAX_GET_TOKEN_RETRIES = 2;
-
-/**
- * Auth class.
- */
-export class Auth {
-  // TODO(taoalpha): this whole thing should be moved to a service
-
-  constructor() {
-    this._type = null;
-    this._cachedTokenPromise = null;
-    this._defaultOptions = {};
-    this._retriesLeft = MAX_GET_TOKEN_RETRIES;
-    this._status = Auth.STATUS.UNDETERMINED;
-    this._authCheckPromise = null;
-    this._last_auth_check_time = Date.now();
-  }
-
-  get baseUrl() {
-    return BaseUrlBehavior.getBaseUrl();
-  }
-
-  /**
-   * Returns if user is authed or not.
-   *
-   * @returns {!Promise<boolean>}
-   */
-  authCheck() {
-    if (!this._authCheckPromise ||
-      (Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS)
-    ) {
-      // Refetch after last check expired
-      this._authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
-      this._last_auth_check_time = Date.now();
-    }
-
-    return this._authCheckPromise.then(res => {
-      // auth-check will return 204 if authed
-      // treat the rest as unauthed
-      if (res.status === 204) {
-        this._setStatus(Auth.STATUS.AUTHED);
-        return true;
-      } else {
-        this._setStatus(Auth.STATUS.NOT_AUTHED);
-        return false;
-      }
-    }).catch(e => {
-      this._setStatus(Auth.STATUS.ERROR);
-      // Reset _authCheckPromise to avoid caching the failed promise
-      this._authCheckPromise = null;
-      return false;
-    });
-  }
-
-  clearCache() {
-    this._authCheckPromise = null;
-  }
-
-  /**
-   * @param {Auth.STATUS} status
-   */
-  _setStatus(status) {
-    if (this._status === status) return;
-
-    if (this._status === Auth.STATUS.AUTHED) {
-      gerritEventEmitter.emit('auth-error', {
-        message: Auth.CREDS_EXPIRED_MSG, action: 'Refresh credentials',
-      });
-    }
-    this._status = status;
-  }
-
-  get status() {
-    return this._status;
-  }
-
-  get isAuthed() {
-    return this._status === Auth.STATUS.AUTHED;
-  }
-
-  _getToken() {
-    return Promise.resolve(this._cachedTokenPromise);
-  }
-
-  /**
-   * Enable cross-domain authentication using OAuth access token.
-   *
-   * @param {
-   *   function(): !Promise<{
-   *     access_token: string,
-   *     expires_at: number
-   *   }>
-   * } getToken
-   * @param {?{credentials:string}} defaultOptions
-   */
-  setup(getToken, defaultOptions) {
-    this._retriesLeft = MAX_GET_TOKEN_RETRIES;
-    if (getToken) {
-      this._type = Auth.TYPE.ACCESS_TOKEN;
-      this._cachedTokenPromise = null;
-      this._getToken = getToken;
-    }
-    this._defaultOptions = {};
-    if (defaultOptions) {
-      for (const p of ['credentials']) {
-        this._defaultOptions[p] = defaultOptions[p];
-      }
-    }
-  }
-
-  /**
-   * Perform network fetch with authentication.
-   *
-   * @param {string} url
-   * @param {Object=} opt_options
-   * @return {!Promise<!Response>}
-   */
-  fetch(url, opt_options) {
-    const options = Object.assign({
-      headers: new Headers(),
-    }, this._defaultOptions, opt_options);
-    if (this._type === Auth.TYPE.ACCESS_TOKEN) {
-      return this._getAccessToken().then(
-          accessToken =>
-            this._fetchWithAccessToken(url, options, accessToken)
-      );
-    } else {
-      return this._fetchWithXsrfToken(url, options);
-    }
-  }
-
-  _getCookie(name) {
-    const key = name + '=';
-    let result = '';
-    document.cookie.split(';').some(c => {
-      c = c.trim();
-      if (c.startsWith(key)) {
-        result = c.substring(key.length);
-        return true;
-      }
-    });
-    return result;
-  }
-
-  _isTokenValid(token) {
-    if (!token) { return false; }
-    if (!token.access_token || !token.expires_at) { return false; }
-
-    const expiration = new Date(parseInt(token.expires_at, 10) * 1000);
-    if (Date.now() >= expiration.getTime()) { return false; }
-
-    return true;
-  }
-
-  _fetchWithXsrfToken(url, options) {
-    if (options.method && options.method !== 'GET') {
-      const token = this._getCookie('XSRF_TOKEN');
-      if (token) {
-        options.headers.append('X-Gerrit-Auth', token);
-      }
-    }
-    options.credentials = 'same-origin';
-    return fetch(url, options);
-  }
-
-  /**
-   * @return {!Promise<string>}
-   */
-  _getAccessToken() {
-    if (!this._cachedTokenPromise) {
-      this._cachedTokenPromise = this._getToken();
-    }
-    return this._cachedTokenPromise.then(token => {
-      if (this._isTokenValid(token)) {
-        this._retriesLeft = MAX_GET_TOKEN_RETRIES;
-        return token.access_token;
-      }
-      if (this._retriesLeft > 0) {
-        this._retriesLeft--;
-        this._cachedTokenPromise = null;
-        return this._getAccessToken();
-      }
-      // Fall back to anonymous access.
-      return null;
-    });
-  }
-
-  _fetchWithAccessToken(url, options, accessToken) {
-    const params = [];
-
-    if (accessToken) {
-      params.push(`access_token=${accessToken}`);
-      const baseUrl = this.baseUrl;
-      const pathname = baseUrl ?
-        url.substring(url.indexOf(baseUrl) + baseUrl.length) : url;
-      if (!pathname.startsWith('/a/')) {
-        url = url.replace(pathname, '/a' + pathname);
-      }
-    }
-
-    const method = options.method || 'GET';
-    let contentType = options.headers.get('Content-Type');
-
-    // For all requests with body, ensure json content type.
-    if (!contentType && options.body) {
-      contentType = 'application/json';
-    }
-
-    if (method !== 'GET') {
-      options.method = 'POST';
-      params.push(`$m=${method}`);
-      // If a request is not GET, and does not have a body, ensure text/plain
-      // content type.
-      if (!contentType) {
-        contentType = 'text/plain';
-      }
-    }
-
-    if (contentType) {
-      options.headers.set('Content-Type', 'text/plain');
-      params.push(`$ct=${encodeURIComponent(contentType)}`);
-    }
-
-    if (params.length) {
-      url = url + (url.indexOf('?') === -1 ? '?' : '&') + params.join('&');
-    }
-    return fetch(url, options);
-  }
-}
-
-Auth.TYPE = {
-  XSRF_TOKEN: 'xsrf_token',
-  ACCESS_TOKEN: 'access_token',
-};
-
-/** @enum {number} */
-Auth.STATUS = {
-  UNDETERMINED: 0,
-  AUTHED: 1,
-  NOT_AUTHED: 2,
-  ERROR: 3,
-};
-
-Auth.CREDS_EXPIRED_MSG = 'Credentials expired.';
-// TODO(dmfilippov) move to appContext
-export const authService = new Auth();
-
-// TODO(dmfilippov) Remove the following lines with assignments
-// Plugins can use global Auth because it was accessible with
-// the global Gerrit... variable. To avoid breaking changes in plugins
-// temporary assign global variables.
-window.Gerrit = window.Gerrit || {};
-window.Gerrit.Auth = authService;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
deleted file mode 100644
index 5fa476f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
+++ /dev/null
@@ -1,394 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-auth</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../../test/common-test-setup.js';
-import {BaseUrlBehavior} from '../../../behaviors/base-url-behavior/base-url-behavior.js';
-import {Auth, authService} from './gr-auth.js';
-import {gerritEventEmitter} from '../gr-event-emitter/gr-event-emitter.js';
-
-suite('gr-auth', () => {
-  let auth;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    auth = authService;
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('Auth class methods', () => {
-    let fakeFetch;
-    setup(() => {
-      auth = new Auth();
-      fakeFetch = sandbox.stub(window, 'fetch');
-    });
-
-    test('auth-check returns 403', done => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        done();
-      });
-    });
-
-    test('auth-check returns 204', done => {
-      fakeFetch.returns(Promise.resolve({status: 204}));
-      auth.authCheck().then(authed => {
-        assert.isTrue(authed);
-        assert.equal(auth.status, Auth.STATUS.AUTHED);
-        done();
-      });
-    });
-
-    test('auth-check returns 502', done => {
-      fakeFetch.returns(Promise.resolve({status: 502}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        done();
-      });
-    });
-
-    test('auth-check failed', done => {
-      fakeFetch.returns(Promise.reject(new Error('random error')));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.ERROR);
-        done();
-      });
-    });
-  });
-
-  suite('cache and events behaivor', () => {
-    let fakeFetch;
-    let clock;
-    setup(() => {
-      auth = new Auth();
-      clock = sinon.useFakeTimers();
-      fakeFetch = sandbox.stub(window, 'fetch');
-    });
-
-    test('cache auth-check result', done => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        auth.authCheck().then(authed2 => {
-          assert.isFalse(authed);
-          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-          done();
-        });
-      });
-    });
-
-    test('clearCache should refetch auth-check result', done => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        auth.clearCache();
-        auth.authCheck().then(authed2 => {
-          assert.isTrue(authed2);
-          assert.equal(auth.status, Auth.STATUS.AUTHED);
-          done();
-        });
-      });
-    });
-
-    test('cache expired on auth-check after certain time', done => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        auth.authCheck().then(authed2 => {
-          assert.isTrue(authed2);
-          assert.equal(auth.status, Auth.STATUS.AUTHED);
-          done();
-        });
-      });
-    });
-
-    test('no cache if auth-check failed', done => {
-      fakeFetch.returns(Promise.reject(new Error('random error')));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.ERROR);
-        assert.equal(fakeFetch.callCount, 1);
-        auth.authCheck().then(() => {
-          assert.equal(fakeFetch.callCount, 2);
-          done();
-        });
-      });
-    });
-
-    test('fire event when switch from authed to unauthed', done => {
-      fakeFetch.returns(Promise.resolve({status: 204}));
-      auth.authCheck().then(authed => {
-        assert.isTrue(authed);
-        assert.equal(auth.status, Auth.STATUS.AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.resolve({status: 403}));
-        const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
-        auth.authCheck().then(authed2 => {
-          assert.isFalse(authed2);
-          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-          assert.isTrue(emitStub.called);
-          done();
-        });
-      });
-    });
-
-    test('fire event when switch from authed to error', done => {
-      fakeFetch.returns(Promise.resolve({status: 204}));
-      auth.authCheck().then(authed => {
-        assert.isTrue(authed);
-        assert.equal(auth.status, Auth.STATUS.AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.reject(new Error('random error')));
-        const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
-        auth.authCheck().then(authed2 => {
-          assert.isFalse(authed2);
-          assert.isTrue(emitStub.called);
-          assert.equal(auth.status, Auth.STATUS.ERROR);
-          done();
-        });
-      });
-    });
-
-    test('no event from non-authed to other status', done => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
-        auth.authCheck().then(authed2 => {
-          assert.isTrue(authed2);
-          assert.isFalse(emitStub.called);
-          assert.equal(auth.status, Auth.STATUS.AUTHED);
-          done();
-        });
-      });
-    });
-
-    test('no event from non-authed to other status', done => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.reject(new Error('random error')));
-        const emitStub = sinon.stub();
-        gerritEventEmitter.emit = emitStub;
-        auth.authCheck().then(authed2 => {
-          assert.isFalse(authed2);
-          assert.isFalse(emitStub.called);
-          assert.equal(auth.status, Auth.STATUS.ERROR);
-          done();
-        });
-      });
-    });
-  });
-
-  suite('default (xsrf token header)', () => {
-    setup(() => {
-      sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
-    });
-
-    test('GET', done => {
-      auth.fetch('/url', {bar: 'bar'}).then(() => {
-        const [url, options] = fetch.lastCall.args;
-        assert.equal(url, '/url');
-        assert.equal(options.credentials, 'same-origin');
-        done();
-      });
-    });
-
-    test('POST', done => {
-      sandbox.stub(auth, '_getCookie')
-          .withArgs('XSRF_TOKEN')
-          .returns('foobar');
-      auth.fetch('/url', {method: 'POST'}).then(() => {
-        const [url, options] = fetch.lastCall.args;
-        assert.equal(url, '/url');
-        assert.equal(options.credentials, 'same-origin');
-        assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
-        done();
-      });
-    });
-  });
-
-  suite('cors (access token)', () => {
-    setup(() => {
-      sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
-    });
-
-    let getToken;
-
-    const makeToken = opt_accessToken => {
-      return {
-        access_token: opt_accessToken || 'zbaz',
-        expires_at: new Date(Date.now() + 10e8).getTime(),
-      };
-    };
-
-    setup(() => {
-      getToken = sandbox.stub();
-      getToken.returns(Promise.resolve(makeToken()));
-      auth.setup(getToken);
-    });
-
-    test('base url support', done => {
-      const baseUrl = 'http://foo';
-      sandbox.stub(BaseUrlBehavior, 'getBaseUrl').returns(baseUrl);
-      auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => {
-        const [url] = fetch.lastCall.args;
-        assert.equal(url, 'http://foo/a/url?access_token=zbaz');
-        done();
-      });
-    });
-
-    test('fetch not signed in', done => {
-      getToken.returns(Promise.resolve());
-      auth.fetch('/url', {bar: 'bar'}).then(() => {
-        const [url, options] = fetch.lastCall.args;
-        assert.equal(url, '/url');
-        assert.equal(options.bar, 'bar');
-        assert.equal(Object.keys(options.headers).length, 0);
-        done();
-      });
-    });
-
-    test('fetch signed in', done => {
-      auth.fetch('/url', {bar: 'bar'}).then(() => {
-        const [url, options] = fetch.lastCall.args;
-        assert.equal(url, '/a/url?access_token=zbaz');
-        assert.equal(options.bar, 'bar');
-        done();
-      });
-    });
-
-    test('getToken calls are cached', done => {
-      Promise.all([
-        auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => {
-        assert.equal(getToken.callCount, 1);
-        done();
-      });
-    });
-
-    test('getToken refreshes token', done => {
-      sandbox.stub(auth, '_isTokenValid');
-      auth._isTokenValid
-          .onFirstCall().returns(true)
-          .onSecondCall()
-          .returns(false)
-          .onThirdCall()
-          .returns(true);
-      auth.fetch('/url-one')
-          .then(() => {
-            getToken.returns(Promise.resolve(makeToken('bzzbb')));
-            return auth.fetch('/url-two');
-          })
-          .then(() => {
-            const [[firstUrl], [secondUrl]] = fetch.args;
-            assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
-            assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
-            done();
-          });
-    });
-
-    test('signed in token error falls back to anonymous', done => {
-      getToken.returns(Promise.resolve('rubbish'));
-      auth.fetch('/url', {bar: 'bar'}).then(() => {
-        const [url, options] = fetch.lastCall.args;
-        assert.equal(url, '/url');
-        assert.equal(options.bar, 'bar');
-        done();
-      });
-    });
-
-    test('_isTokenValid', () => {
-      assert.isFalse(auth._isTokenValid());
-      assert.isFalse(auth._isTokenValid({}));
-      assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
-      assert.isFalse(auth._isTokenValid({
-        access_token: 'foo',
-        expires_at: Date.now()/1000 - 1,
-      }));
-      assert.isTrue(auth._isTokenValid({
-        access_token: 'foo',
-        expires_at: Date.now()/1000 + 1,
-      }));
-    });
-
-    test('HTTP PUT with content type', done => {
-      const originalOptions = {
-        method: 'PUT',
-        headers: new Headers({'Content-Type': 'mail/pigeon'}),
-      };
-      auth.fetch('/url', originalOptions).then(() => {
-        assert.isTrue(getToken.called);
-        const [url, options] = fetch.lastCall.args;
-        assert.include(url, '$ct=mail%2Fpigeon');
-        assert.include(url, '$m=PUT');
-        assert.include(url, 'access_token=zbaz');
-        assert.equal(options.method, 'POST');
-        assert.equal(options.headers.get('Content-Type'), 'text/plain');
-        done();
-      });
-    });
-
-    test('HTTP PUT without content type', done => {
-      const originalOptions = {
-        method: 'PUT',
-      };
-      auth.fetch('/url', originalOptions).then(() => {
-        assert.isTrue(getToken.called);
-        const [url, options] = fetch.lastCall.args;
-        assert.include(url, '$ct=text%2Fplain');
-        assert.include(url, '$m=PUT');
-        assert.include(url, 'access_token=zbaz');
-        assert.equal(options.method, 'POST');
-        assert.equal(options.headers.get('Content-Type'), 'text/plain');
-        done();
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
index 23b8de7..b364caf 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
@@ -14,15 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-etag-decorator">
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
 
 // Limit cache size because /change/detail responses may be large.
 const MAX_CACHE_SIZE = 30;
@@ -47,7 +38,7 @@
   if (!etag) {
     return opt_options;
   }
-  const options = Object.assign({}, opt_options);
+  const options = {...opt_options};
   options.headers = options.headers || new Headers();
   options.headers.set('If-None-Match', this._etags.get(url));
   return options;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
deleted file mode 100644
index cfa164f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
+++ /dev/null
@@ -1,99 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-etag-decorator</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../../test/common-test-setup.js';
-import {GrEtagDecorator} from './gr-etag-decorator.js';
-
-suite('gr-etag-decorator', () => {
-  let etag;
-  let sandbox;
-
-  const fakeRequest = (opt_etag, opt_status) => {
-    const headers = new Headers();
-    if (opt_etag) {
-      headers.set('etag', opt_etag);
-    }
-    const status = opt_status || 200;
-    return {ok: true, status, headers};
-  };
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    etag = new GrEtagDecorator();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('exists', () => {
-    assert.isOk(etag);
-  });
-
-  test('works', () => {
-    etag.collect('/foo', fakeRequest('bar'));
-    const options = etag.getOptions('/foo');
-    assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
-  });
-
-  test('updates etags', () => {
-    etag.collect('/foo', fakeRequest('bar'));
-    etag.collect('/foo', fakeRequest('baz'));
-    const options = etag.getOptions('/foo');
-    assert.strictEqual(options.headers.get('If-None-Match'), 'baz');
-  });
-
-  test('discards empty etags', () => {
-    etag.collect('/foo', fakeRequest('bar'));
-    etag.collect('/foo', fakeRequest());
-    const options = etag.getOptions('/foo', {headers: new Headers()});
-    assert.isNull(options.headers.get('If-None-Match'));
-  });
-
-  test('discards etags in order used', () => {
-    etag.collect('/foo', fakeRequest('bar'));
-    _.times(29, i => {
-      etag.collect('/qaz/' + i, fakeRequest('qaz'));
-    });
-    let options = etag.getOptions('/foo');
-    assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
-    etag.collect('/zaq', fakeRequest('zaq'));
-    options = etag.getOptions('/foo', {headers: new Headers()});
-    assert.isNull(options.headers.get('If-None-Match'));
-  });
-
-  test('getCachedPayload', () => {
-    const payload = 'payload';
-    etag.collect('/foo', fakeRequest('bar'), payload);
-    assert.strictEqual(etag.getCachedPayload('/foo'), payload);
-    etag.collect('/foo', fakeRequest('bar', 304), 'garbage');
-    assert.strictEqual(etag.getCachedPayload('/foo'), payload);
-    etag.collect('/foo', fakeRequest('bar', 200), 'new payload');
-    assert.strictEqual(etag.getCachedPayload('/foo'), 'new payload');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js
new file mode 100644
index 0000000..e5217a4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js
@@ -0,0 +1,83 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {GrEtagDecorator} from './gr-etag-decorator.js';
+
+suite('gr-etag-decorator', () => {
+  let etag;
+
+  const fakeRequest = (opt_etag, opt_status) => {
+    const headers = new Headers();
+    if (opt_etag) {
+      headers.set('etag', opt_etag);
+    }
+    const status = opt_status || 200;
+    return {ok: true, status, headers};
+  };
+
+  setup(() => {
+    etag = new GrEtagDecorator();
+  });
+
+  test('exists', () => {
+    assert.isOk(etag);
+  });
+
+  test('works', () => {
+    etag.collect('/foo', fakeRequest('bar'));
+    const options = etag.getOptions('/foo');
+    assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
+  });
+
+  test('updates etags', () => {
+    etag.collect('/foo', fakeRequest('bar'));
+    etag.collect('/foo', fakeRequest('baz'));
+    const options = etag.getOptions('/foo');
+    assert.strictEqual(options.headers.get('If-None-Match'), 'baz');
+  });
+
+  test('discards empty etags', () => {
+    etag.collect('/foo', fakeRequest('bar'));
+    etag.collect('/foo', fakeRequest());
+    const options = etag.getOptions('/foo', {headers: new Headers()});
+    assert.isNull(options.headers.get('If-None-Match'));
+  });
+
+  test('discards etags in order used', () => {
+    etag.collect('/foo', fakeRequest('bar'));
+    _.times(29, i => {
+      etag.collect('/qaz/' + i, fakeRequest('qaz'));
+    });
+    let options = etag.getOptions('/foo');
+    assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
+    etag.collect('/zaq', fakeRequest('zaq'));
+    options = etag.getOptions('/foo', {headers: new Headers()});
+    assert.isNull(options.headers.get('If-None-Match'));
+  });
+
+  test('getCachedPayload', () => {
+    const payload = 'payload';
+    etag.collect('/foo', fakeRequest('bar'), payload);
+    assert.strictEqual(etag.getCachedPayload('/foo'), payload);
+    etag.collect('/foo', fakeRequest('bar', 304), 'garbage');
+    assert.strictEqual(etag.getCachedPayload('/foo'), payload);
+    etag.collect('/foo', fakeRequest('bar', 200), 'new payload');
+    assert.strictEqual(etag.getCachedPayload('/foo'), 'new payload');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index b675df7..58d53bb 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -14,29 +14,24 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-/* NB: es6-promise Needed for IE11 and fetch polyfill support, see Issue 4308 */
 /* NB: Order is important, because of namespaced classes. */
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-import '../../../scripts/bundled-polymer.js';
 
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import 'es6-promise/lib/es6-promise.js';
-import 'whatwg-fetch/fetch.js';
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
-import {PathListBehavior} from '../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.js';
-import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
 import {GrEtagDecorator} from './gr-etag-decorator.js';
 import {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './gr-rest-apis/gr-rest-api-helper.js';
 import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
-import {util} from '../../../scripts/util.js';
-import {authService} from './gr-auth.js';
+import {parseDate} from '../../../utils/date-util.js';
+import {getBaseUrl} from '../../../utils/url-util.js';
+import {appContext} from '../../../services/app-context.js';
+import {
+  getParentIndex,
+  isMergeParent,
+  patchNumEquals,
+  SPECIAL_PATCH_SET_NUM,
+} from '../../../utils/patch-set-util.js';
+import {ListChangesOption, listChangesOptionsToHex} from '../../../utils/change-util.js';
 
 const DiffViewMode = {
   SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -46,7 +41,6 @@
 const MAX_PROJECT_RESULTS = 25;
 // This value is somewhat arbitrary and not based on research or calculations.
 const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
-const PARENT_PATCH_NUM = 'PARENT';
 
 const Requests = {
   SEND_DIFF_DRAFT: 'sendDiffDraft',
@@ -54,22 +48,49 @@
 
 const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE =
     'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
-const HEADER_REPORTING_BLACKLIST = /^set-cookie$/i;
+const HEADER_REPORTING_BLOCK_REGEX = /^set-cookie$/i;
 
 const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
 const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
     '/revisions/*';
 
+let siteBasedCache = new SiteBasedCache(); // Shared across instances.
+let fetchPromisesCache = new FetchPromisesCache(); // Shared across instances.
+let pendingRequest = {}; // Shared across instances.
+let grEtagDecorator = new GrEtagDecorator; // Shared across instances.
+let projectLookup = {}; // Shared across instances.
+
+export function _testOnlyResetGrRestApiSharedObjects() {
+  for (const key in fetchPromisesCache._data) {
+    if (fetchPromisesCache._data.hasOwnProperty(key)) {
+      // reject already fulfilled promise does nothing
+      fetchPromisesCache._data[key].reject();
+    }
+  }
+
+  for (const key in pendingRequest) {
+    if (!pendingRequest.hasOwnProperty(key)) {
+      continue;
+    }
+    for (const req of pendingRequest[key]) {
+      // reject already fulfilled promise does nothing
+      req.reject();
+    }
+  }
+
+  siteBasedCache = new SiteBasedCache();
+  fetchPromisesCache = new FetchPromisesCache();
+  pendingRequest = {};
+  grEtagDecorator = new GrEtagDecorator;
+  projectLookup = {};
+  appContext.authService.clearCache();
+}
+
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrRestApiInterface extends mixinBehaviors( [
-  PathListBehavior,
-  PatchSetBehavior,
-  RESTClientBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrRestApiInterface extends GestureEventListeners(
+    LegacyElementMixin(PolymerElement)) {
   static get is() { return 'gr-rest-api-interface'; }
   /**
    * Fired when an server error occurs.
@@ -98,26 +119,26 @@
     return {
       _cache: {
         type: Object,
-        value: new SiteBasedCache(), // Shared across instances.
+        value: siteBasedCache, // Shared across instances.
       },
       _sharedFetchPromises: {
         type: Object,
-        value: new FetchPromisesCache(), // Shared across instances.
+        value: fetchPromisesCache, // Shared across instances.
       },
       _pendingRequests: {
         type: Object,
-        value: {}, // Intentional to share the object across instances.
+        value: pendingRequest, // Intentional to share the object across instances.
       },
       _etags: {
         type: Object,
-        value: new GrEtagDecorator(), // Share across instances.
+        value: grEtagDecorator, // Share across instances.
       },
       /**
        * Used to maintain a mapping of changeNums to project names.
        */
       _projectLookup: {
         type: Object,
-        value: {}, // Intentional to share the object across instances.
+        value: projectLookup, // Intentional to share the object across instances.
       },
     };
   }
@@ -125,7 +146,7 @@
   /** @override */
   created() {
     super.created();
-    this._auth = authService;
+    this.authService = appContext.authService;
     this._initRestApiHelper();
   }
 
@@ -133,8 +154,8 @@
     if (this._restApiHelper) {
       return;
     }
-    if (this._cache && this._auth && this._sharedFetchPromises) {
-      this._restApiHelper = new GrRestApiHelper(this._cache, this._auth,
+    if (this._cache && this.authService && this._sharedFetchPromises) {
+      this._restApiHelper = new GrRestApiHelper(this._cache, this.authService,
           this._sharedFetchPromises, this);
     }
   }
@@ -727,7 +748,7 @@
     if (cachedAccount) {
       // Replace object in cache with new object to force UI updates.
       this._cache.set('/accounts/self/detail',
-          Object.assign({}, cachedAccount, obj));
+          {...cachedAccount, ...obj});
     }
   }
 
@@ -846,7 +867,7 @@
   }
 
   getLoggedIn() {
-    return this._auth.authCheck();
+    return this.authService.authCheck();
   }
 
   getIsAdmin() {
@@ -1034,16 +1055,16 @@
 
   _getChangesOptionsHex(config) {
     const options = [
-      this.ListChangesOption.LABELS,
-      this.ListChangesOption.DETAILED_ACCOUNTS,
+      ListChangesOption.LABELS,
+      ListChangesOption.DETAILED_ACCOUNTS,
     ];
     if (config && config.change && config.change.enable_attention_set) {
-      options.push(this.ListChangesOption.DETAILED_LABELS);
+      options.push(ListChangesOption.DETAILED_LABELS);
     } else {
-      options.push(this.ListChangesOption.REVIEWED);
+      options.push(ListChangesOption.REVIEWED);
     }
 
-    return this.listChangesOptionsToHex(...options);
+    return listChangesOptionsToHex(...options);
   }
 
   _getChangeOptionsHex(config) {
@@ -1055,20 +1076,20 @@
     // This list MUST be kept in sync with
     // ChangeIT#changeDetailsDoesNotRequireIndex
     const options = [
-      this.ListChangesOption.ALL_COMMITS,
-      this.ListChangesOption.ALL_REVISIONS,
-      this.ListChangesOption.CHANGE_ACTIONS,
-      this.ListChangesOption.DETAILED_LABELS,
-      this.ListChangesOption.DOWNLOAD_COMMANDS,
-      this.ListChangesOption.MESSAGES,
-      this.ListChangesOption.SUBMITTABLE,
-      this.ListChangesOption.WEB_LINKS,
-      this.ListChangesOption.SKIP_DIFFSTAT,
+      ListChangesOption.ALL_COMMITS,
+      ListChangesOption.ALL_REVISIONS,
+      ListChangesOption.CHANGE_ACTIONS,
+      ListChangesOption.DETAILED_LABELS,
+      ListChangesOption.DOWNLOAD_COMMANDS,
+      ListChangesOption.MESSAGES,
+      ListChangesOption.SUBMITTABLE,
+      ListChangesOption.WEB_LINKS,
+      ListChangesOption.SKIP_DIFFSTAT,
     ];
     if (config.receive && config.receive.enable_signed_push) {
-      options.push(this.ListChangesOption.PUSH_CERTIFICATES);
+      options.push(ListChangesOption.PUSH_CERTIFICATES);
     }
-    return this.listChangesOptionsToHex(...options);
+    return listChangesOptionsToHex(...options);
   }
 
   /**
@@ -1081,10 +1102,10 @@
     if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.diffPage) {
       optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
     } else {
-      optionsHex = this.listChangesOptionsToHex(
-          this.ListChangesOption.ALL_COMMITS,
-          this.ListChangesOption.ALL_REVISIONS,
-          this.ListChangesOption.SKIP_DIFFSTAT
+      optionsHex = listChangesOptionsToHex(
+          ListChangesOption.ALL_COMMITS,
+          ListChangesOption.ALL_REVISIONS,
+          ListChangesOption.SKIP_DIFFSTAT
       );
     }
     return this._getChangeDetail(changeNum, optionsHex, opt_errFn,
@@ -1163,9 +1184,10 @@
    */
   getChangeFiles(changeNum, patchRange, opt_parentIndex) {
     let params = undefined;
-    if (this.isMergeParent(patchRange.basePatchNum)) {
-      params = {parent: this.getParentIndex(patchRange.basePatchNum)};
-    } else if (!this.patchNumEquals(patchRange.basePatchNum, 'PARENT')) {
+    if (isMergeParent(patchRange.basePatchNum)) {
+      params = {parent: getParentIndex(patchRange.basePatchNum)};
+    } else if (!patchNumEquals(patchRange.basePatchNum,
+        SPECIAL_PATCH_SET_NUM.PARENT)) {
       params = {base: patchRange.basePatchNum};
     }
     return this._getChangeURLAndFetch({
@@ -1216,7 +1238,7 @@
    * @return {!Promise<!Array<!Object>>}
    */
   getChangeOrEditFiles(changeNum, patchRange) {
-    if (this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME)) {
+    if (patchNumEquals(patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT)) {
       return this.getChangeEditFiles(changeNum, patchRange).then(res =>
         res.files);
     }
@@ -1589,9 +1611,9 @@
   }
 
   getChangeConflicts(changeNum) {
-    const options = this.listChangesOptionsToHex(
-        this.ListChangesOption.CURRENT_REVISION,
-        this.ListChangesOption.CURRENT_COMMIT
+    const options = listChangesOptionsToHex(
+        ListChangesOption.CURRENT_REVISION,
+        ListChangesOption.CURRENT_COMMIT
     );
     const params = {
       O: options,
@@ -1605,9 +1627,9 @@
   }
 
   getChangeCherryPicks(project, changeID, changeNum) {
-    const options = this.listChangesOptionsToHex(
-        this.ListChangesOption.CURRENT_REVISION,
-        this.ListChangesOption.CURRENT_COMMIT
+    const options = listChangesOptionsToHex(
+        ListChangesOption.CURRENT_REVISION,
+        ListChangesOption.CURRENT_COMMIT
     );
     const query = [
       'project:' + project,
@@ -1627,11 +1649,11 @@
   }
 
   getChangesWithSameTopic(topic, changeNum) {
-    const options = this.listChangesOptionsToHex(
-        this.ListChangesOption.LABELS,
-        this.ListChangesOption.CURRENT_REVISION,
-        this.ListChangesOption.CURRENT_COMMIT,
-        this.ListChangesOption.DETAILED_LABELS
+    const options = listChangesOptionsToHex(
+        ListChangesOption.LABELS,
+        ListChangesOption.CURRENT_REVISION,
+        ListChangesOption.CURRENT_COMMIT,
+        ListChangesOption.DETAILED_LABELS
     );
     const query = [
       'status:open',
@@ -1755,7 +1777,7 @@
       }
       return res;
     };
-    const promise = this.patchNumEquals(patchNum, this.EDIT_NAME) ?
+    const promise = patchNumEquals(patchNum, SPECIAL_PATCH_SET_NUM.EDIT) ?
       this._getFileInChangeEdit(changeNum, path) :
       this._getFileInRevision(changeNum, path, patchNum, suppress404s);
 
@@ -1949,10 +1971,15 @@
   }
 
   saveChangeReviewed(changeNum, reviewed) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: 'PUT',
-      endpoint: reviewed ? '/reviewed' : '/unreviewed',
+    return this.getConfig().then(config => {
+      const isAttentionSetEnabled = !!config && !!config.change
+          && config.change.enable_attention_set;
+      if (isAttentionSetEnabled) return Promise.resolve();
+      return this._getChangeURLAndSend({
+        changeNum,
+        method: 'PUT',
+        endpoint: reviewed ? '/reviewed' : '/unreviewed',
+      });
     });
   }
 
@@ -1998,9 +2025,9 @@
       intraline: null,
       whitespace: opt_whitespace || 'IGNORE_NONE',
     };
-    if (this.isMergeParent(basePatchNum)) {
-      params.parent = this.getParentIndex(basePatchNum);
-    } else if (!this.patchNumEquals(basePatchNum, PARENT_PATCH_NUM)) {
+    if (isMergeParent(basePatchNum)) {
+      params.parent = getParentIndex(basePatchNum);
+    } else if (!patchNumEquals(basePatchNum, SPECIAL_PATCH_SET_NUM.PARENT)) {
       params.base = basePatchNum;
     }
     const endpoint = `/files/${encodeURIComponent(path)}/diff`;
@@ -2014,7 +2041,7 @@
     };
 
     // Invalidate the cache if its edit patch to make sure we always get latest.
-    if (patchNum === this.EDIT_NAME) {
+    if (patchNum === SPECIAL_PATCH_SET_NUM.EDIT) {
       if (!req.fetchOptions) req.fetchOptions = {};
       if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
       req.fetchOptions.headers.append('Cache-Control', 'no-cache');
@@ -2081,7 +2108,7 @@
   _setRanges(comments) {
     comments = comments || [];
     comments.sort(
-        (a, b) => util.parseDate(a.updated) - util.parseDate(b.updated)
+        (a, b) => parseDate(a.updated) - parseDate(b.updated)
     );
     for (const comment of comments) {
       this._setRange(comments, comment);
@@ -2119,8 +2146,8 @@
     if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
       return fetchComments();
     }
-    function onlyParent(c) { return c.side == PARENT_PATCH_NUM; }
-    function withoutParent(c) { return c.side != PARENT_PATCH_NUM; }
+    function onlyParent(c) { return c.side == SPECIAL_PATCH_SET_NUM.PARENT; }
+    function withoutParent(c) { return c.side != SPECIAL_PATCH_SET_NUM.PARENT; }
     function setPath(c) { c.path = opt_path; }
 
     const promises = [];
@@ -2135,7 +2162,7 @@
       // in a single pass.
       comments = this._setRanges(comments);
 
-      if (opt_basePatchNum == PARENT_PATCH_NUM) {
+      if (opt_basePatchNum == SPECIAL_PATCH_SET_NUM.PARENT) {
         baseComments = comments.filter(onlyParent);
         baseComments.forEach(setPath);
       }
@@ -2145,7 +2172,7 @@
     });
     promises.push(fetchPromise);
 
-    if (opt_basePatchNum != PARENT_PATCH_NUM) {
+    if (opt_basePatchNum != SPECIAL_PATCH_SET_NUM.PARENT) {
       fetchPromise = fetchComments(opt_basePatchNum).then(response => {
         baseComments = (response[opt_path] || [])
             .filter(withoutParent);
@@ -2243,7 +2270,7 @@
   }
 
   _fetchB64File(url) {
-    return this._restApiHelper.fetch({url: this.getBaseUrl() + url})
+    return this._restApiHelper.fetch({url: getBaseUrl() + url})
         .then(response => {
           if (!response.ok) {
             return Promise.reject(new Error(response.statusText));
@@ -2334,6 +2361,26 @@
     });
   }
 
+  addToAttentionSet(changeNum, user, reason) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'POST',
+      endpoint: '/attention',
+      body: {user, reason},
+      reportUrlAsIs: true,
+    });
+  }
+
+  removeFromAttentionSet(changeNum, user, reason) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: 'DELETE',
+      endpoint: `/attention/${user}`,
+      anonymizedEndpoint: '/attention/*',
+      body: {reason},
+    });
+  }
+
   /**
    * @suppress {checkTypes}
    * Resulted in error: Promise.prototype.then does not match formal
@@ -2756,7 +2803,7 @@
         // Read the response headers into an object representation.
         const headers = Array.from(result.headers.entries())
             .reduce((obj, [key, val]) => {
-              if (!HEADER_REPORTING_BLACKLIST.test(key)) {
+              if (!HEADER_REPORTING_BLOCK_REGEX.test(key)) {
                 obj[key] = val;
               }
               return obj;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
deleted file mode 100644
index 0a51d26..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ /dev/null
@@ -1,1449 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-rest-api-interface</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-rest-api-interface></gr-rest-api-interface>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-rest-api-interface.js';
-import {mockPromise} from '../../../test/test-utils.js';
-import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
-import {authService} from './gr-auth.js';
-
-suite('gr-rest-api-interface tests', () => {
-  let element;
-  let sandbox;
-  let ctr = 0;
-
-  setup(() => {
-    // Modify CANONICAL_PATH to effectively reset cache.
-    ctr += 1;
-    window.CANONICAL_PATH = `test${ctr}`;
-
-    sandbox = sinon.sandbox.create();
-    const testJSON = ')]}\'\n{"hello": "bonjour"}';
-    sandbox.stub(window, 'fetch').returns(Promise.resolve({
-      ok: true,
-      text() {
-        return Promise.resolve(testJSON);
-      },
-    }));
-    // fake auth
-    sandbox.stub(authService, 'authCheck').returns(Promise.resolve(true));
-    element = fixture('basic');
-    element._projectLookup = {};
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('parent diff comments are properly grouped', done => {
-    sandbox.stub(element._restApiHelper, 'fetchJSON', () => Promise.resolve({
-      '/COMMIT_MSG': [],
-      'sieve.go': [
-        {
-          updated: '2017-02-03 22:32:28.000000000',
-          message: 'this isn’t quite right',
-        },
-        {
-          side: 'PARENT',
-          message: 'how did this work in the first place?',
-          updated: '2017-02-03 22:33:28.000000000',
-        },
-      ],
-    }));
-    element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
-        obj => {
-          assert.equal(obj.baseComments.length, 1);
-          assert.deepEqual(obj.baseComments[0], {
-            side: 'PARENT',
-            message: 'how did this work in the first place?',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:33:28.000000000',
-          });
-          assert.equal(obj.comments.length, 1);
-          assert.deepEqual(obj.comments[0], {
-            message: 'this isn’t quite right',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-          done();
-        });
-  });
-
-  test('_setRange', () => {
-    const comments = [
-      {
-        id: 1,
-        side: 'PARENT',
-        message: 'how did this work in the first place?',
-        updated: '2017-02-03 22:32:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-      {
-        id: 2,
-        in_reply_to: 1,
-        message: 'this isn’t quite right',
-        updated: '2017-02-03 22:33:28.000000000',
-      },
-    ];
-    const expectedResult = {
-      id: 2,
-      in_reply_to: 1,
-      message: 'this isn’t quite right',
-      updated: '2017-02-03 22:33:28.000000000',
-      range: {
-        start_line: 1,
-        start_character: 1,
-        end_line: 2,
-        end_character: 1,
-      },
-    };
-    const comment = comments[1];
-    assert.deepEqual(element._setRange(comments, comment), expectedResult);
-  });
-
-  test('_setRanges', () => {
-    const comments = [
-      {
-        id: 3,
-        in_reply_to: 2,
-        message: 'this isn’t quite right either',
-        updated: '2017-02-03 22:34:28.000000000',
-      },
-      {
-        id: 2,
-        in_reply_to: 1,
-        message: 'this isn’t quite right',
-        updated: '2017-02-03 22:33:28.000000000',
-      },
-      {
-        id: 1,
-        side: 'PARENT',
-        message: 'how did this work in the first place?',
-        updated: '2017-02-03 22:32:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-    ];
-    const expectedResult = [
-      {
-        id: 1,
-        side: 'PARENT',
-        message: 'how did this work in the first place?',
-        updated: '2017-02-03 22:32:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-      {
-        id: 2,
-        in_reply_to: 1,
-        message: 'this isn’t quite right',
-        updated: '2017-02-03 22:33:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-      {
-        id: 3,
-        in_reply_to: 2,
-        message: 'this isn’t quite right either',
-        updated: '2017-02-03 22:34:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-    ];
-    assert.deepEqual(element._setRanges(comments), expectedResult);
-  });
-
-  test('differing patch diff comments are properly grouped', done => {
-    sandbox.stub(element, 'getFromProjectLookup')
-        .returns(Promise.resolve('test'));
-    sandbox.stub(element._restApiHelper, 'fetchJSON', request => {
-      const url = request.url;
-      if (url === '/changes/test~42/revisions/1') {
-        return Promise.resolve({
-          '/COMMIT_MSG': [],
-          'sieve.go': [
-            {
-              message: 'this isn’t quite right',
-              updated: '2017-02-03 22:32:28.000000000',
-            },
-            {
-              side: 'PARENT',
-              message: 'how did this work in the first place?',
-              updated: '2017-02-03 22:33:28.000000000',
-            },
-          ],
-        });
-      } else if (url === '/changes/test~42/revisions/2') {
-        return Promise.resolve({
-          '/COMMIT_MSG': [],
-          'sieve.go': [
-            {
-              message: 'What on earth are you thinking, here?',
-              updated: '2017-02-03 22:32:28.000000000',
-            },
-            {
-              side: 'PARENT',
-              message: 'Yeah not sure how this worked either?',
-              updated: '2017-02-03 22:33:28.000000000',
-            },
-            {
-              message: '¯\\_(ツ)_/¯',
-              updated: '2017-02-04 22:33:28.000000000',
-            },
-          ],
-        });
-      }
-    });
-    element._getDiffComments('42', '', 1, 2, 'sieve.go').then(
-        obj => {
-          assert.equal(obj.baseComments.length, 1);
-          assert.deepEqual(obj.baseComments[0], {
-            message: 'this isn’t quite right',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-          assert.equal(obj.comments.length, 2);
-          assert.deepEqual(obj.comments[0], {
-            message: 'What on earth are you thinking, here?',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-          assert.deepEqual(obj.comments[1], {
-            message: '¯\\_(ツ)_/¯',
-            path: 'sieve.go',
-            updated: '2017-02-04 22:33:28.000000000',
-          });
-          done();
-        });
-  });
-
-  test('special file path sorting', () => {
-    assert.deepEqual(
-        ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
-            element.specialFilePathCompare),
-        ['/COMMIT_MSG', '.a', '.b', 'file']);
-
-    assert.deepEqual(
-        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
-            element.specialFilePathCompare),
-        ['/COMMIT_MSG', '.b', 'foo/bar/baz.h', 'foo/bar/baz.cc']);
-
-    assert.deepEqual(
-        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hpp'].sort(
-            element.specialFilePathCompare),
-        ['/COMMIT_MSG', '.b', 'foo/bar/baz.hpp', 'foo/bar/baz.cc']);
-
-    assert.deepEqual(
-        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hxx'].sort(
-            element.specialFilePathCompare),
-        ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']);
-
-    assert.deepEqual(
-        ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(
-            element.specialFilePathCompare),
-        ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']);
-
-    // Regression test for Issue 4448.
-    assert.deepEqual(
-        [
-          'minidump/minidump_memory_writer.cc',
-          'minidump/minidump_memory_writer.h',
-          'minidump/minidump_thread_writer.cc',
-          'minidump/minidump_thread_writer.h',
-        ].sort(element.specialFilePathCompare),
-        [
-          'minidump/minidump_memory_writer.h',
-          'minidump/minidump_memory_writer.cc',
-          'minidump/minidump_thread_writer.h',
-          'minidump/minidump_thread_writer.cc',
-        ]);
-
-    // Regression test for Issue 4545.
-    assert.deepEqual(
-        [
-          'task_test.go',
-          'task.go',
-        ].sort(element.specialFilePathCompare),
-        [
-          'task.go',
-          'task_test.go',
-        ]);
-  });
-
-  test('server error', done => {
-    const getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
-    window.fetch.returns(Promise.resolve({ok: false}));
-    const serverErrorEventPromise = new Promise(resolve => {
-      element.addEventListener('server-error', resolve);
-    });
-
-    element._restApiHelper.fetchJSON({}).then(response => {
-      assert.isUndefined(response);
-      assert.isTrue(getResponseObjectStub.notCalled);
-      serverErrorEventPromise.then(() => done());
-    });
-  });
-
-  test('legacy n,z key in change url is replaced', async () => {
-    sandbox.stub(element, 'getConfig', async () => { return {}; });
-    const stub = sandbox.stub(element._restApiHelper, 'fetchJSON')
-        .returns(Promise.resolve([]));
-    await element.getChanges(1, null, 'n,z');
-    assert.equal(stub.lastCall.args[0].params.S, 0);
-  });
-
-  test('saveDiffPreferences invalidates cache line', () => {
-    const cacheKey = '/accounts/self/preferences.diff';
-    const sendStub = sandbox.stub(element._restApiHelper, 'send');
-    element._cache.set(cacheKey, {tab_size: 4});
-    element.saveDiffPreferences({tab_size: 8});
-    assert.isTrue(sendStub.called);
-    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-  });
-
-  test('getAccount when resp is null does not add anything to the cache',
-      done => {
-        const cacheKey = '/accounts/self/detail';
-        const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
-            () => Promise.resolve());
-
-        element.getAccount().then(() => {
-          assert.isTrue(stub.called);
-          assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-          done();
-        });
-
-        element._restApiHelper._cache.set(cacheKey, 'fake cache');
-        stub.lastCall.args[0].errFn();
-      });
-
-  test('getAccount does not add to the cache when resp.status is 403',
-      done => {
-        const cacheKey = '/accounts/self/detail';
-        const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
-            () => Promise.resolve());
-
-        element.getAccount().then(() => {
-          assert.isTrue(stub.called);
-          assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-          done();
-        });
-        element._cache.set(cacheKey, 'fake cache');
-        stub.lastCall.args[0].errFn({status: 403});
-      });
-
-  test('getAccount when resp is successful', done => {
-    const cacheKey = '/accounts/self/detail';
-    const stub = sandbox.stub(element._restApiHelper, 'fetchCacheURL',
-        () => Promise.resolve());
-
-    element.getAccount().then(response => {
-      assert.isTrue(stub.called);
-      assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
-      done();
-    });
-    element._restApiHelper._cache.set(cacheKey, 'fake cache');
-
-    stub.lastCall.args[0].errFn({});
-  });
-
-  const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
-    sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(loggedIn));
-    sandbox.stub(element, '_isNarrowScreen', () => smallScreen);
-    sandbox.stub(
-        element._restApiHelper,
-        'fetchCacheURL',
-        () => Promise.resolve(testJSON));
-  };
-
-  test('getPreferences returns correctly on small screens logged in',
-      done => {
-        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
-        const loggedIn = true;
-        const smallScreen = true;
-
-        preferenceSetup(testJSON, loggedIn, smallScreen);
-
-        element.getPreferences().then(obj => {
-          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-          done();
-        });
-      });
-
-  test('getPreferences returns correctly on small screens not logged in',
-      done => {
-        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
-        const loggedIn = false;
-        const smallScreen = true;
-
-        preferenceSetup(testJSON, loggedIn, smallScreen);
-        element.getPreferences().then(obj => {
-          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-          done();
-        });
-      });
-
-  test('getPreferences returns correctly on larger screens logged in',
-      done => {
-        const testJSON = {diff_view: 'UNIFIED_DIFF'};
-        const loggedIn = true;
-        const smallScreen = false;
-
-        preferenceSetup(testJSON, loggedIn, smallScreen);
-
-        element.getPreferences().then(obj => {
-          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-          assert.equal(obj.diff_view, 'UNIFIED_DIFF');
-          done();
-        });
-      });
-
-  test('getPreferences returns correctly on larger screens not logged in',
-      done => {
-        const testJSON = {diff_view: 'UNIFIED_DIFF'};
-        const loggedIn = false;
-        const smallScreen = false;
-
-        preferenceSetup(testJSON, loggedIn, smallScreen);
-
-        element.getPreferences().then(obj => {
-          assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
-          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-          done();
-        });
-      });
-
-  test('savPreferences normalizes download scheme', () => {
-    const sendStub = sandbox.stub(element._restApiHelper, 'send');
-    element.savePreferences({download_scheme: 'HTTP'});
-    assert.isTrue(sendStub.called);
-    assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
-  });
-
-  test('getDiffPreferences returns correct defaults', done => {
-    sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(false));
-
-    element.getDiffPreferences().then(obj => {
-      assert.equal(obj.auto_hide_diff_table_header, true);
-      assert.equal(obj.context, 10);
-      assert.equal(obj.cursor_blink_rate, 0);
-      assert.equal(obj.font_size, 12);
-      assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
-      assert.equal(obj.intraline_difference, true);
-      assert.equal(obj.line_length, 100);
-      assert.equal(obj.line_wrapping, false);
-      assert.equal(obj.show_line_endings, true);
-      assert.equal(obj.show_tabs, true);
-      assert.equal(obj.show_whitespace_errors, true);
-      assert.equal(obj.syntax_highlighting, true);
-      assert.equal(obj.tab_size, 8);
-      assert.equal(obj.theme, 'DEFAULT');
-      done();
-    });
-  });
-
-  test('saveDiffPreferences set show_tabs to false', () => {
-    const sendStub = sandbox.stub(element._restApiHelper, 'send');
-    element.saveDiffPreferences({show_tabs: false});
-    assert.isTrue(sendStub.called);
-    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
-  });
-
-  test('getEditPreferences returns correct defaults', done => {
-    sandbox.stub(element, 'getLoggedIn', () => Promise.resolve(false));
-
-    element.getEditPreferences().then(obj => {
-      assert.equal(obj.auto_close_brackets, false);
-      assert.equal(obj.cursor_blink_rate, 0);
-      assert.equal(obj.hide_line_numbers, false);
-      assert.equal(obj.hide_top_menu, false);
-      assert.equal(obj.indent_unit, 2);
-      assert.equal(obj.indent_with_tabs, false);
-      assert.equal(obj.key_map_type, 'DEFAULT');
-      assert.equal(obj.line_length, 100);
-      assert.equal(obj.line_wrapping, false);
-      assert.equal(obj.match_brackets, true);
-      assert.equal(obj.show_base, false);
-      assert.equal(obj.show_tabs, true);
-      assert.equal(obj.show_whitespace_errors, true);
-      assert.equal(obj.syntax_highlighting, true);
-      assert.equal(obj.tab_size, 8);
-      assert.equal(obj.theme, 'DEFAULT');
-      done();
-    });
-  });
-
-  test('saveEditPreferences set show_tabs to false', () => {
-    const sendStub = sandbox.stub(element._restApiHelper, 'send');
-    element.saveEditPreferences({show_tabs: false});
-    assert.isTrue(sendStub.called);
-    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
-  });
-
-  test('confirmEmail', () => {
-    const sendStub = sandbox.spy(element._restApiHelper, 'send');
-    element.confirmEmail('foo');
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].method, 'PUT');
-    assert.equal(sendStub.lastCall.args[0].url,
-        '/config/server/email.confirm');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
-  });
-
-  test('setAccountStatus', () => {
-    const sendStub = sandbox.stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve('OOO'));
-    element._cache.set('/accounts/self/detail', {});
-    return element.setAccountStatus('OOO').then(() => {
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].method, 'PUT');
-      assert.equal(sendStub.lastCall.args[0].url,
-          '/accounts/self/status');
-      assert.deepEqual(sendStub.lastCall.args[0].body,
-          {status: 'OOO'});
-      assert.deepEqual(element._restApiHelper
-          ._cache.get('/accounts/self/detail'),
-      {status: 'OOO'});
-    });
-  });
-
-  suite('draft comments', () => {
-    test('_sendDiffDraftRequest pending requests tracked', () => {
-      const obj = element._pendingRequests;
-      sandbox.stub(element, '_getChangeURLAndSend', () => mockPromise());
-      assert.notOk(element.hasPendingDiffDrafts());
-
-      element._sendDiffDraftRequest(null, null, null, {});
-      assert.equal(obj.sendDiffDraft.length, 1);
-      assert.isTrue(!!element.hasPendingDiffDrafts());
-
-      element._sendDiffDraftRequest(null, null, null, {});
-      assert.equal(obj.sendDiffDraft.length, 2);
-      assert.isTrue(!!element.hasPendingDiffDrafts());
-
-      for (const promise of obj.sendDiffDraft) { promise.resolve(); }
-
-      return element.awaitPendingDiffDrafts().then(() => {
-        assert.equal(obj.sendDiffDraft.length, 0);
-        assert.isFalse(!!element.hasPendingDiffDrafts());
-      });
-    });
-
-    suite('_failForCreate200', () => {
-      test('_sendDiffDraftRequest checks for 200 on create', () => {
-        const sendPromise = Promise.resolve();
-        sandbox.stub(element, '_getChangeURLAndSend').returns(sendPromise);
-        const failStub = sandbox.stub(element, '_failForCreate200')
-            .returns(Promise.resolve());
-        return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
-          assert.isTrue(failStub.calledOnce);
-          assert.isTrue(failStub.calledWithExactly(sendPromise));
-        });
-      });
-
-      test('_sendDiffDraftRequest no checks for 200 on non create', () => {
-        sandbox.stub(element, '_getChangeURLAndSend')
-            .returns(Promise.resolve());
-        const failStub = sandbox.stub(element, '_failForCreate200')
-            .returns(Promise.resolve());
-        return element._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
-            .then(() => {
-              assert.isFalse(failStub.called);
-            });
-      });
-
-      test('_failForCreate200 fails on 200', done => {
-        const result = {
-          ok: true,
-          status: 200,
-          headers: {entries: () => [
-            ['Set-CoOkiE', 'secret'],
-            ['Innocuous', 'hello'],
-          ]},
-        };
-        element._failForCreate200(Promise.resolve(result))
-            .then(() => {
-              assert.isTrue(false, 'Promise should not resolve');
-            })
-            .catch(e => {
-              assert.isOk(e);
-              assert.include(e.message, 'Saving draft resulted in HTTP 200');
-              assert.include(e.message, 'hello');
-              assert.notInclude(e.message, 'secret');
-              done();
-            });
-      });
-
-      test('_failForCreate200 does not fail on 201', done => {
-        const result = {
-          ok: true,
-          status: 201,
-          headers: {entries: () => []},
-        };
-        element._failForCreate200(Promise.resolve(result))
-            .then(() => {
-              done();
-            })
-            .catch(e => {
-              assert.isTrue(false, 'Promise should not fail');
-            });
-      });
-    });
-  });
-
-  test('saveChangeEdit', () => {
-    element._projectLookup = {1: 'test'};
-    const change_num = '1';
-    const file_name = 'index.php';
-    const file_contents = '<?php';
-    sandbox.stub(element._restApiHelper, 'send').returns(
-        Promise.resolve([change_num, file_name, file_contents]));
-    sandbox.stub(element, 'getResponseObject')
-        .returns(Promise.resolve([change_num, file_name, file_contents]));
-    element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
-    return element.saveChangeEdit(change_num, file_name, file_contents)
-        .then(() => {
-          assert.isTrue(element._restApiHelper.send.calledOnce);
-          assert.equal(element._restApiHelper.send.lastCall.args[0].method,
-              'PUT');
-          assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-              '/changes/test~1/edit/' + file_name);
-          assert.equal(element._restApiHelper.send.lastCall.args[0].body,
-              file_contents);
-        });
-  });
-
-  test('putChangeCommitMessage', () => {
-    element._projectLookup = {1: 'test'};
-    const change_num = '1';
-    const message = 'this is a commit message';
-    sandbox.stub(element._restApiHelper, 'send').returns(
-        Promise.resolve([change_num, message]));
-    sandbox.stub(element, 'getResponseObject')
-        .returns(Promise.resolve([change_num, message]));
-    element._cache.set('/changes/' + change_num + '/message', {});
-    return element.putChangeCommitMessage(change_num, message).then(() => {
-      assert.isTrue(element._restApiHelper.send.calledOnce);
-      assert.equal(element._restApiHelper.send.lastCall.args[0].method, 'PUT');
-      assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-          '/changes/test~1/message');
-      assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body,
-          {message});
-    });
-  });
-
-  test('deleteChangeCommitMessage', () => {
-    element._projectLookup = {1: 'test'};
-    const change_num = '1';
-    const messageId = 'abc';
-    sandbox.stub(element._restApiHelper, 'send').returns(
-        Promise.resolve([change_num, messageId]));
-    sandbox.stub(element, 'getResponseObject')
-        .returns(Promise.resolve([change_num, messageId]));
-    return element.deleteChangeCommitMessage(change_num, messageId).then(() => {
-      assert.isTrue(element._restApiHelper.send.calledOnce);
-      assert.equal(
-          element._restApiHelper.send.lastCall.args[0].method,
-          'DELETE'
-      );
-      assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-          '/changes/test~1/messages/abc');
-    });
-  });
-
-  test('startWorkInProgress', () => {
-    const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve('ok'));
-    element.startWorkInProgress('42');
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-    assert.equal(sendStub.lastCall.args[0].method, 'POST');
-    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {});
-
-    element.startWorkInProgress('42', 'revising...');
-    assert.isTrue(sendStub.calledTwice);
-    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-    assert.equal(sendStub.lastCall.args[0].method, 'POST');
-    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
-    assert.deepEqual(sendStub.lastCall.args[0].body,
-        {message: 'revising...'});
-  });
-
-  test('startReview', () => {
-    const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve({}));
-    element.startReview('42', {message: 'Please review.'});
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
-    assert.equal(sendStub.lastCall.args[0].method, 'POST');
-    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
-    assert.equal(sendStub.lastCall.args[0].endpoint, '/ready');
-    assert.deepEqual(sendStub.lastCall.args[0].body,
-        {message: 'Please review.'});
-  });
-
-  test('deleteComment', () => {
-    const sendStub = sandbox.stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve('some response'));
-    return element.deleteComment('foo', 'bar', '01234', 'removal reason')
-        .then(response => {
-          assert.equal(response, 'some response');
-          assert.isTrue(sendStub.calledOnce);
-          assert.equal(sendStub.lastCall.args[0].changeNum, 'foo');
-          assert.equal(sendStub.lastCall.args[0].method, 'POST');
-          assert.equal(sendStub.lastCall.args[0].patchNum, 'bar');
-          assert.equal(sendStub.lastCall.args[0].endpoint,
-              '/comments/01234/delete');
-          assert.deepEqual(sendStub.lastCall.args[0].body,
-              {reason: 'removal reason'});
-        });
-  });
-
-  test('createRepo encodes name', () => {
-    const sendStub = sandbox.stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve());
-    return element.createRepo({name: 'x/y'}).then(() => {
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
-    });
-  });
-
-  test('queryChangeFiles', () => {
-    const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-        .returns(Promise.resolve());
-    return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
-      assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
-      assert.equal(fetchStub.lastCall.args[0].endpoint,
-          '/files?q=test%2Fpath.js');
-      assert.equal(fetchStub.lastCall.args[0].patchNum, 'edit');
-    });
-  });
-
-  test('normal use', () => {
-    const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
-
-    assert.equal(element._getReposUrl('test', 25),
-        '/projects/?n=26&S=0&query=test');
-
-    assert.equal(element._getReposUrl(null, 25),
-        `/projects/?n=26&S=0&query=${defaultQuery}`);
-
-    assert.equal(element._getReposUrl('test', 25, 25),
-        '/projects/?n=26&S=25&query=test');
-  });
-
-  test('invalidateReposCache', () => {
-    const url = '/projects/?n=26&S=0&query=test';
-
-    element._cache.set(url, {});
-
-    element.invalidateReposCache();
-
-    assert.isUndefined(element._sharedFetchPromises[url]);
-
-    assert.isFalse(element._cache.has(url));
-  });
-
-  test('invalidateAccountsCache', () => {
-    const url = '/accounts/self/detail';
-
-    element._cache.set(url, {});
-
-    element.invalidateAccountsCache();
-
-    assert.isUndefined(element._sharedFetchPromises[url]);
-
-    assert.isFalse(element._cache.has(url));
-  });
-
-  suite('getRepos', () => {
-    const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
-    let fetchCacheURLStub;
-    setup(() => {
-      fetchCacheURLStub =
-          sandbox.stub(element._restApiHelper, 'fetchCacheURL');
-    });
-
-    test('normal use', () => {
-      element.getRepos('test', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=test');
-
-      element.getRepos(null, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          `/projects/?n=26&S=0&query=${defaultQuery}`);
-
-      element.getRepos('test', 25, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=25&query=test');
-    });
-
-    test('with blank', () => {
-      element.getRepos('test/test', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=inname%3Atest%20AND%20inname%3Atest');
-    });
-
-    test('with hyphen', () => {
-      element.getRepos('foo-bar', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-    });
-
-    test('with leading hyphen', () => {
-      element.getRepos('-bar', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=inname%3Abar');
-    });
-
-    test('with trailing hyphen', () => {
-      element.getRepos('foo-bar-', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-    });
-
-    test('with underscore', () => {
-      element.getRepos('foo_bar', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-    });
-
-    test('with underscore', () => {
-      element.getRepos('foo_bar', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-    });
-
-    test('hyphen only', () => {
-      element.getRepos('-', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          `/projects/?n=26&S=0&query=${defaultQuery}`);
-    });
-  });
-
-  test('_getGroupsUrl normal use', () => {
-    assert.equal(element._getGroupsUrl('test', 25),
-        '/groups/?n=26&S=0&m=test');
-
-    assert.equal(element._getGroupsUrl(null, 25),
-        '/groups/?n=26&S=0');
-
-    assert.equal(element._getGroupsUrl('test', 25, 25),
-        '/groups/?n=26&S=25&m=test');
-  });
-
-  test('invalidateGroupsCache', () => {
-    const url = '/groups/?n=26&S=0&m=test';
-
-    element._cache.set(url, {});
-
-    element.invalidateGroupsCache();
-
-    assert.isUndefined(element._sharedFetchPromises[url]);
-
-    assert.isFalse(element._cache.has(url));
-  });
-
-  suite('getGroups', () => {
-    let fetchCacheURLStub;
-    setup(() => {
-      fetchCacheURLStub =
-          sandbox.stub(element._restApiHelper, 'fetchCacheURL');
-    });
-
-    test('normal use', () => {
-      element.getGroups('test', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=0&m=test');
-
-      element.getGroups(null, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=0');
-
-      element.getGroups('test', 25, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=25&m=test');
-    });
-
-    test('regex', () => {
-      element.getGroups('^test.*', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=0&r=%5Etest.*');
-
-      element.getGroups('^test.*', 25, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=25&r=%5Etest.*');
-    });
-  });
-
-  test('gerrit auth is used', () => {
-    sandbox.stub(authService, 'fetch').returns(Promise.resolve());
-    element._restApiHelper.fetchJSON({url: 'foo'});
-    assert(authService.fetch.called);
-  });
-
-  test('getSuggestedAccounts does not return _fetchJSON', () => {
-    const _fetchJSONSpy = sandbox.spy(element._restApiHelper, 'fetchJSON');
-    return element.getSuggestedAccounts().then(accts => {
-      assert.isFalse(_fetchJSONSpy.called);
-      assert.equal(accts.length, 0);
-    });
-  });
-
-  test('_fetchJSON gets called by getSuggestedAccounts', () => {
-    const _fetchJSONStub = sandbox.stub(element._restApiHelper, 'fetchJSON',
-        () => Promise.resolve());
-    return element.getSuggestedAccounts('own').then(() => {
-      assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
-        q: 'own',
-        suggest: null,
-      });
-    });
-  });
-
-  suite('getChangeDetail', () => {
-    suite('change detail options', () => {
-      let toHexStub;
-
-      setup(() => {
-        toHexStub = sandbox.stub(element, 'listChangesOptionsToHex',
-            options => 'deadbeef');
-        sandbox.stub(element, '_getChangeDetail',
-            async (changeNum, options) => { return {changeNum, options}; });
-      });
-
-      test('signed pushes disabled', async () => {
-        const {PUSH_CERTIFICATES} = element.ListChangesOption;
-        sandbox.stub(element, 'getConfig', async () => { return {}; });
-        const {changeNum, options} = await element.getChangeDetail(123);
-        assert.strictEqual(123, changeNum);
-        assert.strictEqual('deadbeef', options);
-        assert.isTrue(toHexStub.calledOnce);
-        assert.isFalse(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
-      });
-
-      test('signed pushes enabled', async () => {
-        const {PUSH_CERTIFICATES} = element.ListChangesOption;
-        sandbox.stub(element, 'getConfig', async () => {
-          return {receive: {enable_signed_push: true}};
-        });
-        const {changeNum, options} = await element.getChangeDetail(123);
-        assert.strictEqual(123, changeNum);
-        assert.strictEqual('deadbeef', options);
-        assert.isTrue(toHexStub.calledOnce);
-        assert.isTrue(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
-      });
-    });
-
-    test('GrReviewerUpdatesParser.parse is used', () => {
-      sandbox.stub(GrReviewerUpdatesParser, 'parse').returns(
-          Promise.resolve('foo'));
-      return element.getChangeDetail(42).then(result => {
-        assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
-        assert.equal(result, 'foo');
-      });
-    });
-
-    test('_getChangeDetail passes params to ETags decorator', () => {
-      const changeNum = 4321;
-      element._projectLookup[changeNum] = 'test';
-      const expectedUrl =
-          window.CANONICAL_PATH + '/changes/test~4321/detail?'+
-          '0=5&1=1&2=6&3=7&4=1&5=4';
-      sandbox.stub(element._etags, 'getOptions');
-      sandbox.stub(element._etags, 'collect');
-      return element._getChangeDetail(changeNum, '516714').then(() => {
-        assert.isTrue(element._etags.getOptions.calledWithExactly(
-            expectedUrl));
-        assert.equal(element._etags.collect.lastCall.args[0], expectedUrl);
-      });
-    });
-
-    test('_getChangeDetail calls errFn on 500', () => {
-      const errFn = sinon.stub();
-      sandbox.stub(element, 'getChangeActionURL')
-          .returns(Promise.resolve(''));
-      sandbox.stub(element._restApiHelper, 'fetchRawJSON')
-          .returns(Promise.resolve({ok: false, status: 500}));
-      return element._getChangeDetail(123, '516714', errFn).then(() => {
-        assert.isTrue(errFn.called);
-      });
-    });
-
-    test('_getChangeDetail populates _projectLookup', () => {
-      sandbox.stub(element, 'getChangeActionURL')
-          .returns(Promise.resolve(''));
-      sandbox.stub(element._restApiHelper, 'fetchRawJSON')
-          .returns(Promise.resolve({ok: true}));
-
-      const mockResponse = {_number: 1, project: 'test'};
-      sandbox.stub(element._restApiHelper, 'readResponsePayload')
-          .returns(Promise.resolve({
-            parsed: mockResponse,
-            raw: JSON.stringify(mockResponse),
-          }));
-      return element._getChangeDetail(1, '516714').then(() => {
-        assert.equal(Object.keys(element._projectLookup).length, 1);
-        assert.equal(element._projectLookup[1], 'test');
-      });
-    });
-
-    suite('_getChangeDetail ETag cache', () => {
-      let requestUrl;
-      let mockResponseSerial;
-      let collectSpy;
-      let getPayloadSpy;
-
-      setup(() => {
-        requestUrl = '/foo/bar';
-        const mockResponse = {foo: 'bar', baz: 42};
-        mockResponseSerial = element.JSON_PREFIX +
-            JSON.stringify(mockResponse);
-        sandbox.stub(element._restApiHelper, 'urlWithParams')
-            .returns(requestUrl);
-        sandbox.stub(element, 'getChangeActionURL')
-            .returns(Promise.resolve(requestUrl));
-        collectSpy = sandbox.spy(element._etags, 'collect');
-        getPayloadSpy = sandbox.spy(element._etags, 'getCachedPayload');
-      });
-
-      test('contributes to cache', () => {
-        sandbox.stub(element._restApiHelper, 'fetchRawJSON')
-            .returns(Promise.resolve({
-              text: () => Promise.resolve(mockResponseSerial),
-              status: 200,
-              ok: true,
-            }));
-
-        return element._getChangeDetail(123, '516714').then(detail => {
-          assert.isFalse(getPayloadSpy.called);
-          assert.isTrue(collectSpy.calledOnce);
-          const cachedResponse = element._etags.getCachedPayload(requestUrl);
-          assert.equal(cachedResponse, mockResponseSerial);
-        });
-      });
-
-      test('uses cache on HTTP 304', () => {
-        sandbox.stub(element._restApiHelper, 'fetchRawJSON')
-            .returns(Promise.resolve({
-              text: () => Promise.resolve(mockResponseSerial),
-              status: 304,
-              ok: true,
-            }));
-
-        return element._getChangeDetail(123, {}).then(detail => {
-          assert.isFalse(collectSpy.called);
-          assert.isTrue(getPayloadSpy.calledOnce);
-        });
-      });
-    });
-  });
-
-  test('setInProjectLookup', () => {
-    element.setInProjectLookup('test', 'project');
-    assert.deepEqual(element._projectLookup, {test: 'project'});
-  });
-
-  suite('getFromProjectLookup', () => {
-    test('getChange fails', () => {
-      sandbox.stub(element, 'getChange')
-          .returns(Promise.resolve(null));
-      return element.getFromProjectLookup().then(val => {
-        assert.strictEqual(val, undefined);
-        assert.deepEqual(element._projectLookup, {});
-      });
-    });
-
-    test('getChange succeeds, no project', () => {
-      sandbox.stub(element, 'getChange').returns(Promise.resolve(null));
-      return element.getFromProjectLookup().then(val => {
-        assert.strictEqual(val, undefined);
-        assert.deepEqual(element._projectLookup, {});
-      });
-    });
-
-    test('getChange succeeds with project', () => {
-      sandbox.stub(element, 'getChange')
-          .returns(Promise.resolve({project: 'project'}));
-      return element.getFromProjectLookup('test').then(val => {
-        assert.equal(val, 'project');
-        assert.deepEqual(element._projectLookup, {test: 'project'});
-      });
-    });
-  });
-
-  suite('getChanges populates _projectLookup', () => {
-    test('multiple queries', () => {
-      sandbox.stub(element._restApiHelper, 'fetchJSON')
-          .returns(Promise.resolve([
-            [
-              {_number: 1, project: 'test'},
-              {_number: 2, project: 'test'},
-            ], [
-              {_number: 3, project: 'test/test'},
-            ],
-          ]));
-      // When opt_query instanceof Array, _fetchJSON returns
-      // Array<Array<Object>>.
-      return element.getChanges(null, []).then(() => {
-        assert.equal(Object.keys(element._projectLookup).length, 3);
-        assert.equal(element._projectLookup[1], 'test');
-        assert.equal(element._projectLookup[2], 'test');
-        assert.equal(element._projectLookup[3], 'test/test');
-      });
-    });
-
-    test('no query', () => {
-      sandbox.stub(element._restApiHelper, 'fetchJSON')
-          .returns(Promise.resolve([
-            {_number: 1, project: 'test'},
-            {_number: 2, project: 'test'},
-            {_number: 3, project: 'test/test'},
-          ]));
-
-      // When opt_query !instanceof Array, _fetchJSON returns
-      // Array<Object>.
-      return element.getChanges().then(() => {
-        assert.equal(Object.keys(element._projectLookup).length, 3);
-        assert.equal(element._projectLookup[1], 'test');
-        assert.equal(element._projectLookup[2], 'test');
-        assert.equal(element._projectLookup[3], 'test/test');
-      });
-    });
-  });
-
-  test('_getChangeURLAndFetch', () => {
-    element._projectLookup = {1: 'test'};
-    const fetchStub = sandbox.stub(element._restApiHelper, 'fetchJSON')
-        .returns(Promise.resolve());
-    const req = {changeNum: 1, endpoint: '/test', patchNum: 1};
-    return element._getChangeURLAndFetch(req).then(() => {
-      assert.equal(fetchStub.lastCall.args[0].url,
-          '/changes/test~1/revisions/1/test');
-    });
-  });
-
-  test('_getChangeURLAndSend', () => {
-    element._projectLookup = {1: 'test'};
-    const sendStub = sandbox.stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve());
-
-    const req = {
-      changeNum: 1,
-      method: 'POST',
-      patchNum: 1,
-      endpoint: '/test',
-    };
-    return element._getChangeURLAndSend(req).then(() => {
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].method, 'POST');
-      assert.equal(sendStub.lastCall.args[0].url,
-          '/changes/test~1/revisions/1/test');
-    });
-  });
-
-  suite('reading responses', () => {
-    test('_readResponsePayload', () => {
-      const mockObject = {foo: 'bar', baz: 'foo'};
-      const serial = element.JSON_PREFIX + JSON.stringify(mockObject);
-      const mockResponse = {text: () => Promise.resolve(serial)};
-      return element._restApiHelper.readResponsePayload(mockResponse)
-          .then(payload => {
-            assert.deepEqual(payload.parsed, mockObject);
-            assert.equal(payload.raw, serial);
-          });
-    });
-
-    test('_parsePrefixedJSON', () => {
-      const obj = {x: 3, y: {z: 4}, w: 23};
-      const serial = element.JSON_PREFIX + JSON.stringify(obj);
-      const result = element._restApiHelper.parsePrefixedJSON(serial);
-      assert.deepEqual(result, obj);
-    });
-  });
-
-  test('setChangeTopic', () => {
-    const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
-    return element.setChangeTopic(123, 'foo-bar').then(() => {
-      assert.isTrue(sendSpy.calledOnce);
-      assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
-    });
-  });
-
-  test('setChangeHashtag', () => {
-    const sendSpy = sandbox.spy(element, '_getChangeURLAndSend');
-    return element.setChangeHashtag(123, 'foo-bar').then(() => {
-      assert.isTrue(sendSpy.calledOnce);
-      assert.equal(sendSpy.lastCall.args[0].body, 'foo-bar');
-    });
-  });
-
-  test('generateAccountHttpPassword', () => {
-    const sendSpy = sandbox.spy(element._restApiHelper, 'send');
-    return element.generateAccountHttpPassword().then(() => {
-      assert.isTrue(sendSpy.calledOnce);
-      assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
-    });
-  });
-
-  suite('getChangeFiles', () => {
-    test('patch only', () => {
-      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      const range = {basePatchNum: 'PARENT', patchNum: 2};
-      return element.getChangeFiles(123, range).then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
-        assert.isNotOk(fetchStub.lastCall.args[0].params);
-      });
-    });
-
-    test('simple range', () => {
-      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      const range = {basePatchNum: 4, patchNum: 5};
-      return element.getChangeFiles(123, range).then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-      });
-    });
-
-    test('parent index', () => {
-      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      const range = {basePatchNum: -3, patchNum: 5};
-      return element.getChangeFiles(123, range).then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
-      });
-    });
-  });
-
-  suite('getDiff', () => {
-    test('patchOnly', () => {
-      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-      });
-    });
-
-    test('simple range', () => {
-      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
-      });
-    });
-
-    test('parent index', () => {
-      const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
-      });
-    });
-  });
-
-  test('getDashboard', () => {
-    const fetchCacheURLStub = sandbox.stub(element._restApiHelper,
-        'fetchCacheURL');
-    element.getDashboard('gerrit/project', 'default:main');
-    assert.isTrue(fetchCacheURLStub.calledOnce);
-    assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
-        '/projects/gerrit%2Fproject/dashboards/default%3Amain');
-  });
-
-  test('getFileContent', () => {
-    sandbox.stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve({
-          ok: 'true',
-          headers: {
-            get(header) {
-              if (header === 'X-FYI-Content-Type') {
-                return 'text/java';
-              }
-            },
-          },
-        }));
-
-    sandbox.stub(element, 'getResponseObject')
-        .returns(Promise.resolve('new content'));
-
-    const edit = element.getFileContent('1', 'tst/path', 'EDIT').then(res => {
-      assert.deepEqual(res,
-          {content: 'new content', type: 'text/java', ok: true});
-    });
-
-    const normal = element.getFileContent('1', 'tst/path', '3').then(res => {
-      assert.deepEqual(res,
-          {content: 'new content', type: 'text/java', ok: true});
-    });
-
-    return Promise.all([edit, normal]);
-  });
-
-  test('getFileContent suppresses 404s', done => {
-    const res = {status: 404};
-    const handler = e => {
-      assert.isFalse(e.detail.res.status === 404);
-      done();
-    };
-    element.addEventListener('server-error', handler);
-    sandbox.stub(authService, 'fetch').returns(Promise.resolve(res));
-    sandbox.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
-    element.getFileContent('1', 'tst/path', '1').then(() => {
-      flushAsynchronousOperations();
-
-      res.status = 500;
-      element.getFileContent('1', 'tst/path', '1');
-    });
-  });
-
-  test('getChangeFilesOrEditFiles is edit-sensitive', () => {
-    const fn = element.getChangeOrEditFiles.bind(element);
-    const getChangeFilesStub = sandbox.stub(element, 'getChangeFiles')
-        .returns(Promise.resolve({}));
-    const getChangeEditFilesStub = sandbox.stub(element, 'getChangeEditFiles')
-        .returns(Promise.resolve({}));
-
-    return fn('1', {patchNum: 'edit'}).then(() => {
-      assert.isTrue(getChangeEditFilesStub.calledOnce);
-      assert.isFalse(getChangeFilesStub.called);
-      return fn('1', {patchNum: '1'}).then(() => {
-        assert.isTrue(getChangeEditFilesStub.calledOnce);
-        assert.isTrue(getChangeFilesStub.calledOnce);
-      });
-    });
-  });
-
-  test('_fetch forwards request and logs', () => {
-    const logStub = sandbox.stub(element._restApiHelper, '_logCall');
-    const response = {status: 404, text: sinon.stub()};
-    const url = 'my url';
-    const fetchOptions = {method: 'DELETE'};
-    sandbox.stub(element._auth, 'fetch').returns(Promise.resolve(response));
-    const startTime = 123;
-    sandbox.stub(Date, 'now').returns(startTime);
-    const req = {url, fetchOptions};
-    return element._restApiHelper.fetch(req).then(() => {
-      assert.isTrue(logStub.calledOnce);
-      assert.isTrue(logStub.calledWith(req, startTime, response.status));
-      assert.isFalse(response.text.called);
-    });
-  });
-
-  test('_logCall only reports requests with anonymized URLss', () => {
-    sandbox.stub(Date, 'now').returns(200);
-    const handler = sinon.stub();
-    element.addEventListener('rpc-log', handler);
-
-    element._restApiHelper._logCall({url: 'url'}, 100, 200);
-    assert.isFalse(handler.called);
-
-    element._restApiHelper
-        ._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
-    flushAsynchronousOperations();
-    assert.isTrue(handler.calledOnce);
-  });
-
-  test('saveChangeStarred', async () => {
-    sandbox.stub(element, 'getFromProjectLookup')
-        .returns(Promise.resolve('test'));
-    const sendStub =
-        sandbox.stub(element._restApiHelper, 'send').returns(Promise.resolve());
-
-    await element.saveChangeStarred(123, true);
-    assert.isTrue(sendStub.calledOnce);
-    assert.deepEqual(sendStub.lastCall.args[0], {
-      method: 'PUT',
-      url: '/accounts/self/starred.changes/test~123',
-      anonymizedUrl: '/accounts/self/starred.changes/*',
-    });
-
-    await element.saveChangeStarred(456, false);
-    assert.isTrue(sendStub.calledTwice);
-    assert.deepEqual(sendStub.lastCall.args[0], {
-      method: 'DELETE',
-      url: '/accounts/self/starred.changes/test~456',
-      anonymizedUrl: '/accounts/self/starred.changes/*',
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
new file mode 100644
index 0000000..aadde88
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
@@ -0,0 +1,1379 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-rest-api-interface.js';
+import {mockPromise} from '../../../test/test-utils.js';
+import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
+import {ListChangesOption} from '../../../utils/change-util.js';
+import {appContext} from '../../../services/app-context.js';
+
+const basicFixture = fixtureFromElement('gr-rest-api-interface');
+
+suite('gr-rest-api-interface tests', () => {
+  let element;
+
+  let ctr = 0;
+  let originalCanonicalPath;
+
+  setup(() => {
+    // Modify CANONICAL_PATH to effectively reset cache.
+    ctr += 1;
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = `test${ctr}`;
+
+    const testJSON = ')]}\'\n{"hello": "bonjour"}';
+    sinon.stub(window, 'fetch').returns(Promise.resolve({
+      ok: true,
+      text() {
+        return Promise.resolve(testJSON);
+      },
+    }));
+    // fake auth
+    sinon.stub(appContext.authService, 'authCheck')
+        .returns(Promise.resolve(true));
+    element = basicFixture.instantiate();
+    element._projectLookup = {};
+  });
+
+  teardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  test('parent diff comments are properly grouped', done => {
+    sinon.stub(element._restApiHelper, 'fetchJSON')
+        .callsFake(() => Promise.resolve({
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
+              updated: '2017-02-03 22:32:28.000000000',
+              message: 'this isn’t quite right',
+            },
+            {
+              side: 'PARENT',
+              message: 'how did this work in the first place?',
+              updated: '2017-02-03 22:33:28.000000000',
+            },
+          ],
+        }));
+    element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
+        obj => {
+          assert.equal(obj.baseComments.length, 1);
+          assert.deepEqual(obj.baseComments[0], {
+            side: 'PARENT',
+            message: 'how did this work in the first place?',
+            path: 'sieve.go',
+            updated: '2017-02-03 22:33:28.000000000',
+          });
+          assert.equal(obj.comments.length, 1);
+          assert.deepEqual(obj.comments[0], {
+            message: 'this isn’t quite right',
+            path: 'sieve.go',
+            updated: '2017-02-03 22:32:28.000000000',
+          });
+          done();
+        });
+  });
+
+  test('_setRange', () => {
+    const comments = [
+      {
+        id: 1,
+        side: 'PARENT',
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: 2,
+        in_reply_to: 1,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000',
+      },
+    ];
+    const expectedResult = {
+      id: 2,
+      in_reply_to: 1,
+      message: 'this isn’t quite right',
+      updated: '2017-02-03 22:33:28.000000000',
+      range: {
+        start_line: 1,
+        start_character: 1,
+        end_line: 2,
+        end_character: 1,
+      },
+    };
+    const comment = comments[1];
+    assert.deepEqual(element._setRange(comments, comment), expectedResult);
+  });
+
+  test('_setRanges', () => {
+    const comments = [
+      {
+        id: 3,
+        in_reply_to: 2,
+        message: 'this isn’t quite right either',
+        updated: '2017-02-03 22:34:28.000000000',
+      },
+      {
+        id: 2,
+        in_reply_to: 1,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000',
+      },
+      {
+        id: 1,
+        side: 'PARENT',
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+    ];
+    const expectedResult = [
+      {
+        id: 1,
+        side: 'PARENT',
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: 2,
+        in_reply_to: 1,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: 3,
+        in_reply_to: 2,
+        message: 'this isn’t quite right either',
+        updated: '2017-02-03 22:34:28.000000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+    ];
+    assert.deepEqual(element._setRanges(comments), expectedResult);
+  });
+
+  test('differing patch diff comments are properly grouped', done => {
+    sinon.stub(element, 'getFromProjectLookup')
+        .returns(Promise.resolve('test'));
+    sinon.stub(element._restApiHelper, 'fetchJSON').callsFake( request => {
+      const url = request.url;
+      if (url === '/changes/test~42/revisions/1') {
+        return Promise.resolve({
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
+              message: 'this isn’t quite right',
+              updated: '2017-02-03 22:32:28.000000000',
+            },
+            {
+              side: 'PARENT',
+              message: 'how did this work in the first place?',
+              updated: '2017-02-03 22:33:28.000000000',
+            },
+          ],
+        });
+      } else if (url === '/changes/test~42/revisions/2') {
+        return Promise.resolve({
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
+              message: 'What on earth are you thinking, here?',
+              updated: '2017-02-03 22:32:28.000000000',
+            },
+            {
+              side: 'PARENT',
+              message: 'Yeah not sure how this worked either?',
+              updated: '2017-02-03 22:33:28.000000000',
+            },
+            {
+              message: '¯\\_(ツ)_/¯',
+              updated: '2017-02-04 22:33:28.000000000',
+            },
+          ],
+        });
+      }
+    });
+    element._getDiffComments('42', '', 1, 2, 'sieve.go').then(
+        obj => {
+          assert.equal(obj.baseComments.length, 1);
+          assert.deepEqual(obj.baseComments[0], {
+            message: 'this isn’t quite right',
+            path: 'sieve.go',
+            updated: '2017-02-03 22:32:28.000000000',
+          });
+          assert.equal(obj.comments.length, 2);
+          assert.deepEqual(obj.comments[0], {
+            message: 'What on earth are you thinking, here?',
+            path: 'sieve.go',
+            updated: '2017-02-03 22:32:28.000000000',
+          });
+          assert.deepEqual(obj.comments[1], {
+            message: '¯\\_(ツ)_/¯',
+            path: 'sieve.go',
+            updated: '2017-02-04 22:33:28.000000000',
+          });
+          done();
+        });
+  });
+
+  test('server error', done => {
+    const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
+    window.fetch.returns(Promise.resolve({ok: false}));
+    const serverErrorEventPromise = new Promise(resolve => {
+      element.addEventListener('server-error', resolve);
+    });
+
+    element._restApiHelper.fetchJSON({}).then(response => {
+      assert.isUndefined(response);
+      assert.isTrue(getResponseObjectStub.notCalled);
+      serverErrorEventPromise.then(() => done());
+    });
+  });
+
+  test('legacy n,z key in change url is replaced', async () => {
+    sinon.stub(element, 'getConfig').callsFake( async () => { return {}; });
+    const stub = sinon.stub(element._restApiHelper, 'fetchJSON')
+        .returns(Promise.resolve([]));
+    await element.getChanges(1, null, 'n,z');
+    assert.equal(stub.lastCall.args[0].params.S, 0);
+  });
+
+  test('saveDiffPreferences invalidates cache line', () => {
+    const cacheKey = '/accounts/self/preferences.diff';
+    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    element._cache.set(cacheKey, {tab_size: 4});
+    element.saveDiffPreferences({tab_size: 8});
+    assert.isTrue(sendStub.called);
+    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
+  });
+
+  test('getAccount when resp is null does not add anything to the cache',
+      done => {
+        const cacheKey = '/accounts/self/detail';
+        const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL')
+            .callsFake(() => Promise.resolve());
+
+        element.getAccount().then(() => {
+          assert.isTrue(stub.called);
+          assert.isFalse(element._restApiHelper._cache.has(cacheKey));
+          done();
+        });
+
+        element._restApiHelper._cache.set(cacheKey, 'fake cache');
+        stub.lastCall.args[0].errFn();
+      });
+
+  test('getAccount does not add to the cache when resp.status is 403',
+      done => {
+        const cacheKey = '/accounts/self/detail';
+        const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL')
+            .callsFake(() => Promise.resolve());
+
+        element.getAccount().then(() => {
+          assert.isTrue(stub.called);
+          assert.isFalse(element._restApiHelper._cache.has(cacheKey));
+          done();
+        });
+        element._cache.set(cacheKey, 'fake cache');
+        stub.lastCall.args[0].errFn({status: 403});
+      });
+
+  test('getAccount when resp is successful', done => {
+    const cacheKey = '/accounts/self/detail';
+    const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL').callsFake(
+        () => Promise.resolve());
+
+    element.getAccount().then(response => {
+      assert.isTrue(stub.called);
+      assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
+      done();
+    });
+    element._restApiHelper._cache.set(cacheKey, 'fake cache');
+
+    stub.lastCall.args[0].errFn({});
+  });
+
+  const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
+    sinon.stub(element, 'getLoggedIn')
+        .callsFake(() => Promise.resolve(loggedIn));
+    sinon.stub(element, '_isNarrowScreen').callsFake(() => smallScreen);
+    sinon.stub(
+        element._restApiHelper,
+        'fetchCacheURL')
+        .callsFake(() => Promise.resolve(testJSON));
+  };
+
+  test('getPreferences returns correctly on small screens logged in',
+      done => {
+        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+        const loggedIn = true;
+        const smallScreen = true;
+
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+
+        element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+          done();
+        });
+      });
+
+  test('getPreferences returns correctly on small screens not logged in',
+      done => {
+        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+        const loggedIn = false;
+        const smallScreen = true;
+
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+        element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+          done();
+        });
+      });
+
+  test('getPreferences returns correctly on larger screens logged in',
+      done => {
+        const testJSON = {diff_view: 'UNIFIED_DIFF'};
+        const loggedIn = true;
+        const smallScreen = false;
+
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+
+        element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+          assert.equal(obj.diff_view, 'UNIFIED_DIFF');
+          done();
+        });
+      });
+
+  test('getPreferences returns correctly on larger screens not logged in',
+      done => {
+        const testJSON = {diff_view: 'UNIFIED_DIFF'};
+        const loggedIn = false;
+        const smallScreen = false;
+
+        preferenceSetup(testJSON, loggedIn, smallScreen);
+
+        element.getPreferences().then(obj => {
+          assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
+          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+          done();
+        });
+      });
+
+  test('savPreferences normalizes download scheme', () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    element.savePreferences({download_scheme: 'HTTP'});
+    assert.isTrue(sendStub.called);
+    assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
+  });
+
+  test('getDiffPreferences returns correct defaults', done => {
+    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
+
+    element.getDiffPreferences().then(obj => {
+      assert.equal(obj.auto_hide_diff_table_header, true);
+      assert.equal(obj.context, 10);
+      assert.equal(obj.cursor_blink_rate, 0);
+      assert.equal(obj.font_size, 12);
+      assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
+      assert.equal(obj.intraline_difference, true);
+      assert.equal(obj.line_length, 100);
+      assert.equal(obj.line_wrapping, false);
+      assert.equal(obj.show_line_endings, true);
+      assert.equal(obj.show_tabs, true);
+      assert.equal(obj.show_whitespace_errors, true);
+      assert.equal(obj.syntax_highlighting, true);
+      assert.equal(obj.tab_size, 8);
+      assert.equal(obj.theme, 'DEFAULT');
+      done();
+    });
+  });
+
+  test('saveDiffPreferences set show_tabs to false', () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    element.saveDiffPreferences({show_tabs: false});
+    assert.isTrue(sendStub.called);
+    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
+  });
+
+  test('getEditPreferences returns correct defaults', done => {
+    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
+
+    element.getEditPreferences().then(obj => {
+      assert.equal(obj.auto_close_brackets, false);
+      assert.equal(obj.cursor_blink_rate, 0);
+      assert.equal(obj.hide_line_numbers, false);
+      assert.equal(obj.hide_top_menu, false);
+      assert.equal(obj.indent_unit, 2);
+      assert.equal(obj.indent_with_tabs, false);
+      assert.equal(obj.key_map_type, 'DEFAULT');
+      assert.equal(obj.line_length, 100);
+      assert.equal(obj.line_wrapping, false);
+      assert.equal(obj.match_brackets, true);
+      assert.equal(obj.show_base, false);
+      assert.equal(obj.show_tabs, true);
+      assert.equal(obj.show_whitespace_errors, true);
+      assert.equal(obj.syntax_highlighting, true);
+      assert.equal(obj.tab_size, 8);
+      assert.equal(obj.theme, 'DEFAULT');
+      done();
+    });
+  });
+
+  test('saveEditPreferences set show_tabs to false', () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    element.saveEditPreferences({show_tabs: false});
+    assert.isTrue(sendStub.called);
+    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
+  });
+
+  test('confirmEmail', () => {
+    const sendStub = sinon.spy(element._restApiHelper, 'send');
+    element.confirmEmail('foo');
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, 'PUT');
+    assert.equal(sendStub.lastCall.args[0].url,
+        '/config/server/email.confirm');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
+  });
+
+  test('setAccountStatus', () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve('OOO'));
+    element._cache.set('/accounts/self/detail', {});
+    return element.setAccountStatus('OOO').then(() => {
+      assert.isTrue(sendStub.calledOnce);
+      assert.equal(sendStub.lastCall.args[0].method, 'PUT');
+      assert.equal(sendStub.lastCall.args[0].url,
+          '/accounts/self/status');
+      assert.deepEqual(sendStub.lastCall.args[0].body,
+          {status: 'OOO'});
+      assert.deepEqual(element._restApiHelper
+          ._cache.get('/accounts/self/detail'),
+      {status: 'OOO'});
+    });
+  });
+
+  suite('draft comments', () => {
+    test('_sendDiffDraftRequest pending requests tracked', () => {
+      const obj = element._pendingRequests;
+      sinon.stub(element, '_getChangeURLAndSend')
+          .callsFake(() => mockPromise());
+      assert.notOk(element.hasPendingDiffDrafts());
+
+      element._sendDiffDraftRequest(null, null, null, {});
+      assert.equal(obj.sendDiffDraft.length, 1);
+      assert.isTrue(!!element.hasPendingDiffDrafts());
+
+      element._sendDiffDraftRequest(null, null, null, {});
+      assert.equal(obj.sendDiffDraft.length, 2);
+      assert.isTrue(!!element.hasPendingDiffDrafts());
+
+      for (const promise of obj.sendDiffDraft) { promise.resolve(); }
+
+      return element.awaitPendingDiffDrafts().then(() => {
+        assert.equal(obj.sendDiffDraft.length, 0);
+        assert.isFalse(!!element.hasPendingDiffDrafts());
+      });
+    });
+
+    suite('_failForCreate200', () => {
+      test('_sendDiffDraftRequest checks for 200 on create', () => {
+        const sendPromise = Promise.resolve();
+        sinon.stub(element, '_getChangeURLAndSend').returns(sendPromise);
+        const failStub = sinon.stub(element, '_failForCreate200')
+            .returns(Promise.resolve());
+        return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
+          assert.isTrue(failStub.calledOnce);
+          assert.isTrue(failStub.calledWithExactly(sendPromise));
+        });
+      });
+
+      test('_sendDiffDraftRequest no checks for 200 on non create', () => {
+        sinon.stub(element, '_getChangeURLAndSend')
+            .returns(Promise.resolve());
+        const failStub = sinon.stub(element, '_failForCreate200')
+            .returns(Promise.resolve());
+        return element._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
+            .then(() => {
+              assert.isFalse(failStub.called);
+            });
+      });
+
+      test('_failForCreate200 fails on 200', done => {
+        const result = {
+          ok: true,
+          status: 200,
+          headers: {entries: () => [
+            ['Set-CoOkiE', 'secret'],
+            ['Innocuous', 'hello'],
+          ]},
+        };
+        element._failForCreate200(Promise.resolve(result))
+            .then(() => {
+              assert.isTrue(false, 'Promise should not resolve');
+            })
+            .catch(e => {
+              assert.isOk(e);
+              assert.include(e.message, 'Saving draft resulted in HTTP 200');
+              assert.include(e.message, 'hello');
+              assert.notInclude(e.message, 'secret');
+              done();
+            });
+      });
+
+      test('_failForCreate200 does not fail on 201', done => {
+        const result = {
+          ok: true,
+          status: 201,
+          headers: {entries: () => []},
+        };
+        element._failForCreate200(Promise.resolve(result))
+            .then(() => {
+              done();
+            })
+            .catch(e => {
+              assert.isTrue(false, 'Promise should not fail');
+            });
+      });
+    });
+  });
+
+  test('saveChangeEdit', () => {
+    element._projectLookup = {1: 'test'};
+    const change_num = '1';
+    const file_name = 'index.php';
+    const file_contents = '<?php';
+    sinon.stub(element._restApiHelper, 'send').returns(
+        Promise.resolve([change_num, file_name, file_contents]));
+    sinon.stub(element, 'getResponseObject')
+        .returns(Promise.resolve([change_num, file_name, file_contents]));
+    element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
+    return element.saveChangeEdit(change_num, file_name, file_contents)
+        .then(() => {
+          assert.isTrue(element._restApiHelper.send.calledOnce);
+          assert.equal(element._restApiHelper.send.lastCall.args[0].method,
+              'PUT');
+          assert.equal(element._restApiHelper.send.lastCall.args[0].url,
+              '/changes/test~1/edit/' + file_name);
+          assert.equal(element._restApiHelper.send.lastCall.args[0].body,
+              file_contents);
+        });
+  });
+
+  test('putChangeCommitMessage', () => {
+    element._projectLookup = {1: 'test'};
+    const change_num = '1';
+    const message = 'this is a commit message';
+    sinon.stub(element._restApiHelper, 'send').returns(
+        Promise.resolve([change_num, message]));
+    sinon.stub(element, 'getResponseObject')
+        .returns(Promise.resolve([change_num, message]));
+    element._cache.set('/changes/' + change_num + '/message', {});
+    return element.putChangeCommitMessage(change_num, message).then(() => {
+      assert.isTrue(element._restApiHelper.send.calledOnce);
+      assert.equal(element._restApiHelper.send.lastCall.args[0].method, 'PUT');
+      assert.equal(element._restApiHelper.send.lastCall.args[0].url,
+          '/changes/test~1/message');
+      assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body,
+          {message});
+    });
+  });
+
+  test('deleteChangeCommitMessage', () => {
+    element._projectLookup = {1: 'test'};
+    const change_num = '1';
+    const messageId = 'abc';
+    sinon.stub(element._restApiHelper, 'send').returns(
+        Promise.resolve([change_num, messageId]));
+    sinon.stub(element, 'getResponseObject')
+        .returns(Promise.resolve([change_num, messageId]));
+    return element.deleteChangeCommitMessage(change_num, messageId).then(() => {
+      assert.isTrue(element._restApiHelper.send.calledOnce);
+      assert.equal(
+          element._restApiHelper.send.lastCall.args[0].method,
+          'DELETE'
+      );
+      assert.equal(element._restApiHelper.send.lastCall.args[0].url,
+          '/changes/test~1/messages/abc');
+    });
+  });
+
+  test('startWorkInProgress', () => {
+    const sendStub = sinon.stub(element, '_getChangeURLAndSend')
+        .returns(Promise.resolve('ok'));
+    element.startWorkInProgress('42');
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+    assert.equal(sendStub.lastCall.args[0].method, 'POST');
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {});
+
+    element.startWorkInProgress('42', 'revising...');
+    assert.isTrue(sendStub.calledTwice);
+    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+    assert.equal(sendStub.lastCall.args[0].method, 'POST');
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+    assert.deepEqual(sendStub.lastCall.args[0].body,
+        {message: 'revising...'});
+  });
+
+  test('startReview', () => {
+    const sendStub = sinon.stub(element, '_getChangeURLAndSend')
+        .returns(Promise.resolve({}));
+    element.startReview('42', {message: 'Please review.'});
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].changeNum, '42');
+    assert.equal(sendStub.lastCall.args[0].method, 'POST');
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/ready');
+    assert.deepEqual(sendStub.lastCall.args[0].body,
+        {message: 'Please review.'});
+  });
+
+  test('deleteComment', () => {
+    const sendStub = sinon.stub(element, '_getChangeURLAndSend')
+        .returns(Promise.resolve('some response'));
+    return element.deleteComment('foo', 'bar', '01234', 'removal reason')
+        .then(response => {
+          assert.equal(response, 'some response');
+          assert.isTrue(sendStub.calledOnce);
+          assert.equal(sendStub.lastCall.args[0].changeNum, 'foo');
+          assert.equal(sendStub.lastCall.args[0].method, 'POST');
+          assert.equal(sendStub.lastCall.args[0].patchNum, 'bar');
+          assert.equal(sendStub.lastCall.args[0].endpoint,
+              '/comments/01234/delete');
+          assert.deepEqual(sendStub.lastCall.args[0].body,
+              {reason: 'removal reason'});
+        });
+  });
+
+  test('createRepo encodes name', () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve());
+    return element.createRepo({name: 'x/y'}).then(() => {
+      assert.isTrue(sendStub.calledOnce);
+      assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
+    });
+  });
+
+  test('queryChangeFiles', () => {
+    const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+        .returns(Promise.resolve());
+    return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
+      assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
+      assert.equal(fetchStub.lastCall.args[0].endpoint,
+          '/files?q=test%2Fpath.js');
+      assert.equal(fetchStub.lastCall.args[0].patchNum, 'edit');
+    });
+  });
+
+  test('normal use', () => {
+    const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
+
+    assert.equal(element._getReposUrl('test', 25),
+        '/projects/?n=26&S=0&query=test');
+
+    assert.equal(element._getReposUrl(null, 25),
+        `/projects/?n=26&S=0&query=${defaultQuery}`);
+
+    assert.equal(element._getReposUrl('test', 25, 25),
+        '/projects/?n=26&S=25&query=test');
+  });
+
+  test('invalidateReposCache', () => {
+    const url = '/projects/?n=26&S=0&query=test';
+
+    element._cache.set(url, {});
+
+    element.invalidateReposCache();
+
+    assert.isUndefined(element._sharedFetchPromises[url]);
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  test('invalidateAccountsCache', () => {
+    const url = '/accounts/self/detail';
+
+    element._cache.set(url, {});
+
+    element.invalidateAccountsCache();
+
+    assert.isUndefined(element._sharedFetchPromises[url]);
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  suite('getRepos', () => {
+    const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
+    let fetchCacheURLStub;
+    setup(() => {
+      fetchCacheURLStub =
+          sinon.stub(element._restApiHelper, 'fetchCacheURL');
+    });
+
+    test('normal use', () => {
+      element.getRepos('test', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=test');
+
+      element.getRepos(null, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          `/projects/?n=26&S=0&query=${defaultQuery}`);
+
+      element.getRepos('test', 25, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=25&query=test');
+    });
+
+    test('with blank', () => {
+      element.getRepos('test/test', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Atest%20AND%20inname%3Atest');
+    });
+
+    test('with hyphen', () => {
+      element.getRepos('foo-bar', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+    });
+
+    test('with leading hyphen', () => {
+      element.getRepos('-bar', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Abar');
+    });
+
+    test('with trailing hyphen', () => {
+      element.getRepos('foo-bar-', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+    });
+
+    test('with underscore', () => {
+      element.getRepos('foo_bar', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+    });
+
+    test('with underscore', () => {
+      element.getRepos('foo_bar', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
+    });
+
+    test('hyphen only', () => {
+      element.getRepos('-', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          `/projects/?n=26&S=0&query=${defaultQuery}`);
+    });
+  });
+
+  test('_getGroupsUrl normal use', () => {
+    assert.equal(element._getGroupsUrl('test', 25),
+        '/groups/?n=26&S=0&m=test');
+
+    assert.equal(element._getGroupsUrl(null, 25),
+        '/groups/?n=26&S=0');
+
+    assert.equal(element._getGroupsUrl('test', 25, 25),
+        '/groups/?n=26&S=25&m=test');
+  });
+
+  test('invalidateGroupsCache', () => {
+    const url = '/groups/?n=26&S=0&m=test';
+
+    element._cache.set(url, {});
+
+    element.invalidateGroupsCache();
+
+    assert.isUndefined(element._sharedFetchPromises[url]);
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  suite('getGroups', () => {
+    let fetchCacheURLStub;
+    setup(() => {
+      fetchCacheURLStub =
+          sinon.stub(element._restApiHelper, 'fetchCacheURL');
+    });
+
+    test('normal use', () => {
+      element.getGroups('test', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=0&m=test');
+
+      element.getGroups(null, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=0');
+
+      element.getGroups('test', 25, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=25&m=test');
+    });
+
+    test('regex', () => {
+      element.getGroups('^test.*', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=0&r=%5Etest.*');
+
+      element.getGroups('^test.*', 25, 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
+          '/groups/?n=26&S=25&r=%5Etest.*');
+    });
+  });
+
+  test('gerrit auth is used', () => {
+    sinon.stub(appContext.authService, 'fetch').returns(Promise.resolve());
+    element._restApiHelper.fetchJSON({url: 'foo'});
+    assert(appContext.authService.fetch.called);
+  });
+
+  test('getSuggestedAccounts does not return _fetchJSON', () => {
+    const _fetchJSONSpy = sinon.spy(element._restApiHelper, 'fetchJSON');
+    return element.getSuggestedAccounts().then(accts => {
+      assert.isFalse(_fetchJSONSpy.called);
+      assert.equal(accts.length, 0);
+    });
+  });
+
+  test('_fetchJSON gets called by getSuggestedAccounts', () => {
+    const _fetchJSONStub = sinon.stub(element._restApiHelper, 'fetchJSON')
+        .callsFake(() => Promise.resolve());
+    return element.getSuggestedAccounts('own').then(() => {
+      assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
+        q: 'own',
+        suggest: null,
+      });
+    });
+  });
+
+  suite('getChangeDetail', () => {
+    suite('change detail options', () => {
+      setup(() => {
+        sinon.stub(element, '_getChangeDetail').callsFake(
+            async (changeNum, options) => { return {changeNum, options}; });
+      });
+
+      test('signed pushes disabled', async () => {
+        sinon.stub(element, 'getConfig').callsFake( async () => { return {}; });
+        const {changeNum, options} = await element.getChangeDetail(123);
+        assert.strictEqual(123, changeNum);
+        assert.isNotOk(
+            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES));
+      });
+
+      test('signed pushes enabled', async () => {
+        sinon.stub(element, 'getConfig').callsFake( async () => {
+          return {receive: {enable_signed_push: true}};
+        });
+        const {changeNum, options} = await element.getChangeDetail(123);
+        assert.strictEqual(123, changeNum);
+        assert.ok(
+            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES));
+      });
+    });
+
+    test('GrReviewerUpdatesParser.parse is used', () => {
+      sinon.stub(GrReviewerUpdatesParser, 'parse').returns(
+          Promise.resolve('foo'));
+      return element.getChangeDetail(42).then(result => {
+        assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
+        assert.equal(result, 'foo');
+      });
+    });
+
+    test('_getChangeDetail passes params to ETags decorator', () => {
+      const changeNum = 4321;
+      element._projectLookup[changeNum] = 'test';
+      const expectedUrl =
+          window.CANONICAL_PATH + '/changes/test~4321/detail?'+
+          '0=5&1=1&2=6&3=7&4=1&5=4';
+      sinon.stub(element._etags, 'getOptions');
+      sinon.stub(element._etags, 'collect');
+      return element._getChangeDetail(changeNum, '516714').then(() => {
+        assert.isTrue(element._etags.getOptions.calledWithExactly(
+            expectedUrl));
+        assert.equal(element._etags.collect.lastCall.args[0], expectedUrl);
+      });
+    });
+
+    test('_getChangeDetail calls errFn on 500', () => {
+      const errFn = sinon.stub();
+      sinon.stub(element, 'getChangeActionURL')
+          .returns(Promise.resolve(''));
+      sinon.stub(element._restApiHelper, 'fetchRawJSON')
+          .returns(Promise.resolve({ok: false, status: 500}));
+      return element._getChangeDetail(123, '516714', errFn).then(() => {
+        assert.isTrue(errFn.called);
+      });
+    });
+
+    test('_getChangeDetail populates _projectLookup', () => {
+      sinon.stub(element, 'getChangeActionURL')
+          .returns(Promise.resolve(''));
+      sinon.stub(element._restApiHelper, 'fetchRawJSON')
+          .returns(Promise.resolve({ok: true}));
+
+      const mockResponse = {_number: 1, project: 'test'};
+      sinon.stub(element._restApiHelper, 'readResponsePayload')
+          .returns(Promise.resolve({
+            parsed: mockResponse,
+            raw: JSON.stringify(mockResponse),
+          }));
+      return element._getChangeDetail(1, '516714').then(() => {
+        assert.equal(Object.keys(element._projectLookup).length, 1);
+        assert.equal(element._projectLookup[1], 'test');
+      });
+    });
+
+    suite('_getChangeDetail ETag cache', () => {
+      let requestUrl;
+      let mockResponseSerial;
+      let collectSpy;
+      let getPayloadSpy;
+
+      setup(() => {
+        requestUrl = '/foo/bar';
+        const mockResponse = {foo: 'bar', baz: 42};
+        mockResponseSerial = element.JSON_PREFIX +
+            JSON.stringify(mockResponse);
+        sinon.stub(element._restApiHelper, 'urlWithParams')
+            .returns(requestUrl);
+        sinon.stub(element, 'getChangeActionURL')
+            .returns(Promise.resolve(requestUrl));
+        collectSpy = sinon.spy(element._etags, 'collect');
+        getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
+      });
+
+      test('contributes to cache', () => {
+        sinon.stub(element._restApiHelper, 'fetchRawJSON')
+            .returns(Promise.resolve({
+              text: () => Promise.resolve(mockResponseSerial),
+              status: 200,
+              ok: true,
+            }));
+
+        return element._getChangeDetail(123, '516714').then(detail => {
+          assert.isFalse(getPayloadSpy.called);
+          assert.isTrue(collectSpy.calledOnce);
+          const cachedResponse = element._etags.getCachedPayload(requestUrl);
+          assert.equal(cachedResponse, mockResponseSerial);
+        });
+      });
+
+      test('uses cache on HTTP 304', () => {
+        sinon.stub(element._restApiHelper, 'fetchRawJSON')
+            .returns(Promise.resolve({
+              text: () => Promise.resolve(mockResponseSerial),
+              status: 304,
+              ok: true,
+            }));
+
+        return element._getChangeDetail(123, {}).then(detail => {
+          assert.isFalse(collectSpy.called);
+          assert.isTrue(getPayloadSpy.calledOnce);
+        });
+      });
+    });
+  });
+
+  test('setInProjectLookup', () => {
+    element.setInProjectLookup('test', 'project');
+    assert.deepEqual(element._projectLookup, {test: 'project'});
+  });
+
+  suite('getFromProjectLookup', () => {
+    test('getChange fails', () => {
+      sinon.stub(element, 'getChange')
+          .returns(Promise.resolve(null));
+      return element.getFromProjectLookup().then(val => {
+        assert.strictEqual(val, undefined);
+        assert.deepEqual(element._projectLookup, {});
+      });
+    });
+
+    test('getChange succeeds, no project', () => {
+      sinon.stub(element, 'getChange').returns(Promise.resolve(null));
+      return element.getFromProjectLookup().then(val => {
+        assert.strictEqual(val, undefined);
+        assert.deepEqual(element._projectLookup, {});
+      });
+    });
+
+    test('getChange succeeds with project', () => {
+      sinon.stub(element, 'getChange')
+          .returns(Promise.resolve({project: 'project'}));
+      return element.getFromProjectLookup('test').then(val => {
+        assert.equal(val, 'project');
+        assert.deepEqual(element._projectLookup, {test: 'project'});
+      });
+    });
+  });
+
+  suite('getChanges populates _projectLookup', () => {
+    test('multiple queries', () => {
+      sinon.stub(element._restApiHelper, 'fetchJSON')
+          .returns(Promise.resolve([
+            [
+              {_number: 1, project: 'test'},
+              {_number: 2, project: 'test'},
+            ], [
+              {_number: 3, project: 'test/test'},
+            ],
+          ]));
+      // When opt_query instanceof Array, _fetchJSON returns
+      // Array<Array<Object>>.
+      return element.getChanges(null, []).then(() => {
+        assert.equal(Object.keys(element._projectLookup).length, 3);
+        assert.equal(element._projectLookup[1], 'test');
+        assert.equal(element._projectLookup[2], 'test');
+        assert.equal(element._projectLookup[3], 'test/test');
+      });
+    });
+
+    test('no query', () => {
+      sinon.stub(element._restApiHelper, 'fetchJSON')
+          .returns(Promise.resolve([
+            {_number: 1, project: 'test'},
+            {_number: 2, project: 'test'},
+            {_number: 3, project: 'test/test'},
+          ]));
+
+      // When opt_query !instanceof Array, _fetchJSON returns
+      // Array<Object>.
+      return element.getChanges().then(() => {
+        assert.equal(Object.keys(element._projectLookup).length, 3);
+        assert.equal(element._projectLookup[1], 'test');
+        assert.equal(element._projectLookup[2], 'test');
+        assert.equal(element._projectLookup[3], 'test/test');
+      });
+    });
+  });
+
+  test('_getChangeURLAndFetch', () => {
+    element._projectLookup = {1: 'test'};
+    const fetchStub = sinon.stub(element._restApiHelper, 'fetchJSON')
+        .returns(Promise.resolve());
+    const req = {changeNum: 1, endpoint: '/test', patchNum: 1};
+    return element._getChangeURLAndFetch(req).then(() => {
+      assert.equal(fetchStub.lastCall.args[0].url,
+          '/changes/test~1/revisions/1/test');
+    });
+  });
+
+  test('_getChangeURLAndSend', () => {
+    element._projectLookup = {1: 'test'};
+    const sendStub = sinon.stub(element._restApiHelper, 'send')
+        .returns(Promise.resolve());
+
+    const req = {
+      changeNum: 1,
+      method: 'POST',
+      patchNum: 1,
+      endpoint: '/test',
+    };
+    return element._getChangeURLAndSend(req).then(() => {
+      assert.isTrue(sendStub.calledOnce);
+      assert.equal(sendStub.lastCall.args[0].method, 'POST');
+      assert.equal(sendStub.lastCall.args[0].url,
+          '/changes/test~1/revisions/1/test');
+    });
+  });
+
+  suite('reading responses', () => {
+    test('_readResponsePayload', () => {
+      const mockObject = {foo: 'bar', baz: 'foo'};
+      const serial = element.JSON_PREFIX + JSON.stringify(mockObject);
+      const mockResponse = {text: () => Promise.resolve(serial)};
+      return element._restApiHelper.readResponsePayload(mockResponse)
+          .then(payload => {
+            assert.deepEqual(payload.parsed, mockObject);
+            assert.equal(payload.raw, serial);
+          });
+    });
+
+    test('_parsePrefixedJSON', () => {
+      const obj = {x: 3, y: {z: 4}, w: 23};
+      const serial = element.JSON_PREFIX + JSON.stringify(obj);
+      const result = element._restApiHelper.parsePrefixedJSON(serial);
+      assert.deepEqual(result, obj);
+    });
+  });
+
+  test('setChangeTopic', () => {
+    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
+    return element.setChangeTopic(123, 'foo-bar').then(() => {
+      assert.isTrue(sendSpy.calledOnce);
+      assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
+    });
+  });
+
+  test('setChangeHashtag', () => {
+    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
+    return element.setChangeHashtag(123, 'foo-bar').then(() => {
+      assert.isTrue(sendSpy.calledOnce);
+      assert.equal(sendSpy.lastCall.args[0].body, 'foo-bar');
+    });
+  });
+
+  test('generateAccountHttpPassword', () => {
+    const sendSpy = sinon.spy(element._restApiHelper, 'send');
+    return element.generateAccountHttpPassword().then(() => {
+      assert.isTrue(sendSpy.calledOnce);
+      assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
+    });
+  });
+
+  suite('getChangeFiles', () => {
+    test('patch only', () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      const range = {basePatchNum: 'PARENT', patchNum: 2};
+      return element.getChangeFiles(123, range).then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+        assert.isNotOk(fetchStub.lastCall.args[0].params);
+      });
+    });
+
+    test('simple range', () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      const range = {basePatchNum: 4, patchNum: 5};
+      return element.getChangeFiles(123, range).then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
+      });
+    });
+
+    test('parent index', () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      const range = {basePatchNum: -3, patchNum: 5};
+      return element.getChangeFiles(123, range).then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
+        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
+      });
+    });
+  });
+
+  suite('getDiff', () => {
+    test('patchOnly', () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
+      });
+    });
+
+    test('simple range', () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
+        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
+      });
+    });
+
+    test('parent index', () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
+          .returns(Promise.resolve());
+      return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
+        assert.isTrue(fetchStub.calledOnce);
+        assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+        assert.isOk(fetchStub.lastCall.args[0].params);
+        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
+        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
+      });
+    });
+  });
+
+  test('getDashboard', () => {
+    const fetchCacheURLStub = sinon.stub(element._restApiHelper,
+        'fetchCacheURL');
+    element.getDashboard('gerrit/project', 'default:main');
+    assert.isTrue(fetchCacheURLStub.calledOnce);
+    assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/gerrit%2Fproject/dashboards/default%3Amain');
+  });
+
+  test('getFileContent', () => {
+    sinon.stub(element, '_getChangeURLAndSend')
+        .returns(Promise.resolve({
+          ok: 'true',
+          headers: {
+            get(header) {
+              if (header === 'X-FYI-Content-Type') {
+                return 'text/java';
+              }
+            },
+          },
+        }));
+
+    sinon.stub(element, 'getResponseObject')
+        .returns(Promise.resolve('new content'));
+
+    const edit = element.getFileContent('1', 'tst/path', 'EDIT').then(res => {
+      assert.deepEqual(res,
+          {content: 'new content', type: 'text/java', ok: true});
+    });
+
+    const normal = element.getFileContent('1', 'tst/path', '3').then(res => {
+      assert.deepEqual(res,
+          {content: 'new content', type: 'text/java', ok: true});
+    });
+
+    return Promise.all([edit, normal]);
+  });
+
+  test('getFileContent suppresses 404s', done => {
+    const res = {status: 404};
+    const handler = e => {
+      assert.isFalse(e.detail.res.status === 404);
+      done();
+    };
+    element.addEventListener('server-error', handler);
+    sinon.stub(appContext.authService, 'fetch').returns(Promise.resolve(res));
+    sinon.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
+    element.getFileContent('1', 'tst/path', '1').then(() => {
+      flushAsynchronousOperations();
+
+      res.status = 500;
+      element.getFileContent('1', 'tst/path', '1');
+    });
+  });
+
+  test('getChangeFilesOrEditFiles is edit-sensitive', () => {
+    const fn = element.getChangeOrEditFiles.bind(element);
+    const getChangeFilesStub = sinon.stub(element, 'getChangeFiles')
+        .returns(Promise.resolve({}));
+    const getChangeEditFilesStub = sinon.stub(element, 'getChangeEditFiles')
+        .returns(Promise.resolve({}));
+
+    return fn('1', {patchNum: 'edit'}).then(() => {
+      assert.isTrue(getChangeEditFilesStub.calledOnce);
+      assert.isFalse(getChangeFilesStub.called);
+      return fn('1', {patchNum: '1'}).then(() => {
+        assert.isTrue(getChangeEditFilesStub.calledOnce);
+        assert.isTrue(getChangeFilesStub.calledOnce);
+      });
+    });
+  });
+
+  test('_fetch forwards request and logs', () => {
+    const logStub = sinon.stub(element._restApiHelper, '_logCall');
+    const response = {status: 404, text: sinon.stub()};
+    const url = 'my url';
+    const fetchOptions = {method: 'DELETE'};
+    sinon.stub(element.authService, 'fetch').returns(Promise.resolve(response));
+    const startTime = 123;
+    sinon.stub(Date, 'now').returns(startTime);
+    const req = {url, fetchOptions};
+    return element._restApiHelper.fetch(req).then(() => {
+      assert.isTrue(logStub.calledOnce);
+      assert.isTrue(logStub.calledWith(req, startTime, response.status));
+      assert.isFalse(response.text.called);
+    });
+  });
+
+  test('_logCall only reports requests with anonymized URLss', () => {
+    sinon.stub(Date, 'now').returns(200);
+    const handler = sinon.stub();
+    element.addEventListener('rpc-log', handler);
+
+    element._restApiHelper._logCall({url: 'url'}, 100, 200);
+    assert.isFalse(handler.called);
+
+    element._restApiHelper
+        ._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
+    flushAsynchronousOperations();
+    assert.isTrue(handler.calledOnce);
+  });
+
+  test('saveChangeStarred', async () => {
+    sinon.stub(element, 'getFromProjectLookup')
+        .returns(Promise.resolve('test'));
+    const sendStub =
+        sinon.stub(element._restApiHelper, 'send').returns(Promise.resolve());
+
+    await element.saveChangeStarred(123, true);
+    assert.isTrue(sendStub.calledOnce);
+    assert.deepEqual(sendStub.lastCall.args[0], {
+      method: 'PUT',
+      url: '/accounts/self/starred.changes/test~123',
+      anonymizedUrl: '/accounts/self/starred.changes/*',
+    });
+
+    await element.saveChangeStarred(456, false);
+    assert.isTrue(sendStub.calledTwice);
+    assert.deepEqual(sendStub.lastCall.args[0], {
+      method: 'DELETE',
+      url: '/accounts/self/starred.changes/test~456',
+      anonymizedUrl: '/accounts/self/starred.changes/*',
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
index bc70791..f0932b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
@@ -14,6 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {getBaseUrl} from '../../../../utils/url-util.js';
+
 const JSON_PREFIX = ')]}\'';
 
 /**
@@ -148,7 +150,7 @@
     const elapsed = (endTime - startTime);
     const startAt = new Date(startTime);
     const endAt = new Date(endTime);
-    console.log([
+    console.info([
       'HTTP',
       status,
       method,
@@ -237,7 +239,7 @@
    * @return {string}
    */
   urlWithParams(url, opt_params) {
-    if (!opt_params) { return this.getBaseUrl() + url; }
+    if (!opt_params) { return getBaseUrl() + url; }
 
     const params = [];
     for (const p in opt_params) {
@@ -250,7 +252,7 @@
         params.push(`${encodeURIComponent(p)}=${encodeURIComponent(value)}`);
       }
     }
-    return this.getBaseUrl() + url + '?' + params.join('&');
+    return getBaseUrl() + url + '?' + params.join('&');
   }
 
   /**
@@ -299,10 +301,6 @@
     return req;
   }
 
-  getBaseUrl() {
-    return this._restApiInterface.getBaseUrl();
-  }
-
   dispatchEvent(type, detail) {
     return this._restApiInterface.dispatchEvent(type, detail);
   }
@@ -358,7 +356,7 @@
       }
     }
     const url = req.url.startsWith('http') ?
-      req.url : this.getBaseUrl() + req.url;
+      req.url : getBaseUrl() + req.url;
     const fetchReq = {
       url,
       fetchOptions: options,
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html
deleted file mode 100644
index 32d2166..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html
+++ /dev/null
@@ -1,174 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-rest-api-helper</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<script type="module">
-import '../../../../test/common-test-setup.js';
-import {SiteBasedCache} from './gr-rest-api-helper.js';
-import {FetchPromisesCache, GrRestApiHelper} from './gr-rest-api-helper.js';
-import {authService} from '../gr-auth.js';
-
-suite('gr-rest-api-helper tests', () => {
-  let helper;
-  let sandbox;
-  let cache;
-  let fetchPromisesCache;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    cache = new SiteBasedCache();
-    fetchPromisesCache = new FetchPromisesCache();
-
-    window.CANONICAL_PATH = 'testhelper';
-
-    const mockRestApiInterface = {
-      getBaseUrl: sinon.stub().returns(window.CANONICAL_PATH),
-      fire: sinon.stub(),
-    };
-
-    const testJSON = ')]}\'\n{"hello": "bonjour"}';
-    sandbox.stub(window, 'fetch').returns(Promise.resolve({
-      ok: true,
-      text() {
-        return Promise.resolve(testJSON);
-      },
-    }));
-
-    helper = new GrRestApiHelper(cache, authService, fetchPromisesCache,
-        mockRestApiInterface);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  suite('fetchJSON()', () => {
-    test('Sets header to accept application/json', () => {
-      const authFetchStub = sandbox.stub(helper._auth, 'fetch')
-          .returns(Promise.resolve());
-      helper.fetchJSON({url: '/dummy/url'});
-      assert.isTrue(authFetchStub.called);
-      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-          'application/json');
-    });
-
-    test('Use header option accept when provided', () => {
-      const authFetchStub = sandbox.stub(helper._auth, 'fetch')
-          .returns(Promise.resolve());
-      const headers = new Headers();
-      headers.append('Accept', '*/*');
-      const fetchOptions = {headers};
-      helper.fetchJSON({url: '/dummy/url', fetchOptions});
-      assert.isTrue(authFetchStub.called);
-      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-          '*/*');
-    });
-  });
-
-  test('JSON prefix is properly removed', done => {
-    helper.fetchJSON({url: '/dummy/url'}).then(obj => {
-      assert.deepEqual(obj, {hello: 'bonjour'});
-      done();
-    });
-  });
-
-  test('cached results', done => {
-    let n = 0;
-    sandbox.stub(helper, 'fetchJSON', () => Promise.resolve(++n));
-    const promises = [];
-    promises.push(helper.fetchCacheURL('/foo'));
-    promises.push(helper.fetchCacheURL('/foo'));
-    promises.push(helper.fetchCacheURL('/foo'));
-
-    Promise.all(promises).then(results => {
-      assert.deepEqual(results, [1, 1, 1]);
-      helper.fetchCacheURL('/foo').then(foo => {
-        assert.equal(foo, 1);
-        done();
-      });
-    });
-  });
-
-  test('cached promise', done => {
-    const promise = Promise.reject(new Error('foo'));
-    cache.set('/foo', promise);
-    helper.fetchCacheURL({url: '/foo'}).catch(p => {
-      assert.equal(p.message, 'foo');
-      done();
-    });
-  });
-
-  test('cache invalidation', () => {
-    cache.set('/foo/bar', 1);
-    cache.set('/bar', 2);
-    fetchPromisesCache.set('/foo/bar', 3);
-    fetchPromisesCache.set('/bar', 4);
-    helper.invalidateFetchPromisesPrefix('/foo/');
-    assert.isFalse(cache.has('/foo/bar'));
-    assert.isTrue(cache.has('/bar'));
-    assert.isUndefined(fetchPromisesCache.get('/foo/bar'));
-    assert.strictEqual(4, fetchPromisesCache.get('/bar'));
-  });
-
-  test('params are properly encoded', () => {
-    let url = helper.urlWithParams('/path/', {
-      sp: 'hola',
-      gr: 'guten tag',
-      noval: null,
-    });
-    assert.equal(url,
-        window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
-
-    url = helper.urlWithParams('/path/', {
-      sp: 'hola',
-      en: ['hey', 'hi'],
-    });
-    assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
-
-    // Order must be maintained with array params.
-    url = helper.urlWithParams('/path/', {
-      l: ['c', 'b', 'a'],
-    });
-    assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
-  });
-
-  test('request callbacks can be canceled', done => {
-    let cancelCalled = false;
-    window.fetch.returns(Promise.resolve({
-      body: {
-        cancel() { cancelCalled = true; },
-      },
-    }));
-    const cancelCondition = () => true;
-    helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(
-        obj => {
-          assert.isUndefined(obj);
-          assert.isTrue(cancelCalled);
-          done();
-        });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
new file mode 100644
index 0000000..a50a9e7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
@@ -0,0 +1,163 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../../test/common-test-setup-karma.js';
+import {SiteBasedCache} from './gr-rest-api-helper.js';
+import {FetchPromisesCache, GrRestApiHelper} from './gr-rest-api-helper.js';
+import {appContext} from '../../../../services/app-context.js';
+
+suite('gr-rest-api-helper tests', () => {
+  let helper;
+
+  let cache;
+  let fetchPromisesCache;
+  let originalCanonicalPath;
+
+  setup(() => {
+    cache = new SiteBasedCache();
+    fetchPromisesCache = new FetchPromisesCache();
+
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = 'testhelper';
+
+    const mockRestApiInterface = {
+      fire: sinon.stub(),
+    };
+
+    const testJSON = ')]}\'\n{"hello": "bonjour"}';
+    sinon.stub(window, 'fetch').returns(Promise.resolve({
+      ok: true,
+      text() {
+        return Promise.resolve(testJSON);
+      },
+    }));
+
+    helper = new GrRestApiHelper(cache, appContext.authService,
+        fetchPromisesCache, mockRestApiInterface);
+  });
+
+  teardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  suite('fetchJSON()', () => {
+    test('Sets header to accept application/json', () => {
+      const authFetchStub = sinon.stub(helper._auth, 'fetch')
+          .returns(Promise.resolve());
+      helper.fetchJSON({url: '/dummy/url'});
+      assert.isTrue(authFetchStub.called);
+      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
+          'application/json');
+    });
+
+    test('Use header option accept when provided', () => {
+      const authFetchStub = sinon.stub(helper._auth, 'fetch')
+          .returns(Promise.resolve());
+      const headers = new Headers();
+      headers.append('Accept', '*/*');
+      const fetchOptions = {headers};
+      helper.fetchJSON({url: '/dummy/url', fetchOptions});
+      assert.isTrue(authFetchStub.called);
+      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
+          '*/*');
+    });
+  });
+
+  test('JSON prefix is properly removed', done => {
+    helper.fetchJSON({url: '/dummy/url'}).then(obj => {
+      assert.deepEqual(obj, {hello: 'bonjour'});
+      done();
+    });
+  });
+
+  test('cached results', done => {
+    let n = 0;
+    sinon.stub(helper, 'fetchJSON').callsFake(() => Promise.resolve(++n));
+    const promises = [];
+    promises.push(helper.fetchCacheURL('/foo'));
+    promises.push(helper.fetchCacheURL('/foo'));
+    promises.push(helper.fetchCacheURL('/foo'));
+
+    Promise.all(promises).then(results => {
+      assert.deepEqual(results, [1, 1, 1]);
+      helper.fetchCacheURL('/foo').then(foo => {
+        assert.equal(foo, 1);
+        done();
+      });
+    });
+  });
+
+  test('cached promise', done => {
+    const promise = Promise.reject(new Error('foo'));
+    cache.set('/foo', promise);
+    helper.fetchCacheURL({url: '/foo'}).catch(p => {
+      assert.equal(p.message, 'foo');
+      done();
+    });
+  });
+
+  test('cache invalidation', () => {
+    cache.set('/foo/bar', 1);
+    cache.set('/bar', 2);
+    fetchPromisesCache.set('/foo/bar', 3);
+    fetchPromisesCache.set('/bar', 4);
+    helper.invalidateFetchPromisesPrefix('/foo/');
+    assert.isFalse(cache.has('/foo/bar'));
+    assert.isTrue(cache.has('/bar'));
+    assert.isUndefined(fetchPromisesCache.get('/foo/bar'));
+    assert.strictEqual(4, fetchPromisesCache.get('/bar'));
+  });
+
+  test('params are properly encoded', () => {
+    let url = helper.urlWithParams('/path/', {
+      sp: 'hola',
+      gr: 'guten tag',
+      noval: null,
+    });
+    assert.equal(url,
+        window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
+
+    url = helper.urlWithParams('/path/', {
+      sp: 'hola',
+      en: ['hey', 'hi'],
+    });
+    assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
+
+    // Order must be maintained with array params.
+    url = helper.urlWithParams('/path/', {
+      l: ['c', 'b', 'a'],
+    });
+    assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
+  });
+
+  test('request callbacks can be canceled', done => {
+    let cancelCalled = false;
+    window.fetch.returns(Promise.resolve({
+      body: {
+        cancel() { cancelCalled = true; },
+      },
+    }));
+    const cancelCondition = () => true;
+    helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(
+        obj => {
+          assert.isUndefined(obj);
+          assert.isTrue(cancelCalled);
+          done();
+        });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
index 3d1ce05..4988724 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
@@ -15,215 +15,223 @@
  * limitations under the License.
  */
 
-import {util} from '../../../scripts/util.js';
+import {parseDate} from '../../../utils/date-util.js';
+import {MessageTag} from '../../../constants/constants.js';
 
-/** @constructor */
-export function GrReviewerUpdatesParser(change) {
-  this.result = Object.assign({}, change);
-  this._lastState = {};
-}
+const MESSAGE_REVIEWERS_THRESHOLD_MILLIS = 500;
+const REVIEWER_UPDATE_THRESHOLD_MILLIS = 6000;
 
-GrReviewerUpdatesParser.parse = function(change) {
-  if (!change ||
-      !change.messages ||
-      !change.reviewer_updates ||
-      !change.reviewer_updates.length) {
-    return change;
+export class GrReviewerUpdatesParser {
+  constructor(change) {
+    this.result = {...change};
+    this._lastState = {};
+    this._batch = null;
+    this._updateItems = null;
   }
-  const parser = new GrReviewerUpdatesParser(change);
-  parser._filterRemovedMessages();
-  parser._groupUpdates();
-  parser._formatUpdates();
-  parser._advanceUpdates();
-  return parser.result;
-};
 
-GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS = 500;
-GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS = 6000;
-
-GrReviewerUpdatesParser.prototype.result = null;
-GrReviewerUpdatesParser.prototype._batch = null;
-GrReviewerUpdatesParser.prototype._updateItems = null;
-GrReviewerUpdatesParser.prototype._lastState = null;
-
-/**
- * Removes messages that describe removed reviewers, since reviewer_updates
- * are used.
- */
-GrReviewerUpdatesParser.prototype._filterRemovedMessages = function() {
-  this.result.messages = this.result.messages
-      .filter(
-          message => message.tag !== 'autogenerated:gerrit:deleteReviewer'
-      );
-};
-
-/**
- * Is a part of _groupUpdates(). Creates a new batch of updates.
- *
- * @param {Object} update instance of ReviewerUpdateInfo
- */
-GrReviewerUpdatesParser.prototype._startBatch = function(update) {
-  this._updateItems = [];
-  return {
-    author: update.updated_by,
-    date: update.updated,
-    type: 'REVIEWER_UPDATE',
-  };
-};
-
-/**
- * Is a part of _groupUpdates(). Validates current batch:
- * - filters out updates that don't change reviewer state.
- * - updates current reviewer state.
- *
- * @param {Object} update instance of ReviewerUpdateInfo
- */
-GrReviewerUpdatesParser.prototype._completeBatch = function(update) {
-  const items = [];
-  for (const accountId in this._updateItems) {
-    if (!this._updateItems.hasOwnProperty(accountId)) continue;
-    const updateItem = this._updateItems[accountId];
-    if (this._lastState[accountId] !== updateItem.state) {
-      this._lastState[accountId] = updateItem.state;
-      items.push(updateItem);
-    }
+  /**
+   * Removes messages that describe removed reviewers, since reviewer_updates
+   * are used.
+   */
+  _filterRemovedMessages() {
+    this.result.messages = this.result.messages.filter(
+        message => message.tag !== MessageTag.TAG_DELETE_REVIEWER
+    );
   }
-  if (items.length) {
-    this._batch.updates = items;
-  }
-};
 
-/**
- * Groups reviewer updates. Sequential updates are grouped if:
- * - They were performed within short timeframe (6 seconds)
- * - Made by the same person
- * - Non-change updates are discarded within a group
- * - Groups with no-change updates are discarded (eg CC -> CC)
- */
-GrReviewerUpdatesParser.prototype._groupUpdates = function() {
-  const updates = this.result.reviewer_updates;
-  const newUpdates = updates.reduce((newUpdates, update) => {
-    if (!this._batch) {
-      this._batch = this._startBatch(update);
-    }
-    const updateDate = util.parseDate(update.updated).getTime();
-    const batchUpdateDate = util.parseDate(this._batch.date).getTime();
-    const reviewerId = update.reviewer._account_id.toString();
-    if (updateDate - batchUpdateDate >
-        GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS ||
-        update.updated_by._account_id !== this._batch.author._account_id) {
-      // Next sequential update should form new group.
-      this._completeBatch();
-      if (this._batch.updates && this._batch.updates.length) {
-        newUpdates.push(this._batch);
-      }
-      this._batch = this._startBatch(update);
-    }
-    this._updateItems[reviewerId] = {
-      reviewer: update.reviewer,
-      state: update.state,
+  /**
+   * Is a part of _groupUpdates(). Creates a new batch of updates.
+   *
+   * @param {Object} update instance of ReviewerUpdateInfo
+   */
+  _startBatch(update) {
+    this._updateItems = [];
+    return {
+      author: update.updated_by,
+      date: update.updated,
+      type: 'REVIEWER_UPDATE',
+      tag: MessageTag.TAG_REVIEWER_UPDATE,
     };
-    if (this._lastState[reviewerId]) {
-      this._updateItems[reviewerId].prev_state = this._lastState[reviewerId];
-    }
-    return newUpdates;
-  }, []);
-  this._completeBatch();
-  if (this._batch.updates && this._batch.updates.length) {
-    newUpdates.push(this._batch);
   }
-  this.result.reviewer_updates = newUpdates;
-};
 
-/**
- * Generates update message for reviewer state change.
- *
- * @param {string} prev previous reviewer state.
- * @param {string} state current reviewer state.
- * @return {string}
- */
-GrReviewerUpdatesParser.prototype._getUpdateMessage = function(prev, state) {
-  if (prev === 'REMOVED' || !prev) {
-    return 'Added to ' + state.toLowerCase() + ': ';
-  } else if (state === 'REMOVED') {
-    if (prev) {
-      return 'Removed from ' + prev.toLowerCase() + ': ';
+  /**
+   * Is a part of _groupUpdates(). Validates current batch:
+   * - filters out updates that don't change reviewer state.
+   * - updates current reviewer state.
+   *
+   * @param {Object} update instance of ReviewerUpdateInfo
+   */
+  _completeBatch(update) {
+    const items = [];
+    for (const accountId in this._updateItems) {
+      if (!this._updateItems.hasOwnProperty(accountId)) continue;
+      const updateItem = this._updateItems[accountId];
+      if (this._lastState[accountId] !== updateItem.state) {
+        this._lastState[accountId] = updateItem.state;
+        items.push(updateItem);
+      }
+    }
+    if (items.length) {
+      this._batch.updates = items;
+    }
+  }
+
+  /**
+   * Groups reviewer updates. Sequential updates are grouped if:
+   * - They were performed within short timeframe (6 seconds)
+   * - Made by the same person
+   * - Non-change updates are discarded within a group
+   * - Groups with no-change updates are discarded (eg CC -> CC)
+   */
+  _groupUpdates() {
+    const updates = this.result.reviewer_updates;
+    const newUpdates = updates.reduce((newUpdates, update) => {
+      if (!this._batch) {
+        this._batch = this._startBatch(update);
+      }
+      const updateDate = parseDate(update.updated).getTime();
+      const batchUpdateDate = parseDate(this._batch.date).getTime();
+      const reviewerId = update.reviewer._account_id.toString();
+      if (
+        updateDate - batchUpdateDate >
+          REVIEWER_UPDATE_THRESHOLD_MILLIS ||
+        update.updated_by._account_id !== this._batch.author._account_id
+      ) {
+        // Next sequential update should form new group.
+        this._completeBatch();
+        if (this._batch.updates && this._batch.updates.length) {
+          newUpdates.push(this._batch);
+        }
+        this._batch = this._startBatch(update);
+      }
+      this._updateItems[reviewerId] = {
+        reviewer: update.reviewer,
+        state: update.state,
+      };
+      if (this._lastState[reviewerId]) {
+        this._updateItems[reviewerId].prev_state = this._lastState[reviewerId];
+      }
+      return newUpdates;
+    }, []);
+    this._completeBatch();
+    if (this._batch.updates && this._batch.updates.length) {
+      newUpdates.push(this._batch);
+    }
+    this.result.reviewer_updates = newUpdates;
+  }
+
+  /**
+   * Generates update message for reviewer state change.
+   *
+   * @param {string} prev previous reviewer state.
+   * @param {string} state current reviewer state.
+   * @return {string}
+   */
+  _getUpdateMessage(prev, state) {
+    if (prev === 'REMOVED' || !prev) {
+      return 'Added to ' + state.toLowerCase() + ': ';
+    } else if (state === 'REMOVED') {
+      if (prev) {
+        return 'Removed from ' + prev.toLowerCase() + ': ';
+      } else {
+        return 'Removed : ';
+      }
     } else {
-      return 'Removed : ';
+      return (
+        'Moved from ' + prev.toLowerCase() + ' to ' + state.toLowerCase() + ': '
+      );
     }
-  } else {
-    return 'Moved from ' + prev.toLowerCase() + ' to ' + state.toLowerCase() +
-        ': ';
   }
-};
 
-/**
- * Groups updates for same category (eg CC->CC) into a hash arrays of
- * reviewers.
- *
- * @param {!Array<!Object>} updates Array of ReviewerUpdateItemInfo.
- * @return {!Object} Hash of arrays of AccountInfo, message as key.
- */
-GrReviewerUpdatesParser.prototype._groupUpdatesByMessage = function(updates) {
-  return updates.reduce((result, item) => {
-    const message = this._getUpdateMessage(item.prev_state, item.state);
-    if (!result[message]) {
-      result[message] = [];
-    }
-    result[message].push(item.reviewer);
-    return result;
-  }, {});
-};
-
-/**
- * Generates text messages for grouped reviewer updates.
- * Formats reviewer updates to a (not yet implemented) EventInfo instance.
- *
- * @see https://gerrit-review.googlesource.com/c/94490/
- */
-GrReviewerUpdatesParser.prototype._formatUpdates = function() {
-  for (const update of this.result.reviewer_updates) {
-    const grouppedReviewers = this._groupUpdatesByMessage(update.updates);
-    const newUpdates = [];
-    for (const message in grouppedReviewers) {
-      if (grouppedReviewers.hasOwnProperty(message)) {
-        newUpdates.push({
-          message,
-          reviewers: grouppedReviewers[message],
-        });
+  /**
+   * Groups updates for same category (eg CC->CC) into a hash arrays of
+   * reviewers.
+   *
+   * @param {!Array<!Object>} updates Array of ReviewerUpdateItemInfo.
+   * @return {!Object} Hash of arrays of AccountInfo, message as key.
+   */
+  _groupUpdatesByMessage(updates) {
+    return updates.reduce((result, item) => {
+      const message = this._getUpdateMessage(item.prev_state, item.state);
+      if (!result[message]) {
+        result[message] = [];
       }
-    }
-    update.updates = newUpdates;
+      result[message].push(item.reviewer);
+      return result;
+    }, {});
   }
-};
 
-/**
- * Moves reviewer updates that are within short time frame of change messages
- * back in time so they would come before change messages.
- * TODO(viktard): Remove when server-side serves reviewer updates like so.
- */
-GrReviewerUpdatesParser.prototype._advanceUpdates = function() {
-  const updates = this.result.reviewer_updates;
-  const messages = this.result.messages;
-  messages.forEach((message, index) => {
-    const messageDate = util.parseDate(message.date).getTime();
-    const nextMessageDate = index === messages.length - 1 ? null :
-      util.parseDate(messages[index + 1].date).getTime();
-    for (const update of updates) {
-      const date = util.parseDate(update.date).getTime();
-      if (date >= messageDate &&
-          (!nextMessageDate || date < nextMessageDate)) {
-        const timestamp = util.parseDate(update.date).getTime() -
-            GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS;
-        update.date = new Date(timestamp)
-            .toISOString()
-            .replace('T', ' ')
-            .replace('Z', '000000');
+  /**
+   * Generates text messages for grouped reviewer updates.
+   * Formats reviewer updates to a (not yet implemented) EventInfo instance.
+   *
+   * @see https://gerrit-review.googlesource.com/c/94490/
+   */
+  _formatUpdates() {
+    for (const update of this.result.reviewer_updates) {
+      const grouppedReviewers = this._groupUpdatesByMessage(update.updates);
+      const newUpdates = [];
+      for (const message in grouppedReviewers) {
+        if (grouppedReviewers.hasOwnProperty(message)) {
+          newUpdates.push({
+            message,
+            reviewers: grouppedReviewers[message],
+          });
+        }
       }
-      if (nextMessageDate && date > nextMessageDate) {
-        break;
-      }
+      update.updates = newUpdates;
     }
-  });
-};
+  }
 
+  /**
+   * Moves reviewer updates that are within short time frame of change messages
+   * back in time so they would come before change messages.
+   * TODO(viktard): Remove when server-side serves reviewer updates like so.
+   */
+  _advanceUpdates() {
+    const updates = this.result.reviewer_updates;
+    const messages = this.result.messages;
+    messages.forEach((message, index) => {
+      const messageDate = parseDate(message.date).getTime();
+      const nextMessageDate =
+        index === messages.length - 1
+          ? null
+          : parseDate(messages[index + 1].date).getTime();
+      for (const update of updates) {
+        const date = parseDate(update.date).getTime();
+        if (
+          date >= messageDate &&
+          (!nextMessageDate || date < nextMessageDate)
+        ) {
+          const timestamp =
+            parseDate(update.date).getTime() -
+            MESSAGE_REVIEWERS_THRESHOLD_MILLIS;
+          update.date = new Date(timestamp)
+              .toISOString()
+              .replace('T', ' ')
+              .replace('Z', '000000');
+        }
+        if (nextMessageDate && date > nextMessageDate) {
+          break;
+        }
+      }
+    });
+  }
+
+  static parse(change) {
+    if (
+      !change ||
+    !change.messages ||
+    !change.reviewer_updates ||
+    !change.reviewer_updates.length
+    ) {
+      return change;
+    }
+    const parser = new GrReviewerUpdatesParser(change);
+    parser._filterRemovedMessages();
+    parser._groupUpdates();
+    parser._formatUpdates();
+    parser._advanceUpdates();
+    return parser.result;
+  }
+}
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
deleted file mode 100644
index f2ccfb7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
+++ /dev/null
@@ -1,306 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-reviewer-updates-parser</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../../test/common-test-setup.js';
-import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
-import {util} from '../../../scripts/util.js';
-
-suite('gr-reviewer-updates-parser tests', () => {
-  let sandbox;
-  let instance;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('ignores changes without messages', () => {
-    const change = {};
-    sandbox.stub(
-        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
-    sandbox.stub(
-        GrReviewerUpdatesParser.prototype, '_groupUpdates');
-    sandbox.stub(
-        GrReviewerUpdatesParser.prototype, '_formatUpdates');
-    assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
-    assert.isFalse(
-        GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
-    assert.isFalse(
-        GrReviewerUpdatesParser.prototype._groupUpdates.called);
-    assert.isFalse(
-        GrReviewerUpdatesParser.prototype._formatUpdates.called);
-  });
-
-  test('ignores changes without reviewer updates', () => {
-    const change = {
-      messages: [],
-    };
-    sandbox.stub(
-        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
-    sandbox.stub(
-        GrReviewerUpdatesParser.prototype, '_groupUpdates');
-    sandbox.stub(
-        GrReviewerUpdatesParser.prototype, '_formatUpdates');
-    assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
-    assert.isFalse(
-        GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
-    assert.isFalse(
-        GrReviewerUpdatesParser.prototype._groupUpdates.called);
-    assert.isFalse(
-        GrReviewerUpdatesParser.prototype._formatUpdates.called);
-  });
-
-  test('ignores changes with empty reviewer updates', () => {
-    const change = {
-      messages: [],
-      reviewer_updates: [],
-    };
-    sandbox.stub(
-        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
-    sandbox.stub(
-        GrReviewerUpdatesParser.prototype, '_groupUpdates');
-    sandbox.stub(
-        GrReviewerUpdatesParser.prototype, '_formatUpdates');
-    assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
-    assert.isFalse(
-        GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
-    assert.isFalse(
-        GrReviewerUpdatesParser.prototype._groupUpdates.called);
-    assert.isFalse(
-        GrReviewerUpdatesParser.prototype._formatUpdates.called);
-  });
-
-  test('filter removed messages', () => {
-    const change = {
-      messages: [
-        {
-          message: 'msg1',
-          tag: 'autogenerated:gerrit:deleteReviewer',
-        },
-        {
-          message: 'msg2',
-          tag: 'foo',
-        },
-      ],
-    };
-    instance = new GrReviewerUpdatesParser(change);
-    instance._filterRemovedMessages();
-    assert.deepEqual(instance.result, {
-      messages: [{
-        message: 'msg2',
-        tag: 'foo',
-      }],
-    });
-  });
-
-  test('group reviewer updates', () => {
-    const reviewer1 = {_account_id: 1};
-    const reviewer2 = {_account_id: 2};
-    const date1 = '2017-01-26 12:11:50.000000000';
-    const date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
-    const date3 = '2017-01-26 12:33:50.000000000';
-    const date4 = '2017-01-26 12:44:50.000000000';
-    const makeItem = function(state, reviewer, opt_date, opt_author) {
-      return {
-        reviewer,
-        updated: opt_date || date1,
-        updated_by: opt_author || reviewer1,
-        state,
-      };
-    };
-    let change = {
-      reviewer_updates: [
-        makeItem('REVIEWER', reviewer1), // New group.
-        makeItem('CC', reviewer2), // Appended.
-        makeItem('REVIEWER', reviewer2, date2), // Overrides previous one.
-
-        makeItem('CC', reviewer1, date2, reviewer2), // New group.
-
-        makeItem('REMOVED', reviewer2, date3), // Group has no state change.
-        makeItem('REVIEWER', reviewer2, date3),
-
-        makeItem('CC', reviewer1, date4), // No change, removed.
-        makeItem('REVIEWER', reviewer1, date4), // Forms new group
-        makeItem('REMOVED', reviewer2, date4), // Should be grouped.
-      ],
-    };
-
-    instance = new GrReviewerUpdatesParser(change);
-    instance._groupUpdates();
-    change = instance.result;
-
-    assert.equal(change.reviewer_updates.length, 3);
-    assert.equal(change.reviewer_updates[0].updates.length, 2);
-    assert.equal(change.reviewer_updates[1].updates.length, 1);
-    assert.equal(change.reviewer_updates[2].updates.length, 2);
-
-    assert.equal(change.reviewer_updates[0].date, date1);
-    assert.deepEqual(change.reviewer_updates[0].author, reviewer1);
-    assert.deepEqual(change.reviewer_updates[0].updates, [
-      {
-        reviewer: reviewer1,
-        state: 'REVIEWER',
-      },
-      {
-        reviewer: reviewer2,
-        state: 'REVIEWER',
-      },
-    ]);
-
-    assert.equal(change.reviewer_updates[1].date, date2);
-    assert.deepEqual(change.reviewer_updates[1].author, reviewer2);
-    assert.deepEqual(change.reviewer_updates[1].updates, [
-      {
-        reviewer: reviewer1,
-        state: 'CC',
-        prev_state: 'REVIEWER',
-      },
-    ]);
-
-    assert.equal(change.reviewer_updates[2].date, date4);
-    assert.deepEqual(change.reviewer_updates[2].author, reviewer1);
-    assert.deepEqual(change.reviewer_updates[2].updates, [
-      {
-        reviewer: reviewer1,
-        prev_state: 'CC',
-        state: 'REVIEWER',
-      },
-      {
-        reviewer: reviewer2,
-        prev_state: 'REVIEWER',
-        state: 'REMOVED',
-      },
-    ]);
-  });
-
-  test('format reviewer updates', () => {
-    const reviewer1 = {_account_id: 1};
-    const reviewer2 = {_account_id: 2};
-    const makeItem = function(prev, state, opt_reviewer) {
-      return {
-        reviewer: opt_reviewer || reviewer1,
-        prev_state: prev,
-        state,
-      };
-    };
-    const makeUpdate = function(items) {
-      return {
-        author: reviewer1,
-        updated: '',
-        updates: items,
-      };
-    };
-    const change = {
-      reviewer_updates: [
-        makeUpdate([
-          makeItem(undefined, 'CC'),
-          makeItem(undefined, 'CC', reviewer2),
-        ]),
-        makeUpdate([
-          makeItem('CC', 'REVIEWER'),
-          makeItem('REVIEWER', 'REMOVED'),
-          makeItem('REMOVED', 'REVIEWER'),
-          makeItem(undefined, 'REVIEWER', reviewer2),
-        ]),
-      ],
-    };
-
-    instance = new GrReviewerUpdatesParser(change);
-    instance._formatUpdates();
-
-    assert.equal(change.reviewer_updates.length, 2);
-    assert.equal(change.reviewer_updates[0].updates.length, 1);
-    assert.equal(change.reviewer_updates[1].updates.length, 3);
-
-    let items = change.reviewer_updates[0].updates;
-    assert.equal(items[0].message, 'Added to cc: ');
-    assert.deepEqual(items[0].reviewers, [reviewer1, reviewer2]);
-
-    items = change.reviewer_updates[1].updates;
-    assert.equal(items[0].message, 'Moved from cc to reviewer: ');
-    assert.deepEqual(items[0].reviewers, [reviewer1]);
-    assert.equal(items[1].message, 'Removed from reviewer: ');
-    assert.deepEqual(items[1].reviewers, [reviewer1]);
-    assert.equal(items[2].message, 'Added to reviewer: ');
-    assert.deepEqual(items[2].reviewers, [reviewer1, reviewer2]);
-  });
-
-  test('_advanceUpdates', () => {
-    const T0 = util.parseDate('2017-02-17 19:04:18.000000000').getTime();
-    const tplus = delta => new Date(T0 + delta)
-        .toISOString()
-        .replace('T', ' ')
-        .replace('Z', '000000');
-    const change = {
-      reviewer_updates: [{
-        date: tplus(0),
-        type: 'REVIEWER_UPDATE',
-        updates: [{
-          message: 'same time update',
-        }],
-      }, {
-        date: tplus(200),
-        type: 'REVIEWER_UPDATE',
-        updates: [{
-          message: 'update within threshold',
-        }],
-      }, {
-        date: tplus(600),
-        type: 'REVIEWER_UPDATE',
-        updates: [{
-          message: 'update between messages',
-        }],
-      }, {
-        date: tplus(1000),
-        type: 'REVIEWER_UPDATE',
-        updates: [{
-          message: 'late update',
-        }],
-      }],
-      messages: [{
-        id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
-        date: tplus(0),
-        message: 'Uploaded patch set 1.',
-      }, {
-        id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
-        date: tplus(800),
-        message: 'Uploaded patch set 2.',
-      }],
-    };
-    instance = new GrReviewerUpdatesParser(change);
-    instance._advanceUpdates();
-    const updates = instance.result.reviewer_updates;
-    assert.isBelow(util.parseDate(updates[0].date).getTime(), T0);
-    assert.isBelow(util.parseDate(updates[1].date).getTime(), T0);
-    assert.equal(updates[2].date, tplus(100));
-    assert.equal(updates[3].date, tplus(500));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
new file mode 100644
index 0000000..34fb709
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
@@ -0,0 +1,287 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
+import {parseDate} from '../../../utils/date-util.js';
+
+suite('gr-reviewer-updates-parser tests', () => {
+  let instance;
+
+  test('ignores changes without messages', () => {
+    const change = {};
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_groupUpdates');
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_formatUpdates');
+    assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._groupUpdates.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._formatUpdates.called);
+  });
+
+  test('ignores changes without reviewer updates', () => {
+    const change = {
+      messages: [],
+    };
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_groupUpdates');
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_formatUpdates');
+    assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._groupUpdates.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._formatUpdates.called);
+  });
+
+  test('ignores changes with empty reviewer updates', () => {
+    const change = {
+      messages: [],
+      reviewer_updates: [],
+    };
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_groupUpdates');
+    sinon.stub(
+        GrReviewerUpdatesParser.prototype, '_formatUpdates');
+    assert.strictEqual(GrReviewerUpdatesParser.parse(change), change);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._filterRemovedMessages.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._groupUpdates.called);
+    assert.isFalse(
+        GrReviewerUpdatesParser.prototype._formatUpdates.called);
+  });
+
+  test('filter removed messages', () => {
+    const change = {
+      messages: [
+        {
+          message: 'msg1',
+          tag: 'autogenerated:gerrit:deleteReviewer',
+        },
+        {
+          message: 'msg2',
+          tag: 'foo',
+        },
+      ],
+    };
+    instance = new GrReviewerUpdatesParser(change);
+    instance._filterRemovedMessages();
+    assert.deepEqual(instance.result, {
+      messages: [{
+        message: 'msg2',
+        tag: 'foo',
+      }],
+    });
+  });
+
+  test('group reviewer updates', () => {
+    const reviewer1 = {_account_id: 1};
+    const reviewer2 = {_account_id: 2};
+    const date1 = '2017-01-26 12:11:50.000000000';
+    const date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
+    const date3 = '2017-01-26 12:33:50.000000000';
+    const date4 = '2017-01-26 12:44:50.000000000';
+    const makeItem = function(state, reviewer, opt_date, opt_author) {
+      return {
+        reviewer,
+        updated: opt_date || date1,
+        updated_by: opt_author || reviewer1,
+        state,
+      };
+    };
+    let change = {
+      reviewer_updates: [
+        makeItem('REVIEWER', reviewer1), // New group.
+        makeItem('CC', reviewer2), // Appended.
+        makeItem('REVIEWER', reviewer2, date2), // Overrides previous one.
+
+        makeItem('CC', reviewer1, date2, reviewer2), // New group.
+
+        makeItem('REMOVED', reviewer2, date3), // Group has no state change.
+        makeItem('REVIEWER', reviewer2, date3),
+
+        makeItem('CC', reviewer1, date4), // No change, removed.
+        makeItem('REVIEWER', reviewer1, date4), // Forms new group
+        makeItem('REMOVED', reviewer2, date4), // Should be grouped.
+      ],
+    };
+
+    instance = new GrReviewerUpdatesParser(change);
+    instance._groupUpdates();
+    change = instance.result;
+
+    assert.equal(change.reviewer_updates.length, 3);
+    assert.equal(change.reviewer_updates[0].updates.length, 2);
+    assert.equal(change.reviewer_updates[1].updates.length, 1);
+    assert.equal(change.reviewer_updates[2].updates.length, 2);
+
+    assert.equal(change.reviewer_updates[0].date, date1);
+    assert.deepEqual(change.reviewer_updates[0].author, reviewer1);
+    assert.deepEqual(change.reviewer_updates[0].updates, [
+      {
+        reviewer: reviewer1,
+        state: 'REVIEWER',
+      },
+      {
+        reviewer: reviewer2,
+        state: 'REVIEWER',
+      },
+    ]);
+
+    assert.equal(change.reviewer_updates[1].date, date2);
+    assert.deepEqual(change.reviewer_updates[1].author, reviewer2);
+    assert.deepEqual(change.reviewer_updates[1].updates, [
+      {
+        reviewer: reviewer1,
+        state: 'CC',
+        prev_state: 'REVIEWER',
+      },
+    ]);
+
+    assert.equal(change.reviewer_updates[2].date, date4);
+    assert.deepEqual(change.reviewer_updates[2].author, reviewer1);
+    assert.deepEqual(change.reviewer_updates[2].updates, [
+      {
+        reviewer: reviewer1,
+        prev_state: 'CC',
+        state: 'REVIEWER',
+      },
+      {
+        reviewer: reviewer2,
+        prev_state: 'REVIEWER',
+        state: 'REMOVED',
+      },
+    ]);
+  });
+
+  test('format reviewer updates', () => {
+    const reviewer1 = {_account_id: 1};
+    const reviewer2 = {_account_id: 2};
+    const makeItem = function(prev, state, opt_reviewer) {
+      return {
+        reviewer: opt_reviewer || reviewer1,
+        prev_state: prev,
+        state,
+      };
+    };
+    const makeUpdate = function(items) {
+      return {
+        author: reviewer1,
+        updated: '',
+        updates: items,
+      };
+    };
+    const change = {
+      reviewer_updates: [
+        makeUpdate([
+          makeItem(undefined, 'CC'),
+          makeItem(undefined, 'CC', reviewer2),
+        ]),
+        makeUpdate([
+          makeItem('CC', 'REVIEWER'),
+          makeItem('REVIEWER', 'REMOVED'),
+          makeItem('REMOVED', 'REVIEWER'),
+          makeItem(undefined, 'REVIEWER', reviewer2),
+        ]),
+      ],
+    };
+
+    instance = new GrReviewerUpdatesParser(change);
+    instance._formatUpdates();
+
+    assert.equal(change.reviewer_updates.length, 2);
+    assert.equal(change.reviewer_updates[0].updates.length, 1);
+    assert.equal(change.reviewer_updates[1].updates.length, 3);
+
+    let items = change.reviewer_updates[0].updates;
+    assert.equal(items[0].message, 'Added to cc: ');
+    assert.deepEqual(items[0].reviewers, [reviewer1, reviewer2]);
+
+    items = change.reviewer_updates[1].updates;
+    assert.equal(items[0].message, 'Moved from cc to reviewer: ');
+    assert.deepEqual(items[0].reviewers, [reviewer1]);
+    assert.equal(items[1].message, 'Removed from reviewer: ');
+    assert.deepEqual(items[1].reviewers, [reviewer1]);
+    assert.equal(items[2].message, 'Added to reviewer: ');
+    assert.deepEqual(items[2].reviewers, [reviewer1, reviewer2]);
+  });
+
+  test('_advanceUpdates', () => {
+    const T0 = parseDate('2017-02-17 19:04:18.000000000').getTime();
+    const tplus = delta => new Date(T0 + delta)
+        .toISOString()
+        .replace('T', ' ')
+        .replace('Z', '000000');
+    const change = {
+      reviewer_updates: [{
+        date: tplus(0),
+        type: 'REVIEWER_UPDATE',
+        updates: [{
+          message: 'same time update',
+        }],
+      }, {
+        date: tplus(200),
+        type: 'REVIEWER_UPDATE',
+        updates: [{
+          message: 'update within threshold',
+        }],
+      }, {
+        date: tplus(600),
+        type: 'REVIEWER_UPDATE',
+        updates: [{
+          message: 'update between messages',
+        }],
+      }, {
+        date: tplus(1000),
+        type: 'REVIEWER_UPDATE',
+        updates: [{
+          message: 'late update',
+        }],
+      }],
+      messages: [{
+        id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
+        date: tplus(0),
+        message: 'Uploaded patch set 1.',
+      }, {
+        id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
+        date: tplus(800),
+        message: 'Uploaded patch set 2.',
+      }],
+    };
+    instance = new GrReviewerUpdatesParser(change);
+    instance._advanceUpdates();
+    const updates = instance.result.reviewer_updates;
+    assert.isBelow(parseDate(updates[0].date).getTime(), T0);
+    assert.isBelow(parseDate(updates[1].date).getTime(), T0);
+    assert.equal(updates[2].date, tplus(100));
+    assert.equal(updates[3].date, tplus(500));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
deleted file mode 100644
index e061e93..0000000
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-select">
-  <slot></slot>
-  
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/**
- * @extends Polymer.Element
- */
-class GrSelect extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  static get is() { return 'gr-select'; }
-
-  static get properties() {
-    return {
-      bindValue: {
-        type: String,
-        notify: true,
-        observer: '_updateValue',
-      },
-    };
-  }
-
-  get nativeSelect() {
-    // gr-select is not a shadow component
-    // TODO(taoalpha): maybe we should convert
-    // it into a shadow dom component instead
-    return this.querySelector('select');
-  }
-
-  _updateValue() {
-    // It's possible to have a value of 0.
-    if (this.bindValue !== undefined) {
-      // Set for chrome/safari so it happens instantly
-      this.nativeSelect.value = this.bindValue;
-      // Async needed for firefox to populate value. It was trying to do it
-      // before options from a dom-repeat were rendered previously.
-      // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
-      this.async(() => {
-        this.nativeSelect.value = this.bindValue;
-      }, 1);
-    }
-  }
-
-  _valueChanged() {
-    this.bindValue = this.nativeSelect.value;
-  }
-
-  focus() {
-    this.nativeSelect.focus();
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('change',
-        () => this._valueChanged());
-    this.addEventListener('dom-change',
-        () => this._updateValue());
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    // If not set via the property, set bind-value to the element value.
-    if (this.bindValue == undefined && this.nativeSelect.options.length > 0) {
-      this.bindValue = this.nativeSelect.value;
-    }
-  }
-}
-
-customElements.define(GrSelect.is, GrSelect);
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
new file mode 100644
index 0000000..a2c1253
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
@@ -0,0 +1,91 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {customElement, property, observe} from '@polymer/decorators';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-select': GrSelect;
+  }
+}
+
+/**
+ * GrSelect `gr-select` component.
+ */
+@customElement('gr-select')
+export class GrSelect extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return html` <slot></slot> `;
+  }
+
+  @property({type: String, notify: true})
+  bindValue?: string;
+
+  get nativeSelect() {
+    // gr-select is not a shadow component
+    // TODO(taoalpha): maybe we should convert
+    // it into a shadow dom component instead
+    // TODO(TS): should warn if no `select` detected.
+    return this.querySelector('select')!;
+  }
+
+  @observe('bindValue')
+  _updateValue() {
+    // It's possible to have a value of 0.
+    if (this.bindValue !== undefined) {
+      // Set for chrome/safari so it happens instantly
+      this.nativeSelect.value = this.bindValue;
+      // Async needed for firefox to populate value. It was trying to do it
+      // before options from a dom-repeat were rendered previously.
+      // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
+      this.async(() => {
+        // TODO(TS): maybe should check for undefined before assigning
+        // or fallback to ''
+        this.nativeSelect.value = this.bindValue!;
+      }, 1);
+    }
+  }
+
+  _valueChanged() {
+    this.bindValue = this.nativeSelect.value;
+  }
+
+  focus() {
+    this.nativeSelect.focus();
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('change', () => this._valueChanged());
+    this.addEventListener('dom-change', () => this._updateValue());
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    // If not set via the property, set bind-value to the element value.
+    if (this.bindValue === undefined && this.nativeSelect.options.length > 0) {
+      this.bindValue = this.nativeSelect.value;
+    }
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
deleted file mode 100644
index 670f383..0000000
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
+++ /dev/null
@@ -1,120 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-select</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-select>
-      <select>
-        <option value="1">One</option>
-        <option value="2">Two</option>
-        <option value="3">Three</option>
-      </select>
-    </gr-select>
-  </template>
-</test-fixture>
-
-<test-fixture id="noOptions">
-  <template>
-    <gr-select>
-      <select>
-      </select>
-    </gr-select>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-select.js';
-suite('gr-select tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('bindValue must be set to the first option value', () => {
-    assert.equal(element.bindValue, '1');
-  });
-
-  test('value of 0 should still trigger value updates', () => {
-    element.bindValue = 0;
-    assert.equal(element.nativeSelect.value, 0);
-  });
-
-  test('bidirectional binding property-to-attribute', () => {
-    const changeStub = sinon.stub();
-    element.addEventListener('bind-value-changed', changeStub);
-
-    // The selected element should be the first one by default.
-    assert.equal(element.nativeSelect.value, '1');
-    assert.equal(element.bindValue, '1');
-    assert.isFalse(changeStub.called);
-
-    // Now change the value.
-    element.bindValue = '2';
-
-    // It should be updated.
-    assert.equal(element.nativeSelect.value, '2');
-    assert.equal(element.bindValue, '2');
-    assert.isTrue(changeStub.called);
-  });
-
-  test('bidirectional binding attribute-to-property', () => {
-    const changeStub = sinon.stub();
-    element.addEventListener('bind-value-changed', changeStub);
-
-    // The selected element should be the first one by default.
-    assert.equal(element.nativeSelect.value, '1');
-    assert.equal(element.bindValue, '1');
-    assert.isFalse(changeStub.called);
-
-    // Now change the value.
-    element.nativeSelect.value = '3';
-    element.dispatchEvent(
-        new CustomEvent('change', {
-          composed: true, bubbles: true,
-        }));
-
-    // It should be updated.
-    assert.equal(element.nativeSelect.value, '3');
-    assert.equal(element.bindValue, '3');
-    assert.isTrue(changeStub.called);
-  });
-
-  suite('gr-select no options tests', () => {
-    let element;
-
-    setup(() => {
-      element = fixture('noOptions');
-    });
-
-    test('bindValue must not be changed', () => {
-      assert.isUndefined(element.bindValue);
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js
new file mode 100644
index 0000000..c697850
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-select.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-select>
+      <select>
+        <option value="1">One</option>
+        <option value="2">Two</option>
+        <option value="3">Three</option>
+      </select>
+    </gr-select>
+`);
+
+const noOptionsFixture = fixtureFromTemplate(html`
+<gr-select>
+      <select>
+      </select>
+    </gr-select>
+`);
+
+suite('gr-select tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('bindValue must be set to the first option value', () => {
+    assert.equal(element.bindValue, '1');
+  });
+
+  test('value of 0 should still trigger value updates', () => {
+    element.bindValue = 0;
+    assert.equal(element.nativeSelect.value, 0);
+  });
+
+  test('bidirectional binding property-to-attribute', () => {
+    const changeStub = sinon.stub();
+    element.addEventListener('bind-value-changed', changeStub);
+
+    // The selected element should be the first one by default.
+    assert.equal(element.nativeSelect.value, '1');
+    assert.equal(element.bindValue, '1');
+    assert.isFalse(changeStub.called);
+
+    // Now change the value.
+    element.bindValue = '2';
+
+    // It should be updated.
+    assert.equal(element.nativeSelect.value, '2');
+    assert.equal(element.bindValue, '2');
+    assert.isTrue(changeStub.called);
+  });
+
+  test('bidirectional binding attribute-to-property', () => {
+    const changeStub = sinon.stub();
+    element.addEventListener('bind-value-changed', changeStub);
+
+    // The selected element should be the first one by default.
+    assert.equal(element.nativeSelect.value, '1');
+    assert.equal(element.bindValue, '1');
+    assert.isFalse(changeStub.called);
+
+    // Now change the value.
+    element.nativeSelect.value = '3';
+    element.dispatchEvent(
+        new CustomEvent('change', {
+          composed: true, bubbles: true,
+        }));
+
+    // It should be updated.
+    assert.equal(element.nativeSelect.value, '3');
+    assert.equal(element.bindValue, '3');
+    assert.isTrue(changeStub.called);
+  });
+
+  suite('gr-select no options tests', () => {
+    let element;
+
+    setup(() => {
+      element = noOptionsFixture.instantiate();
+    });
+
+    test('bindValue must not be changed', () => {
+      assert.isUndefined(element.bindValue);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
index 151498c..a5212fc 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../../../styles/shared-styles.js';
 import '../gr-copy-clipboard/gr-copy-clipboard.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
@@ -23,7 +21,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-shell-command_html.js';
 
-/** @extends Polymer.Element */
+/** @extends PolymerElement */
 class GrShellCommand extends GestureEventListeners(
     LegacyElementMixin(
         PolymerElement)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.js
deleted file mode 100644
index 4a4480e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.js
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .commandContainer {
-      margin-bottom: var(--spacing-m);
-    }
-    .commandContainer {
-      background-color: var(--shell-command-background-color);
-      /* Should be spacing-m larger than the :before width. */
-      padding: var(--spacing-m) var(--spacing-m) var(--spacing-m)
-        calc(3 * var(--spacing-m) + 0.5em);
-      position: relative;
-      width: 100%;
-    }
-    .commandContainer:before {
-      content: '$';
-      position: absolute;
-      display: block;
-      box-sizing: border-box;
-      background: var(--shell-command-decoration-background-color);
-      top: 0;
-      bottom: 0;
-      left: 0;
-      /* Should be spacing-m smaller than the .commandContainer padding-left. */
-      width: calc(2 * var(--spacing-m) + 0.5em);
-      /* Should vertically match the padding of .commandContainer. */
-      padding: var(--spacing-m);
-      /* Should roughly match the height of .commandContainer without padding. */
-      line-height: 26px;
-    }
-    .commandContainer gr-copy-clipboard {
-      --text-container-style: {
-        border: none;
-      }
-    }
-  </style>
-  <label>[[label]]</label>
-  <div class="commandContainer">
-    <gr-copy-clipboard text="[[command]]"></gr-copy-clipboard>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts
new file mode 100644
index 0000000..ef76999
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts
@@ -0,0 +1,58 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    .commandContainer {
+      margin-bottom: var(--spacing-m);
+    }
+    .commandContainer {
+      background-color: var(--shell-command-background-color);
+      /* Should be spacing-m larger than the :before width. */
+      padding: var(--spacing-m) var(--spacing-m) var(--spacing-m)
+        calc(3 * var(--spacing-m) + 0.5em);
+      position: relative;
+      width: 100%;
+    }
+    .commandContainer:before {
+      content: '$';
+      position: absolute;
+      display: block;
+      box-sizing: border-box;
+      background: var(--shell-command-decoration-background-color);
+      top: 0;
+      bottom: 0;
+      left: 0;
+      /* Should be spacing-m smaller than the .commandContainer padding-left. */
+      width: calc(2 * var(--spacing-m) + 0.5em);
+      /* Should vertically match the padding of .commandContainer. */
+      padding: var(--spacing-m);
+      /* Should roughly match the height of .commandContainer without padding. */
+      line-height: 26px;
+    }
+    .commandContainer gr-copy-clipboard {
+      --text-container-style: {
+        border: none;
+      }
+    }
+  </style>
+  <label>[[label]]</label>
+  <div class="commandContainer">
+    <gr-copy-clipboard text="[[command]]"></gr-copy-clipboard>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
deleted file mode 100644
index ee0b64f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.html
+++ /dev/null
@@ -1,61 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-shell-command</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-shell-command></gr-shell-command>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-shell-command.js';
-suite('gr-shell-command tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    element.text = `git fetch http://gerrit@localhost:8080/a/test-project
-        refs/changes/05/5/1 && git checkout FETCH_HEAD`;
-    flushAsynchronousOperations();
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('focusOnCopy', () => {
-    const focusStub = sandbox.stub(element.shadowRoot
-        .querySelector('gr-copy-clipboard'),
-    'focusOnCopy');
-    element.focusOnCopy();
-    assert.isTrue(focusStub.called);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js
new file mode 100644
index 0000000..5e20717
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-shell-command.js';
+
+const basicFixture = fixtureFromElement('gr-shell-command');
+
+suite('gr-shell-command tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.text = `git fetch http://gerrit@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git checkout FETCH_HEAD`;
+    flushAsynchronousOperations();
+  });
+
+  test('focusOnCopy', () => {
+    const focusStub = sinon.stub(element.shadowRoot
+        .querySelector('gr-copy-clipboard'),
+    'focusOnCopy');
+    element.focusOnCopy();
+    assert.isTrue(focusStub.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
deleted file mode 100644
index 8f5c486..0000000
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ /dev/null
@@ -1,163 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-
-const DURATION_DAY = 24 * 60 * 60 * 1000;
-
-// Clean up old entries no more frequently than one day.
-const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
-
-const CLEANUP_PREFIXES_MAX_AGE_MAP = {
-  // respectfultip has a 14-day expiration
-  'respectfultip:': 14 * DURATION_DAY,
-  'draft:': DURATION_DAY,
-  'editablecontent:': DURATION_DAY,
-};
-
-/** @extends Polymer.Element */
-class GrStorage extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get is() { return 'gr-storage'; }
-
-  static get properties() {
-    return {
-      _lastCleanup: Number,
-      /** @type {?Storage} */
-      _storage: {
-        type: Object,
-        value() {
-          return window.localStorage;
-        },
-      },
-      _exceededQuota: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  getDraftComment(location) {
-    this._cleanupItems();
-    return this._getObject(this._getDraftKey(location));
-  }
-
-  setDraftComment(location, message) {
-    const key = this._getDraftKey(location);
-    this._setObject(key, {message, updated: Date.now()});
-  }
-
-  eraseDraftComment(location) {
-    const key = this._getDraftKey(location);
-    this._storage.removeItem(key);
-  }
-
-  getEditableContentItem(key) {
-    this._cleanupItems();
-    return this._getObject(this._getEditableContentKey(key));
-  }
-
-  setEditableContentItem(key, message) {
-    this._setObject(this._getEditableContentKey(key),
-        {message, updated: Date.now()});
-  }
-
-  getRespectfulTipVisibility() {
-    this._cleanupItems();
-    return this._getObject('respectfultip:visibility');
-  }
-
-  setRespectfulTipVisibility(delayDays = 0) {
-    this._cleanupItems();
-    this._setObject(
-        'respectfultip:visibility',
-        {updated: Date.now() + delayDays * DURATION_DAY}
-    );
-  }
-
-  eraseEditableContentItem(key) {
-    this._storage.removeItem(this._getEditableContentKey(key));
-  }
-
-  _getDraftKey(location) {
-    const range = location.range ?
-      `${location.range.start_line}-${location.range.start_character}` +
-            `-${location.range.end_character}-${location.range.end_line}` :
-      null;
-    let key = ['draft', location.changeNum, location.patchNum, location.path,
-      location.line || ''].join(':');
-    if (range) {
-      key = key + ':' + range;
-    }
-    return key;
-  }
-
-  _getEditableContentKey(key) {
-    return `editablecontent:${key}`;
-  }
-
-  _cleanupItems() {
-    // Throttle cleanup to the throttle interval.
-    if (this._lastCleanup &&
-        Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL) {
-      return;
-    }
-    this._lastCleanup = Date.now();
-
-    let item;
-    Object.keys(this._storage).forEach(key => {
-      Object.keys(CLEANUP_PREFIXES_MAX_AGE_MAP).forEach(prefix => {
-        if (key.startsWith(prefix)) {
-          item = this._getObject(key);
-          const expiration = CLEANUP_PREFIXES_MAX_AGE_MAP[prefix];
-          if (Date.now() - item.updated > expiration) {
-            this._storage.removeItem(key);
-          }
-        }
-      });
-    });
-  }
-
-  _getObject(key) {
-    const serial = this._storage.getItem(key);
-    if (!serial) { return null; }
-    return JSON.parse(serial);
-  }
-
-  _setObject(key, obj) {
-    if (this._exceededQuota) { return; }
-    try {
-      this._storage.setItem(key, JSON.stringify(obj));
-    } catch (exc) {
-      // Catch for QuotaExceededError and disable writes on local storage the
-      // first time that it occurs.
-      if (exc.code === 22) {
-        this._exceededQuota = true;
-        console.warn('Local storage quota exceeded: disabling');
-        return;
-      } else {
-        throw exc;
-      }
-    }
-  }
-}
-
-customElements.define(GrStorage.is, GrStorage);
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
new file mode 100644
index 0000000..176f6c9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
@@ -0,0 +1,183 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement, property} from '@polymer/decorators';
+import {CommentRange, PatchSetNum} from '../../../types/common';
+
+export interface StorageLocation {
+  changeNum: number;
+  patchNum: PatchSetNum;
+  path: string;
+  line: number;
+  range: CommentRange;
+}
+
+export interface StorageObject {
+  message?: string;
+  updated: number;
+}
+
+const DURATION_DAY = 24 * 60 * 60 * 1000;
+
+// Clean up old entries no more frequently than one day.
+const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
+
+const CLEANUP_PREFIXES_MAX_AGE_MAP = new Map<string, number>();
+CLEANUP_PREFIXES_MAX_AGE_MAP.set('respectfultip', 14 * DURATION_DAY);
+CLEANUP_PREFIXES_MAX_AGE_MAP.set('draft', DURATION_DAY);
+CLEANUP_PREFIXES_MAX_AGE_MAP.set('editablecontent', DURATION_DAY);
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-storage': GrStorage;
+  }
+}
+
+export interface GrStorage {
+  $: {};
+}
+
+@customElement('gr-storage')
+export class GrStorage extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  @property({type: Number})
+  _lastCleanup = 0;
+
+  @property({type: Object})
+  _storage = window.localStorage;
+
+  @property({type: Boolean})
+  _exceededQuota = false;
+
+  getDraftComment(location: StorageLocation): StorageObject | null {
+    this._cleanupItems();
+    return this._getObject(this._getDraftKey(location));
+  }
+
+  setDraftComment(location: StorageLocation, message: string) {
+    const key = this._getDraftKey(location);
+    this._setObject(key, {message, updated: Date.now()});
+  }
+
+  eraseDraftComment(location: StorageLocation) {
+    const key = this._getDraftKey(location);
+    this._storage.removeItem(key);
+  }
+
+  getEditableContentItem(key: string): StorageObject | null {
+    this._cleanupItems();
+    return this._getObject(this._getEditableContentKey(key));
+  }
+
+  setEditableContentItem(key: string, message: string) {
+    this._setObject(this._getEditableContentKey(key), {
+      message,
+      updated: Date.now(),
+    });
+  }
+
+  getRespectfulTipVisibility(): StorageObject | null {
+    this._cleanupItems();
+    return this._getObject('respectfultip:visibility');
+  }
+
+  setRespectfulTipVisibility(delayDays = 0) {
+    this._cleanupItems();
+    this._setObject('respectfultip:visibility', {
+      updated: Date.now() + delayDays * DURATION_DAY,
+    });
+  }
+
+  eraseEditableContentItem(key: string) {
+    this._storage.removeItem(this._getEditableContentKey(key));
+  }
+
+  _getDraftKey(location: StorageLocation): string {
+    const range = location.range
+      ? `${location.range.start_line}-${location.range.start_character}` +
+        `-${location.range.end_character}-${location.range.end_line}`
+      : null;
+    let key = [
+      'draft',
+      location.changeNum,
+      location.patchNum,
+      location.path,
+      location.line || '',
+    ].join(':');
+    if (range) {
+      key = key + ':' + range;
+    }
+    return key;
+  }
+
+  _getEditableContentKey(key: string): string {
+    return `editablecontent:${key}`;
+  }
+
+  _cleanupItems() {
+    // Throttle cleanup to the throttle interval.
+    if (
+      this._lastCleanup &&
+      Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL
+    ) {
+      return;
+    }
+    this._lastCleanup = Date.now();
+
+    Object.keys(this._storage).forEach(key => {
+      const entries = CLEANUP_PREFIXES_MAX_AGE_MAP.entries();
+      for (const [prefix, expiration] of entries) {
+        if (key.startsWith(prefix)) {
+          const item = this._getObject(key);
+          if (!item || Date.now() - item.updated > expiration) {
+            this._storage.removeItem(key);
+          }
+        }
+      }
+    });
+  }
+
+  _getObject(key: string): StorageObject | null {
+    const serial = this._storage.getItem(key);
+    if (!serial) {
+      return null;
+    }
+    return JSON.parse(serial) as StorageObject;
+  }
+
+  _setObject(key: string, obj: StorageObject) {
+    if (this._exceededQuota) {
+      return;
+    }
+    try {
+      this._storage.setItem(key, JSON.stringify(obj));
+    } catch (exc) {
+      // Catch for QuotaExceededError and disable writes on local storage the
+      // first time that it occurs.
+      if (exc.code === 22) {
+        this._exceededQuota = true;
+        console.warn('Local storage quota exceeded: disabling');
+        return;
+      } else {
+        throw exc;
+      }
+    }
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
deleted file mode 100644
index b560c56..0000000
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
+++ /dev/null
@@ -1,195 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2016 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-storage</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-storage></gr-storage>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-storage.js';
-suite('gr-storage tests', () => {
-  let element;
-  let sandbox;
-
-  function mockStorage(opt_quotaExceeded) {
-    return {
-      getItem(key) { return this[key]; },
-      removeItem(key) { delete this[key]; },
-      setItem(key, value) {
-        // eslint-disable-next-line no-throw-literal
-        if (opt_quotaExceeded) { throw {code: 22}; /* Quota exceeded */ }
-        this[key] = value;
-      },
-    };
-  }
-
-  setup(() => {
-    element = fixture('basic');
-    sandbox = sinon.sandbox.create();
-    element._storage = mockStorage();
-  });
-
-  teardown(() => sandbox.restore());
-
-  test('storing, retrieving and erasing drafts', () => {
-    const changeNum = 1234;
-    const patchNum = 5;
-    const path = 'my_source_file.js';
-    const line = 123;
-    const location = {
-      changeNum,
-      patchNum,
-      path,
-      line,
-    };
-
-    // The key is in the expected format.
-    const key = element._getDraftKey(location);
-    assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
-
-    // There should be no draft initially.
-    const draft = element.getDraftComment(location);
-    assert.isNotOk(draft);
-
-    // Setting the draft stores it under the expected key.
-    element.setDraftComment(location, 'my comment');
-    assert.isOk(element._storage.getItem(key));
-    assert.equal(JSON.parse(element._storage.getItem(key)).message,
-        'my comment');
-    assert.isOk(JSON.parse(element._storage.getItem(key)).updated);
-
-    // Erasing the draft removes the key.
-    element.eraseDraftComment(location);
-    assert.isNotOk(element._storage.getItem(key));
-  });
-
-  test('automatically removes old drafts', () => {
-    const changeNum = 1234;
-    const patchNum = 5;
-    const path = 'my_source_file.js';
-    const line = 123;
-    const location = {
-      changeNum,
-      patchNum,
-      path,
-      line,
-    };
-
-    const key = element._getDraftKey(location);
-
-    // Make sure that the call to cleanup doesn't get throttled.
-    element._lastCleanup = 0;
-
-    const cleanupSpy = sandbox.spy(element, '_cleanupItems');
-
-    // Create a message with a timestamp that is a second behind the max age.
-    element._storage.setItem(key, JSON.stringify({
-      message: 'old message',
-      updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
-    }));
-
-    // Getting the draft should cause it to be removed.
-    const draft = element.getDraftComment(location);
-
-    assert.isTrue(cleanupSpy.called);
-    assert.isNotOk(draft);
-    assert.isNotOk(element._storage.getItem(key));
-  });
-
-  test('_getDraftKey', () => {
-    const changeNum = 1234;
-    const patchNum = 5;
-    const path = 'my_source_file.js';
-    const line = 123;
-    const location = {
-      changeNum,
-      patchNum,
-      path,
-      line,
-    };
-    let expectedResult = 'draft:1234:5:my_source_file.js:123';
-    assert.equal(element._getDraftKey(location), expectedResult);
-    location.range = {
-      start_character: 1,
-      start_line: 1,
-      end_character: 1,
-      end_line: 2,
-    };
-    expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
-    assert.equal(element._getDraftKey(location), expectedResult);
-  });
-
-  test('exceeded quota disables storage', () => {
-    element._storage = mockStorage(true);
-    assert.isFalse(element._exceededQuota);
-
-    const changeNum = 1234;
-    const patchNum = 5;
-    const path = 'my_source_file.js';
-    const line = 123;
-    const location = {
-      changeNum,
-      patchNum,
-      path,
-      line,
-    };
-    const key = element._getDraftKey(location);
-    element.setDraftComment(location, 'my comment');
-    assert.isTrue(element._exceededQuota);
-    assert.isNotOk(element._storage.getItem(key));
-  });
-
-  test('editable content items', () => {
-    const cleanupStub = sandbox.stub(element, '_cleanupItems');
-    const key = 'testKey';
-    const computedKey = element._getEditableContentKey(key);
-    // Key correctly computed.
-    assert.equal(computedKey, 'editablecontent:testKey');
-
-    element.setEditableContentItem(key, 'my content');
-
-    // Setting the draft stores it under the expected key.
-    let item = element._storage.getItem(computedKey);
-    assert.isOk(item);
-    assert.equal(JSON.parse(item).message, 'my content');
-    assert.isOk(JSON.parse(item).updated);
-
-    // getEditableContentItem performs as expected.
-    item = element.getEditableContentItem(key);
-    assert.isOk(item);
-    assert.equal(item.message, 'my content');
-    assert.isOk(item.updated);
-    assert.isTrue(cleanupStub.called);
-
-    // eraseEditableContentItem performs as expected.
-    element.eraseEditableContentItem(key);
-    assert.isNotOk(element._storage.getItem(computedKey));
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js
new file mode 100644
index 0000000..99f953f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js
@@ -0,0 +1,179 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-storage.js';
+
+const basicFixture = fixtureFromElement('gr-storage');
+
+suite('gr-storage tests', () => {
+  let element;
+
+  function mockStorage(opt_quotaExceeded) {
+    return {
+      getItem(key) { return this[key]; },
+      removeItem(key) { delete this[key]; },
+      setItem(key, value) {
+        // eslint-disable-next-line no-throw-literal
+        if (opt_quotaExceeded) { throw {code: 22}; /* Quota exceeded */ }
+        this[key] = value;
+      },
+    };
+  }
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    element._storage = mockStorage();
+  });
+
+  test('storing, retrieving and erasing drafts', () => {
+    const changeNum = 1234;
+    const patchNum = 5;
+    const path = 'my_source_file.js';
+    const line = 123;
+    const location = {
+      changeNum,
+      patchNum,
+      path,
+      line,
+    };
+
+    // The key is in the expected format.
+    const key = element._getDraftKey(location);
+    assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
+
+    // There should be no draft initially.
+    const draft = element.getDraftComment(location);
+    assert.isNotOk(draft);
+
+    // Setting the draft stores it under the expected key.
+    element.setDraftComment(location, 'my comment');
+    assert.isOk(element._storage.getItem(key));
+    assert.equal(JSON.parse(element._storage.getItem(key)).message,
+        'my comment');
+    assert.isOk(JSON.parse(element._storage.getItem(key)).updated);
+
+    // Erasing the draft removes the key.
+    element.eraseDraftComment(location);
+    assert.isNotOk(element._storage.getItem(key));
+  });
+
+  test('automatically removes old drafts', () => {
+    const changeNum = 1234;
+    const patchNum = 5;
+    const path = 'my_source_file.js';
+    const line = 123;
+    const location = {
+      changeNum,
+      patchNum,
+      path,
+      line,
+    };
+
+    const key = element._getDraftKey(location);
+
+    // Make sure that the call to cleanup doesn't get throttled.
+    element._lastCleanup = 0;
+
+    const cleanupSpy = sinon.spy(element, '_cleanupItems');
+
+    // Create a message with a timestamp that is a second behind the max age.
+    element._storage.setItem(key, JSON.stringify({
+      message: 'old message',
+      updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
+    }));
+
+    // Getting the draft should cause it to be removed.
+    const draft = element.getDraftComment(location);
+
+    assert.isTrue(cleanupSpy.called);
+    assert.isNotOk(draft);
+    assert.isNotOk(element._storage.getItem(key));
+  });
+
+  test('_getDraftKey', () => {
+    const changeNum = 1234;
+    const patchNum = 5;
+    const path = 'my_source_file.js';
+    const line = 123;
+    const location = {
+      changeNum,
+      patchNum,
+      path,
+      line,
+    };
+    let expectedResult = 'draft:1234:5:my_source_file.js:123';
+    assert.equal(element._getDraftKey(location), expectedResult);
+    location.range = {
+      start_character: 1,
+      start_line: 1,
+      end_character: 1,
+      end_line: 2,
+    };
+    expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
+    assert.equal(element._getDraftKey(location), expectedResult);
+  });
+
+  test('exceeded quota disables storage', () => {
+    element._storage = mockStorage(true);
+    assert.isFalse(element._exceededQuota);
+
+    const changeNum = 1234;
+    const patchNum = 5;
+    const path = 'my_source_file.js';
+    const line = 123;
+    const location = {
+      changeNum,
+      patchNum,
+      path,
+      line,
+    };
+    const key = element._getDraftKey(location);
+    element.setDraftComment(location, 'my comment');
+    assert.isTrue(element._exceededQuota);
+    assert.isNotOk(element._storage.getItem(key));
+  });
+
+  test('editable content items', () => {
+    const cleanupStub = sinon.stub(element, '_cleanupItems');
+    const key = 'testKey';
+    const computedKey = element._getEditableContentKey(key);
+    // Key correctly computed.
+    assert.equal(computedKey, 'editablecontent:testKey');
+
+    element.setEditableContentItem(key, 'my content');
+
+    // Setting the draft stores it under the expected key.
+    let item = element._storage.getItem(computedKey);
+    assert.isOk(item);
+    assert.equal(JSON.parse(item).message, 'my content');
+    assert.isOk(JSON.parse(item).updated);
+
+    // getEditableContentItem performs as expected.
+    item = element.getEditableContentItem(key);
+    assert.isOk(item);
+    assert.equal(item.message, 'my content');
+    assert.isOk(item.updated);
+    assert.isTrue(cleanupStub.called);
+
+    // eraseEditableContentItem performs as expected.
+    element.eraseEditableContentItem(key);
+    assert.isNotOk(element._storage.getItem(computedKey));
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
index 15ab8e4..8f763f1 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
@@ -14,22 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown.js';
 import '../gr-cursor-manager/gr-cursor-manager.js';
 import '../gr-overlay/gr-overlay.js';
 import '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior.js';
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
 import '../../../styles/shared-styles.js';
-import '../../core/gr-reporting/gr-reporting.js';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-textarea_html.js';
-import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {appContext} from '../../../services/app-context.js';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -67,13 +64,10 @@
 ];
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrTextarea extends mixinBehaviors( [
-  KeyboardShortcutBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrTextarea extends KeyboardShortcutMixin(GestureEventListeners(
+    LegacyElementMixin(PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-textarea'; }
@@ -139,6 +133,11 @@
     };
   }
 
+  constructor() {
+    super();
+    this.reporting = appContext.reportingService;
+  }
+
   /** @override */
   ready() {
     super.ready();
@@ -214,7 +213,7 @@
     this.text = this._getText(text);
     this.$.textarea.selectionStart = colonIndex + 1;
     this.$.textarea.selectionEnd = colonIndex + 1;
-    this.$.reporting.reportInteraction('select-emoji', {type: text});
+    this.reporting.reportInteraction('select-emoji', {type: text});
     this._resetEmojiDropdown();
   }
 
@@ -302,7 +301,7 @@
 
   _openEmojiDropdown() {
     this.$.emojiSuggestions.open();
-    this.$.reporting.reportInteraction('open-emoji-dropdown');
+    this.reporting.reportInteraction('open-emoji-dropdown');
   }
 
   _formatSuggestions(matchedSuggestions) {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.js
deleted file mode 100644
index 61e530a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.js
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: flex;
-      position: relative;
-    }
-    :host(.monospace) {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      font-weight: var(--font-weight-normal);
-    }
-    :host(.code) {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-code);
-      line-height: var(--line-height-code);
-      font-weight: var(--font-weight-normal);
-    }
-    #emojiSuggestions {
-      font-family: var(--font-family);
-    }
-    gr-autocomplete {
-      display: inline-block;
-    }
-    #textarea {
-      background-color: var(--view-background-color);
-      width: 100%;
-    }
-    #hiddenText #emojiSuggestions {
-      visibility: visible;
-      white-space: normal;
-    }
-    iron-autogrow-textarea {
-      position: relative;
-    }
-    #textarea.noBorder {
-      border: none;
-    }
-    #hiddenText {
-      display: block;
-      float: left;
-      position: absolute;
-      visibility: hidden;
-      width: 100%;
-      white-space: pre-wrap;
-    }
-  </style>
-  <div id="hiddenText"></div>
-  <!-- When the autocomplete is open, the span is moved at the end of
-      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
-    vertical-align="top"
-    horizontal-align="left"
-    dynamic-align=""
-    id="emojiSuggestions"
-    suggestions="[[_suggestions]]"
-    index="[[_index]]"
-    vertical-offset="[[_verticalOffset]]"
-    on-dropdown-closed="_resetEmojiDropdown"
-    on-item-selected="_handleEmojiSelect"
-  >
-  </gr-autocomplete-dropdown>
-  <iron-autogrow-textarea
-    id="textarea"
-    autocomplete="[[autocomplete]]"
-    placeholder="[[placeholder]]"
-    disabled="[[disabled]]"
-    rows="[[rows]]"
-    max-rows="[[maxRows]]"
-    value="{{text}}"
-    on-bind-value-changed="_onValueChanged"
-  ></iron-autogrow-textarea>
-  <gr-reporting id="reporting"></gr-reporting>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
new file mode 100644
index 0000000..1f777aa
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
@@ -0,0 +1,93 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      display: flex;
+      position: relative;
+    }
+    :host(.monospace) {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-mono);
+      line-height: var(--line-height-mono);
+      font-weight: var(--font-weight-normal);
+    }
+    :host(.code) {
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-code);
+      line-height: var(--line-height-code);
+      font-weight: var(--font-weight-normal);
+    }
+    #emojiSuggestions {
+      font-family: var(--font-family);
+    }
+    gr-autocomplete {
+      display: inline-block;
+    }
+    #textarea {
+      background-color: var(--view-background-color);
+      width: 100%;
+    }
+    #hiddenText #emojiSuggestions {
+      visibility: visible;
+      white-space: normal;
+    }
+    iron-autogrow-textarea {
+      position: relative;
+    }
+    #textarea.noBorder {
+      border: none;
+    }
+    #hiddenText {
+      display: block;
+      float: left;
+      position: absolute;
+      visibility: hidden;
+      width: 100%;
+      white-space: pre-wrap;
+    }
+  </style>
+  <div id="hiddenText"></div>
+  <!-- When the autocomplete is open, the span is moved at the end of
+      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
+    vertical-align="top"
+    horizontal-align="left"
+    dynamic-align=""
+    id="emojiSuggestions"
+    suggestions="[[_suggestions]]"
+    index="[[_index]]"
+    vertical-offset="[[_verticalOffset]]"
+    on-dropdown-closed="_resetEmojiDropdown"
+    on-item-selected="_handleEmojiSelect"
+  >
+  </gr-autocomplete-dropdown>
+  <iron-autogrow-textarea
+    id="textarea"
+    autocomplete="[[autocomplete]]"
+    placeholder="[[placeholder]]"
+    disabled="[[disabled]]"
+    rows="[[rows]]"
+    max-rows="[[maxRows]]"
+    value="{{text}}"
+    on-bind-value-changed="_onValueChanged"
+  ></iron-autogrow-textarea>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
deleted file mode 100644
index c33b2ae..0000000
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
+++ /dev/null
@@ -1,378 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-textarea</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-textarea></gr-textarea>
-  </template>
-</test-fixture>
-
-<test-fixture id="monospace">
-  <template>
-    <gr-textarea monospace="true"></gr-textarea>
-  </template>
-</test-fixture>
-
-<test-fixture id="hideBorder">
-  <template>
-    <gr-textarea hide-border="true"></gr-textarea>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-textarea.js';
-suite('gr-textarea tests', () => {
-  let element;
-  let sandbox;
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-    element = fixture('basic');
-    sandbox.stub(element.$.reporting, 'reportInteraction');
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('monospace is set properly', () => {
-    assert.isFalse(element.classList.contains('monospace'));
-  });
-
-  test('hideBorder is set properly', () => {
-    assert.isFalse(element.$.textarea.classList.contains('noBorder'));
-  });
-
-  test('emoji selector is not open with the textarea lacks focus', () => {
-    element.$.textarea.selectionStart = 1;
-    element.$.textarea.selectionEnd = 1;
-    element.text = ':';
-    assert.isFalse(!element.$.emojiSuggestions.isHidden);
-  });
-
-  test('emoji selector is not open when a general text is entered', () => {
-    MockInteractions.focus(element.$.textarea);
-    element.$.textarea.selectionStart = 9;
-    element.$.textarea.selectionEnd = 9;
-    element.text = 'some text';
-    assert.isFalse(!element.$.emojiSuggestions.isHidden);
-  });
-
-  test('emoji selector opens when a colon is typed & the textarea has focus',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 1;
-        element.text = ':';
-        flushAsynchronousOperations();
-        assert.isFalse(element.$.emojiSuggestions.isHidden);
-        assert.equal(element._colonIndex, 0);
-        assert.isFalse(element._hideAutocomplete);
-        assert.equal(element._currentSearchString, '');
-      });
-
-  test('emoji selector opens when a colon is typed after space',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 2;
-        element.$.textarea.selectionEnd = 2;
-        element.text = ' :';
-        flushAsynchronousOperations();
-        assert.isFalse(element.$.emojiSuggestions.isHidden);
-        assert.equal(element._colonIndex, 1);
-        assert.isFalse(element._hideAutocomplete);
-        assert.equal(element._currentSearchString, '');
-      });
-
-  test('emoji selector doesn\`t open when a colon is typed after character',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 5;
-        element.$.textarea.selectionEnd = 5;
-        element.text = 'test:';
-        flushAsynchronousOperations();
-        assert.isTrue(element.$.emojiSuggestions.isHidden);
-        assert.isTrue(element._hideAutocomplete);
-      });
-
-  test('emoji selector opens when a colon is typed and some substring',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 1;
-        element.text = ':';
-        element.$.textarea.selectionStart = 2;
-        element.$.textarea.selectionEnd = 2;
-        element.text = ':t';
-        flushAsynchronousOperations();
-        assert.isFalse(element.$.emojiSuggestions.isHidden);
-        assert.equal(element._colonIndex, 0);
-        assert.isFalse(element._hideAutocomplete);
-        assert.equal(element._currentSearchString, 't');
-      });
-
-  test('emoji selector opens when a colon is typed in middle of text',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 1;
-        // Since selectionStart is on Chrome set always on end of text, we
-        // stub it to 1
-        const text = ': hello';
-        sandbox.stub(element.$, 'textarea', {
-          selectionStart: 1,
-          value: text,
-          textarea: {
-            focus: () => {},
-          },
-        });
-        element.text = text;
-        flushAsynchronousOperations();
-        assert.isFalse(element.$.emojiSuggestions.isHidden);
-        assert.equal(element._colonIndex, 0);
-        assert.isFalse(element._hideAutocomplete);
-        assert.equal(element._currentSearchString, '');
-      });
-  test('emoji selector closes when text changes before the colon', () => {
-    const resetStub = sandbox.stub(element, '_resetEmojiDropdown');
-    MockInteractions.focus(element.$.textarea);
-    flushAsynchronousOperations();
-    element.$.textarea.selectionStart = 10;
-    element.$.textarea.selectionEnd = 10;
-    element.text = 'test test ';
-    element.$.textarea.selectionStart = 12;
-    element.$.textarea.selectionEnd = 12;
-    element.text = 'test test :';
-    element.$.textarea.selectionStart = 15;
-    element.$.textarea.selectionEnd = 15;
-    element.text = 'test test :smi';
-
-    assert.equal(element._currentSearchString, 'smi');
-    assert.isFalse(resetStub.called);
-    element.text = 'test test test :smi';
-    assert.isTrue(resetStub.called);
-  });
-
-  test('_resetEmojiDropdown', () => {
-    const closeSpy = sandbox.spy(element, 'closeDropdown');
-    element._resetEmojiDropdown();
-    assert.equal(element._currentSearchString, '');
-    assert.isTrue(element._hideAutocomplete);
-    assert.equal(element._colonIndex, null);
-
-    element.$.emojiSuggestions.open();
-    flushAsynchronousOperations();
-    element._resetEmojiDropdown();
-    assert.isTrue(closeSpy.called);
-  });
-
-  test('_determineSuggestions', () => {
-    const emojiText = 'tear';
-    const formatSpy = sandbox.spy(element, '_formatSuggestions');
-    element._determineSuggestions(emojiText);
-    assert.isTrue(formatSpy.called);
-    assert.isTrue(formatSpy.lastCall.calledWithExactly(
-        [{dataValue: '😂', value: '😂', match: 'tears :\')',
-          text: '😂 tears :\')'},
-        {dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
-        ]));
-  });
-
-  test('_formatSuggestions', () => {
-    const matchedSuggestions = [{value: '😢', match: 'tear'},
-      {value: '😂', match: 'tears'}];
-    element._formatSuggestions(matchedSuggestions);
-    assert.deepEqual(
-        [{value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
-          {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'}],
-        element._suggestions);
-  });
-
-  test('_handleEmojiSelect', () => {
-    element.$.textarea.selectionStart = 16;
-    element.$.textarea.selectionEnd = 16;
-    element.text = 'test test :tears';
-    element._colonIndex = 10;
-    const selectedItem = {dataset: {value: '😂'}};
-    const event = {detail: {selected: selectedItem}};
-    element._handleEmojiSelect(event);
-    assert.equal(element.text, 'test test 😂');
-  });
-
-  test('_updateCaratPosition', () => {
-    element.$.textarea.selectionStart = 4;
-    element.$.textarea.selectionEnd = 4;
-    element.text = 'test';
-    element._updateCaratPosition();
-    assert.deepEqual(element.$.hiddenText.innerHTML, element.text +
-        element.$.caratSpan.outerHTML);
-  });
-
-  test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
-    const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
-    element.$.emojiSuggestions.dispatchEvent(
-        new CustomEvent('dropdown-closed', {
-          composed: true, bubbles: true,
-        }));
-    assert.isTrue(resetSpy.called);
-  });
-
-  test('_onValueChanged fires bind-value-changed', () => {
-    const listenerStub = sinon.stub();
-    const eventObject = {currentTarget: {focused: false}};
-    element.addEventListener('bind-value-changed', listenerStub);
-    element._onValueChanged(eventObject);
-    assert.isTrue(listenerStub.called);
-  });
-
-  suite('keyboard shortcuts', () => {
-    function setupDropdown(callback) {
-      MockInteractions.focus(element.$.textarea);
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 1;
-      element.text = ':';
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 2;
-      element.text = ':1';
-      flushAsynchronousOperations();
-    }
-
-    test('escape key', () => {
-      const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
-      assert.isFalse(resetSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
-      assert.isTrue(resetSpy.called);
-      assert.isFalse(!element.$.emojiSuggestions.isHidden);
-    });
-
-    test('up key', () => {
-      const upSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorUp');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
-      assert.isFalse(upSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
-      assert.isTrue(upSpy.called);
-    });
-
-    test('down key', () => {
-      const downSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorDown');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
-      assert.isFalse(downSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
-      assert.isTrue(downSpy.called);
-    });
-
-    test('enter key', () => {
-      const enterSpy = sandbox.spy(element.$.emojiSuggestions,
-          'getCursorTarget');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isFalse(enterSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isTrue(enterSpy.called);
-      flushAsynchronousOperations();
-      assert.equal(element.text, '💯');
-    });
-
-    test('enter key - ignored on just colon without more information', () => {
-      const enterSpy = sandbox.spy(element.$.emojiSuggestions,
-          'getCursorTarget');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isFalse(enterSpy.called);
-      MockInteractions.focus(element.$.textarea);
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 1;
-      element.text = ':';
-      flushAsynchronousOperations();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isFalse(enterSpy.called);
-    });
-  });
-
-  suite('gr-textarea monospace', () => {
-  // gr-textarea set monospace class in the ready() method.
-  // In Polymer2, ready() is called from the fixture(...) method,
-  // If ready() is called again later, some nested elements doesn't
-  // handle it correctly. A separate test-fixture is used to set
-  // properties before ready() is called.
-
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('monospace');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('monospace is set properly', () => {
-      assert.isTrue(element.classList.contains('monospace'));
-    });
-  });
-
-  suite('gr-textarea hideBorder', () => {
-  // gr-textarea set noBorder class in the ready() method.
-  // In Polymer2, ready() is called from the fixture(...) method,
-  // If ready() is called again later, some nested elements doesn't
-  // handle it correctly. A separate test-fixture is used to set
-  // properties before ready() is called.
-
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('hideBorder');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('hideBorder is set properly', () => {
-      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
new file mode 100644
index 0000000..2aa7697
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
@@ -0,0 +1,343 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-textarea.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromElement('gr-textarea');
+
+const monospaceFixture = fixtureFromTemplate(html`
+<gr-textarea monospace="true"></gr-textarea>
+`);
+
+const hideBorderFixture = fixtureFromTemplate(html`
+<gr-textarea hide-border="true"></gr-textarea>
+`);
+
+suite('gr-textarea tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    sinon.stub(element.reporting, 'reportInteraction');
+  });
+
+  test('monospace is set properly', () => {
+    assert.isFalse(element.classList.contains('monospace'));
+  });
+
+  test('hideBorder is set properly', () => {
+    assert.isFalse(element.$.textarea.classList.contains('noBorder'));
+  });
+
+  test('emoji selector is not open with the textarea lacks focus', () => {
+    element.$.textarea.selectionStart = 1;
+    element.$.textarea.selectionEnd = 1;
+    element.text = ':';
+    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+  });
+
+  test('emoji selector is not open when a general text is entered', () => {
+    MockInteractions.focus(element.$.textarea);
+    element.$.textarea.selectionStart = 9;
+    element.$.textarea.selectionEnd = 9;
+    element.text = 'some text';
+    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+  });
+
+  test('emoji selector opens when a colon is typed & the textarea has focus',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        element.text = ':';
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.emojiSuggestions.isHidden);
+        assert.equal(element._colonIndex, 0);
+        assert.isFalse(element._hideAutocomplete);
+        assert.equal(element._currentSearchString, '');
+      });
+
+  test('emoji selector opens when a colon is typed after space',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 2;
+        element.$.textarea.selectionEnd = 2;
+        element.text = ' :';
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.emojiSuggestions.isHidden);
+        assert.equal(element._colonIndex, 1);
+        assert.isFalse(element._hideAutocomplete);
+        assert.equal(element._currentSearchString, '');
+      });
+
+  test('emoji selector doesn\`t open when a colon is typed after character',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 5;
+        element.$.textarea.selectionEnd = 5;
+        element.text = 'test:';
+        flushAsynchronousOperations();
+        assert.isTrue(element.$.emojiSuggestions.isHidden);
+        assert.isTrue(element._hideAutocomplete);
+      });
+
+  test('emoji selector opens when a colon is typed and some substring',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        element.text = ':';
+        element.$.textarea.selectionStart = 2;
+        element.$.textarea.selectionEnd = 2;
+        element.text = ':t';
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.emojiSuggestions.isHidden);
+        assert.equal(element._colonIndex, 0);
+        assert.isFalse(element._hideAutocomplete);
+        assert.equal(element._currentSearchString, 't');
+      });
+
+  test('emoji selector opens when a colon is typed in middle of text',
+      () => {
+        MockInteractions.focus(element.$.textarea);
+        // Needed for Safari tests. selectionStart is not updated when text is
+        // updated.
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        // Since selectionStart is on Chrome set always on end of text, we
+        // stub it to 1
+        const text = ': hello';
+        sinon.stub(element.$, 'textarea').value( {
+          selectionStart: 1,
+          value: text,
+          textarea: {
+            focus: () => {},
+          },
+        });
+        element.text = text;
+        flushAsynchronousOperations();
+        assert.isFalse(element.$.emojiSuggestions.isHidden);
+        assert.equal(element._colonIndex, 0);
+        assert.isFalse(element._hideAutocomplete);
+        assert.equal(element._currentSearchString, '');
+      });
+  test('emoji selector closes when text changes before the colon', () => {
+    const resetStub = sinon.stub(element, '_resetEmojiDropdown');
+    MockInteractions.focus(element.$.textarea);
+    flushAsynchronousOperations();
+    element.$.textarea.selectionStart = 10;
+    element.$.textarea.selectionEnd = 10;
+    element.text = 'test test ';
+    element.$.textarea.selectionStart = 12;
+    element.$.textarea.selectionEnd = 12;
+    element.text = 'test test :';
+    element.$.textarea.selectionStart = 15;
+    element.$.textarea.selectionEnd = 15;
+    element.text = 'test test :smi';
+
+    assert.equal(element._currentSearchString, 'smi');
+    assert.isFalse(resetStub.called);
+    element.text = 'test test test :smi';
+    assert.isTrue(resetStub.called);
+  });
+
+  test('_resetEmojiDropdown', () => {
+    const closeSpy = sinon.spy(element, 'closeDropdown');
+    element._resetEmojiDropdown();
+    assert.equal(element._currentSearchString, '');
+    assert.isTrue(element._hideAutocomplete);
+    assert.equal(element._colonIndex, null);
+
+    element.$.emojiSuggestions.open();
+    flushAsynchronousOperations();
+    element._resetEmojiDropdown();
+    assert.isTrue(closeSpy.called);
+  });
+
+  test('_determineSuggestions', () => {
+    const emojiText = 'tear';
+    const formatSpy = sinon.spy(element, '_formatSuggestions');
+    element._determineSuggestions(emojiText);
+    assert.isTrue(formatSpy.called);
+    assert.isTrue(formatSpy.lastCall.calledWithExactly(
+        [{dataValue: '😂', value: '😂', match: 'tears :\')',
+          text: '😂 tears :\')'},
+        {dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
+        ]));
+  });
+
+  test('_formatSuggestions', () => {
+    const matchedSuggestions = [{value: '😢', match: 'tear'},
+      {value: '😂', match: 'tears'}];
+    element._formatSuggestions(matchedSuggestions);
+    assert.deepEqual(
+        [{value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
+          {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'}],
+        element._suggestions);
+  });
+
+  test('_handleEmojiSelect', () => {
+    element.$.textarea.selectionStart = 16;
+    element.$.textarea.selectionEnd = 16;
+    element.text = 'test test :tears';
+    element._colonIndex = 10;
+    const selectedItem = {dataset: {value: '😂'}};
+    const event = {detail: {selected: selectedItem}};
+    element._handleEmojiSelect(event);
+    assert.equal(element.text, 'test test 😂');
+  });
+
+  test('_updateCaratPosition', () => {
+    element.$.textarea.selectionStart = 4;
+    element.$.textarea.selectionEnd = 4;
+    element.text = 'test';
+    element._updateCaratPosition();
+    assert.deepEqual(element.$.hiddenText.innerHTML, element.text +
+        element.$.caratSpan.outerHTML);
+  });
+
+  test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
+    const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
+    element.$.emojiSuggestions.dispatchEvent(
+        new CustomEvent('dropdown-closed', {
+          composed: true, bubbles: true,
+        }));
+    assert.isTrue(resetSpy.called);
+  });
+
+  test('_onValueChanged fires bind-value-changed', () => {
+    const listenerStub = sinon.stub();
+    const eventObject = {currentTarget: {focused: false}};
+    element.addEventListener('bind-value-changed', listenerStub);
+    element._onValueChanged(eventObject);
+    assert.isTrue(listenerStub.called);
+  });
+
+  suite('keyboard shortcuts', () => {
+    function setupDropdown(callback) {
+      MockInteractions.focus(element.$.textarea);
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 1;
+      element.text = ':';
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 2;
+      element.text = ':1';
+      flushAsynchronousOperations();
+    }
+
+    test('escape key', () => {
+      const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+      assert.isFalse(resetSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+      assert.isTrue(resetSpy.called);
+      assert.isFalse(!element.$.emojiSuggestions.isHidden);
+    });
+
+    test('up key', () => {
+      const upSpy = sinon.spy(element.$.emojiSuggestions, 'cursorUp');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+      assert.isFalse(upSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+      assert.isTrue(upSpy.called);
+    });
+
+    test('down key', () => {
+      const downSpy = sinon.spy(element.$.emojiSuggestions, 'cursorDown');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+      assert.isFalse(downSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+      assert.isTrue(downSpy.called);
+    });
+
+    test('enter key', () => {
+      const enterSpy = sinon.spy(element.$.emojiSuggestions,
+          'getCursorTarget');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isTrue(enterSpy.called);
+      flushAsynchronousOperations();
+      assert.equal(element.text, '💯');
+    });
+
+    test('enter key - ignored on just colon without more information', () => {
+      const enterSpy = sinon.spy(element.$.emojiSuggestions,
+          'getCursorTarget');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+      MockInteractions.focus(element.$.textarea);
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 1;
+      element.text = ':';
+      flushAsynchronousOperations();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+    });
+  });
+
+  suite('gr-textarea monospace', () => {
+  // gr-textarea set monospace class in the ready() method.
+  // In Polymer2, ready() is called from the fixture(...) method,
+  // If ready() is called again later, some nested elements doesn't
+  // handle it correctly. A separate test-fixture is used to set
+  // properties before ready() is called.
+
+    let element;
+
+    setup(() => {
+      element = monospaceFixture.instantiate();
+    });
+
+    test('monospace is set properly', () => {
+      assert.isTrue(element.classList.contains('monospace'));
+    });
+  });
+
+  suite('gr-textarea hideBorder', () => {
+  // gr-textarea set noBorder class in the ready() method.
+  // In Polymer2, ready() is called from the fixture(...) method,
+  // If ready() is called again later, some nested elements doesn't
+  // handle it correctly. A separate test-fixture is used to set
+  // properties before ready() is called.
+
+    let element;
+
+    setup(() => {
+      element = hideBorderFixture.instantiate();
+    });
+
+    test('hideBorder is set properly', () => {
+      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
index 160f50a..d2182e4 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
@@ -14,24 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../scripts/bundled-polymer.js';
-
 import '../gr-icons/gr-icons.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
 import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
 import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-tooltip-content_html.js';
-import {TooltipBehavior} from '../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin.js';
 
 /**
- * @extends Polymer.Element
+ * @extends PolymerElement
  */
-class GrTooltipContent extends mixinBehaviors( [
-  TooltipBehavior,
-], GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
+class GrTooltipContent extends TooltipMixin(
+    GestureEventListeners(
+        LegacyElementMixin(
+            PolymerElement))) {
   static get template() { return htmlTemplate; }
 
   static get is() { return 'gr-tooltip-content'; }
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.js
deleted file mode 100644
index e5a2813..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.js
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style>
-    iron-icon {
-      width: var(--line-height-normal);
-      height: var(--line-height-normal);
-      vertical-align: top;
-    }
-  </style>
-  <slot></slot
-  ><!--
- --><iron-icon icon="gr-icons:info" hidden$="[[!showIcon]]"></iron-icon>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.ts
new file mode 100644
index 0000000..952420d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style>
+    iron-icon {
+      width: var(--line-height-normal);
+      height: var(--line-height-normal);
+      vertical-align: top;
+    }
+  </style>
+  <slot></slot
+  ><!--
+ --><iron-icon icon="gr-icons:info" hidden$="[[!showIcon]]"></iron-icon>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
deleted file mode 100644
index a8fc18a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
+++ /dev/null
@@ -1,61 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-storage</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-tooltip-content>
-    </gr-tooltip-content>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-tooltip-content.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-suite('gr-tooltip-content tests', () => {
-  let element;
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('icon is not visible by default', () => {
-    assert.equal(dom(element.root)
-        .querySelector('iron-icon').hidden, true);
-  });
-
-  test('position-below attribute is reflected', () => {
-    assert.isFalse(element.hasAttribute('position-below'));
-    element.positionBelow = true;
-    assert.isTrue(element.hasAttribute('position-below'));
-  });
-
-  test('icon is visible with showIcon property', () => {
-    element.showIcon = true;
-    assert.equal(dom(element.root)
-        .querySelector('iron-icon').hidden, false);
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
new file mode 100644
index 0000000..f905eaa
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-tooltip-content.js';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-tooltip-content>
+    </gr-tooltip-content>
+`);
+
+suite('gr-tooltip-content tests', () => {
+  let element;
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('icon is not visible by default', () => {
+    assert.equal(dom(element.root)
+        .querySelector('iron-icon').hidden, true);
+  });
+
+  test('position-below attribute is reflected', () => {
+    assert.isFalse(element.hasAttribute('position-below'));
+    element.positionBelow = true;
+    assert.isTrue(element.hasAttribute('position-below'));
+  });
+
+  test('icon is visible with showIcon property', () => {
+    element.showIcon = true;
+    assert.equal(dom(element.root)
+        .querySelector('iron-icon').hidden, false);
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
deleted file mode 100644
index 0cd2d7c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../scripts/bundled-polymer.js';
-
-import '../../../styles/shared-styles.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-tooltip_html.js';
-
-/** @extends Polymer.Element */
-class GrTooltip extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-tooltip'; }
-
-  static get properties() {
-    return {
-      text: String,
-      maxWidth: {
-        type: String,
-        observer: '_updateWidth',
-      },
-      positionBelow: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-    };
-  }
-
-  _updateWidth(maxWidth) {
-    this.updateStyles({'--tooltip-max-width': maxWidth});
-  }
-}
-
-customElements.define(GrTooltip.is, GrTooltip);
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
new file mode 100644
index 0000000..c1a8eb2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
@@ -0,0 +1,55 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-tooltip_html';
+import {customElement, property, observe} from '@polymer/decorators';
+
+export interface GrTooltip {
+  $: {};
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-tooltip': GrTooltip;
+  }
+}
+
+@customElement('gr-tooltip')
+export class GrTooltip extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  text = '';
+
+  @property({type: String})
+  maxWidth = '';
+
+  @property({type: Boolean, reflectToAttribute: true})
+  positionBelow = false;
+
+  @observe('maxWidth')
+  _updateWidth(maxWidth: string) {
+    this.updateStyles({'--tooltip-max-width': maxWidth});
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.js
deleted file mode 100644
index 3f02fc5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.js
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      --gr-tooltip-arrow-size: 0.5em;
-      --gr-tooltip-arrow-center-offset: 0;
-
-      background-color: var(--tooltip-background-color);
-      box-shadow: var(--elevation-level-2);
-      color: var(--tooltip-text-color);
-      font-size: var(--font-size-small);
-      position: absolute;
-      z-index: 1000;
-      max-width: var(--tooltip-max-width);
-    }
-    :host .tooltip {
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    :host .arrowPositionBelow,
-    :host([position-below]) .arrowPositionAbove {
-      display: none;
-    }
-    :host([position-below]) .arrowPositionBelow {
-      display: initial;
-    }
-    .arrow {
-      border-left: var(--gr-tooltip-arrow-size) solid transparent;
-      border-right: var(--gr-tooltip-arrow-size) solid transparent;
-      height: 0;
-      position: absolute;
-      left: calc(50% - var(--gr-tooltip-arrow-size));
-      margin-left: var(--gr-tooltip-arrow-center-offset);
-      width: 0;
-    }
-    .arrowPositionAbove {
-      border-top: var(--gr-tooltip-arrow-size) solid
-        var(--tooltip-background-color);
-      bottom: calc(-1 * var(--gr-tooltip-arrow-size));
-    }
-    .arrowPositionBelow {
-      border-bottom: var(--gr-tooltip-arrow-size) solid
-        var(--tooltip-background-color);
-      top: calc(-1 * var(--gr-tooltip-arrow-size));
-    }
-  </style>
-  <div class="tooltip">
-    <i class="arrowPositionBelow arrow"></i>
-    [[text]]
-    <i class="arrowPositionAbove arrow"></i>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.ts
new file mode 100644
index 0000000..d59a6c3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_html.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+export const htmlTemplate = html`
+  <style include="shared-styles">
+    :host {
+      --gr-tooltip-arrow-size: 0.5em;
+      --gr-tooltip-arrow-center-offset: 0;
+
+      background-color: var(--tooltip-background-color);
+      box-shadow: var(--elevation-level-2);
+      color: var(--tooltip-text-color);
+      font-size: var(--font-size-small);
+      position: absolute;
+      z-index: 1000;
+      max-width: var(--tooltip-max-width);
+    }
+    :host .tooltip {
+      padding: var(--spacing-m) var(--spacing-l);
+    }
+    :host .arrowPositionBelow,
+    :host([position-below]) .arrowPositionAbove {
+      display: none;
+    }
+    :host([position-below]) .arrowPositionBelow {
+      display: initial;
+    }
+    .arrow {
+      border-left: var(--gr-tooltip-arrow-size) solid transparent;
+      border-right: var(--gr-tooltip-arrow-size) solid transparent;
+      height: 0;
+      position: absolute;
+      left: calc(50% - var(--gr-tooltip-arrow-size));
+      margin-left: var(--gr-tooltip-arrow-center-offset);
+      width: 0;
+    }
+    .arrowPositionAbove {
+      border-top: var(--gr-tooltip-arrow-size) solid
+        var(--tooltip-background-color);
+      bottom: calc(-1 * var(--gr-tooltip-arrow-size));
+    }
+    .arrowPositionBelow {
+      border-bottom: var(--gr-tooltip-arrow-size) solid
+        var(--tooltip-background-color);
+      top: calc(-1 * var(--gr-tooltip-arrow-size));
+    }
+  </style>
+  <div class="tooltip">
+    <i class="arrowPositionBelow arrow"></i>
+    [[text]]
+    <i class="arrowPositionAbove arrow"></i>
+  </div>
+`;
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
deleted file mode 100644
index b69d945..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
+++ /dev/null
@@ -1,66 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-storage</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-tooltip>
-    </gr-tooltip>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './gr-tooltip.js';
-suite('gr-tooltip tests', () => {
-  let element;
-  setup(() => {
-    element = fixture('basic');
-  });
-
-  test('max-width is respected if set', () => {
-    element.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
-        ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
-    element.maxWidth = '50px';
-    assert.equal(getComputedStyle(element).width, '50px');
-  });
-
-  test('the correct arrow is displayed', () => {
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.arrowPositionBelow')).display,
-    'none');
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.arrowPositionAbove'))
-        .display, 'none');
-    element.positionBelow = true;
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.arrowPositionBelow'))
-        .display, 'none');
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.arrowPositionAbove'))
-        .display, 'none');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.js
new file mode 100644
index 0000000..b5f068c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.js
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './gr-tooltip.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-tooltip>
+    </gr-tooltip>
+`);
+
+suite('gr-tooltip tests', () => {
+  let element;
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('max-width is respected if set', () => {
+    element.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
+        ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
+    element.maxWidth = '50px';
+    assert.equal(getComputedStyle(element).width, '50px');
+  });
+
+  test('the correct arrow is displayed', () => {
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.arrowPositionBelow')).display,
+    'none');
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.arrowPositionAbove'))
+        .display, 'none');
+    element.positionBelow = true;
+    assert.notEqual(getComputedStyle(element.shadowRoot
+        .querySelector('.arrowPositionBelow'))
+        .display, 'none');
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.arrowPositionAbove'))
+        .display, 'none');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.js b/polygerrit-ui/app/elements/shared/revision-info/revision-info.js
index 3d9c2bc..2f6bfea 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.js
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.js
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
+import {patchNumEquals} from '../../../utils/patch-set-util.js';
 
 /**
  * @constructor
@@ -76,6 +76,6 @@
  */
 RevisionInfo.prototype.getParentId = function(patchNum, parentIndex) {
   const rev = Object.values(this._change.revisions).find(rev =>
-    PatchSetBehavior.patchNumEquals(rev._number, patchNum));
+    patchNumEquals(rev._number, patchNum));
   return rev.commit.parents[parentIndex].commit;
 };
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
deleted file mode 100644
index 2d89b30..0000000
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.html
+++ /dev/null
@@ -1,90 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>revision-info</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<script type="module">
-import '../../../test/common-test-setup.js';
-import './revision-info.js';
-import {RevisionInfo} from './revision-info.js';
-suite('revision-info tests', () => {
-  let mockChange;
-
-  setup(() => {
-    mockChange = {
-      revisions: {
-        r1: {_number: 1, commit: {parents: [
-          {commit: 'p1'},
-          {commit: 'p2'},
-          {commit: 'p3'},
-        ]}},
-        r2: {_number: 2, commit: {parents: [
-          {commit: 'p1'},
-          {commit: 'p4'},
-        ]}},
-        r3: {_number: 3, commit: {parents: [{commit: 'p5'}]}},
-        r4: {_number: 4, commit: {parents: [
-          {commit: 'p2'},
-          {commit: 'p3'},
-        ]}},
-        r5: {_number: 5, commit: {parents: [
-          {commit: 'p5'},
-          {commit: 'p2'},
-          {commit: 'p3'},
-        ]}},
-      },
-    };
-  });
-
-  test('getMaxParents', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.equal(ri.getMaxParents(), 3);
-  });
-
-  test('getParentCountMap', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentCountMap(), {1: 3, 2: 2, 3: 1, 4: 2, 5: 3});
-  });
-
-  test('getParentCount', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentCount(1), 3);
-    assert.deepEqual(ri.getParentCount(3), 1);
-  });
-
-  test('getParentCount', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentCount(1), 3);
-    assert.deepEqual(ri.getParentCount(3), 1);
-  });
-
-  test('getParentId', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentId(1, 2), 'p3');
-    assert.deepEqual(ri.getParentId(2, 1), 'p4');
-    assert.deepEqual(ri.getParentId(3, 0), 'p5');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.js b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.js
new file mode 100644
index 0000000..7d0dd4f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.js
@@ -0,0 +1,79 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import './revision-info.js';
+import {RevisionInfo} from './revision-info.js';
+suite('revision-info tests', () => {
+  let mockChange;
+
+  setup(() => {
+    mockChange = {
+      revisions: {
+        r1: {_number: 1, commit: {parents: [
+          {commit: 'p1'},
+          {commit: 'p2'},
+          {commit: 'p3'},
+        ]}},
+        r2: {_number: 2, commit: {parents: [
+          {commit: 'p1'},
+          {commit: 'p4'},
+        ]}},
+        r3: {_number: 3, commit: {parents: [{commit: 'p5'}]}},
+        r4: {_number: 4, commit: {parents: [
+          {commit: 'p2'},
+          {commit: 'p3'},
+        ]}},
+        r5: {_number: 5, commit: {parents: [
+          {commit: 'p5'},
+          {commit: 'p2'},
+          {commit: 'p3'},
+        ]}},
+      },
+    };
+  });
+
+  test('getMaxParents', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.equal(ri.getMaxParents(), 3);
+  });
+
+  test('getParentCountMap', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCountMap(), {1: 3, 2: 2, 3: 1, 4: 2, 5: 3});
+  });
+
+  test('getParentCount', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCount(1), 3);
+    assert.deepEqual(ri.getParentCount(3), 1);
+  });
+
+  test('getParentCount', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCount(1), 3);
+    assert.deepEqual(ri.getParentCount(3), 1);
+  });
+
+  test('getParentId', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentId(1, 2), 'p3');
+    assert.deepEqual(ri.getParentId(2, 1), 'p4');
+    assert.deepEqual(ri.getParentId(3, 0), 'p5');
+  });
+});
+
diff --git a/polygerrit-ui/app/elements/test/plugin.html b/polygerrit-ui/app/elements/test/plugin.html
deleted file mode 100644
index ecd9007..0000000
--- a/polygerrit-ui/app/elements/test/plugin.html
+++ /dev/null
@@ -1,44 +0,0 @@
-<dom-module id="my-plugin">
-  <script>
-    Gerrit.install(plugin => {
-      plugin.registerStyleModule('app-theme', 'myplugin-app-theme');
-      plugin.registerStyleModule('app-theme-light', 'myplugin-app-theme-light');
-      plugin.registerStyleModule('app-theme-dark', 'myplugin-app-theme-dark');
-    });
-  </script>
-</dom-module>
-
-<dom-module id="myplugin-app-theme">
-  <template>
-    <style>
-      html {
-        --primary-text-color: #F00BAA;
-      }
-    </style>
-  </template>
-</dom-module>
-
-<dom-module id="myplugin-app-theme-light">
-  <template>
-    <style>
-      html {
-        --header-background-color: #F01BAA;
-        --header-title-content: "MyGerrit";
-        --footer-background-color: #F02BAA;
-      }
-    </style>
-  </template>
-</dom-module>
-
-<dom-module id="myplugin-app-theme-dark">
-  <template>
-    <style>
-      html {
-        --primary-text-color: red;
-        --header-background-color: black;
-        --header-title-content: "MyGerrit Dark";
-        --footer-background-color: yellow;
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/embed/README.md b/polygerrit-ui/app/embed/README.md
index bef098b..4e1677de 100644
--- a/polygerrit-ui/app/embed/README.md
+++ b/polygerrit-ui/app/embed/README.md
@@ -1,4 +1,4 @@
-This folder contains shared components that can be used independently from Gerrit.
+This folder contains shared components that can be used independently of Gerrit.
 
 ### gr-diff
 
@@ -10,4 +10,4 @@
 
 All supported attributes defined in `polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js`, you can pass them by just assigning them to the `gr-app` element.
 
-To customize the style of the diff, you can use `css variables`, all supported varibled defined in `polygerrit-ui/app/styles/themes/app-theme.html` and `polygerrit-ui/app/styles/themes/dark-theme.html`.
+To customize the style of the diff, you can use `css variables`, all supported variables defined in `polygerrit-ui/app/styles/themes/app-theme.js` and `polygerrit-ui/app/styles/themes/dark-theme.js`.
diff --git a/polygerrit-ui/app/embed/app-context-init.js b/polygerrit-ui/app/embed/app-context-init.js
deleted file mode 100644
index 55a5866..0000000
--- a/polygerrit-ui/app/embed/app-context-init.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {appContext} from '../services/app-context.js';
-
-class MockFlagsService {
-  isEnabled(experimentId) {
-    return false;
-  }
-
-  /**
-   * @returns {string[]} array of all enabled experiments.
-   */
-  get enabledExperiments() {
-    return [];
-  }
-}
-
-// Setup mocks for appContext.
-// This is a temporary solution
-// TODO(dmfilippov): find a better solution for gr-diff
-export function initDiffAppContext() {
-  appContext.flagsService = new MockFlagsService();
-}
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.js b/polygerrit-ui/app/embed/gr-diff-app-context-init.js
new file mode 100644
index 0000000..933edba
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.js
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {appContext} from '../services/app-context.js';
+import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock.js';
+
+class MockFlagsService {
+  isEnabled(experimentId) {
+    return false;
+  }
+
+  /**
+   * @returns {!Array<string>} array of all enabled experiments.
+   */
+  get enabledExperiments() {
+    return [];
+  }
+}
+
+class MockAuthService {
+  clearCache() {
+
+  }
+
+  get isAuthed() {
+    return false;
+  }
+
+  authCheck() {
+    return Promise.resolve(false);
+  }
+}
+
+// Setup mocks for appContext.
+// This is a temporary solution
+// TODO(dmfilippov): find a better solution for gr-diff
+export function initDiffAppContext() {
+  function setMock(serviceName, setupMock) {
+    Object.defineProperty(appContext, serviceName, {
+      get() {
+        return setupMock;
+      },
+    });
+  }
+  setMock('flagsService', new MockFlagsService);
+  setMock('reportingService', grReportingMock);
+  setMock('authService', new MockAuthService);
+}
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
new file mode 100644
index 0000000..832c931
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {appContext} from '../services/app-context.js';
+import {initDiffAppContext} from './gr-diff-app-context-init.js';
+suite('gr diff app context initializer tests', () => {
+  setup(() => {
+    initDiffAppContext();
+  });
+
+  test('all services initialized and are singletons', () => {
+    Object.keys(appContext).forEach(serviceName => {
+      const service = appContext[serviceName];
+      assert.isNotNull(service);
+      const service2 = appContext[serviceName];
+      assert.strictEqual(service, service2);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/embed/gr-diff.js b/polygerrit-ui/app/embed/gr-diff.js
index a8b7e03..6050d69 100644
--- a/polygerrit-ui/app/embed/gr-diff.js
+++ b/polygerrit-ui/app/embed/gr-diff.js
@@ -16,9 +16,20 @@
  */
 
 window.Gerrit = window.Gerrit || {};
+// We need to use goog.declareModuleId internally in google for TS-imports-JS
+// case. To avoid errors when goog is not available, the empty implementation is
+// added.
+window.goog = window.goog || {declareModuleId(name) {}};
+// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
+// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
+// Because gr-diff.js is a shared component, it shouldn' pollute global
+// variables. If an application wants to use Polymer global variable -
+// the app must assign/import it and do not rely on the Polymer variable
+// exposed by shared gr-diff component.
+import '../scripts/bundled-polymer.js';
 import '../elements/diff/gr-diff/gr-diff.js';
 import '../elements/diff/gr-diff-cursor/gr-diff-cursor.js';
-import {initDiffAppContext} from './app-context-init.js';
+import {initDiffAppContext} from './gr-diff-app-context-init.js';
 import {GrDiffLine} from '../elements/diff/gr-diff/gr-diff-line.js';
 import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation.js';
 
diff --git a/polygerrit-ui/app/externs/BUILD b/polygerrit-ui/app/externs/BUILD
deleted file mode 100644
index 26ead9a..0000000
--- a/polygerrit-ui/app/externs/BUILD
+++ /dev/null
@@ -1,25 +0,0 @@
-# Copyright (C) 2018 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_library")
-
-package(
-    default_visibility = ["//visibility:public"],
-)
-
-closure_js_library(
-    name = "plugin",
-    srcs = ["plugin.js"],
-    no_closure_library = True,
-)
diff --git a/polygerrit-ui/app/externs/plugin.js b/polygerrit-ui/app/externs/plugin.js
deleted file mode 100644
index c88c724..0000000
--- a/polygerrit-ui/app/externs/plugin.js
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * @fileoverview Closure compiler externs for the Gerrit UI plugins.
- * @externs
- */
-
-/* eslint-disable no-var */
-
-var Gerrit = {};
-
-/**
- * @param {!Function} callback
- */
-Gerrit.install = function(callback) {};
diff --git a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.js b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.js
new file mode 100644
index 0000000..e3b9f20
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.js
@@ -0,0 +1,122 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin.js';
+
+/**
+ * @polymer
+ * @mixinFunction
+ */
+export const ChangeTableMixin = dedupingMixin(superClass => {
+  /**
+   * @polymer
+   * @mixinClass
+   */
+  class Mixin extends superClass {
+    static get properties() {
+      return {
+        columnNames: {
+          type: Array,
+          value: [
+            'Subject',
+            'Status',
+            'Owner',
+            'Assignee',
+            'Reviewers',
+            'Comments',
+            'Repo',
+            'Branch',
+            'Updated',
+            'Size',
+          ],
+          readOnly: true,
+        },
+      };
+    }
+
+    /**
+     * Returns the complement to the given column array
+     *
+     * @param {Array} columns
+     * @return {!Array}
+     */
+    getComplementColumns(columns) {
+      return this.columnNames.filter(column => !columns.includes(column));
+    }
+
+    /**
+     * @param {string} columnToCheck
+     * @param {!Array} columnsToDisplay
+     * @return {boolean}
+     */
+    isColumnHidden(columnToCheck, columnsToDisplay) {
+      if ([columnsToDisplay, columnToCheck].includes(undefined)) {
+        return false;
+      }
+      return !columnsToDisplay.includes(columnToCheck);
+    }
+
+    /**
+     * Is the column disabled by a server config or experiment? For example the
+     * assignee feature might be disabled and thus the corresponding column is
+     * also disabled.
+     *
+     * @param {string} column
+     * @param {Object} config
+     * @param {!Array<string>} experiments
+     * @return {boolean}
+     */
+    isColumnEnabled(column, config, experiments) {
+      if (!config || !config.change) return true;
+      if (column === 'Assignee') return !!config.change.enable_assignee;
+      if (column === 'Comments') return experiments.includes('comments-column');
+      if (column === 'Reviewers') return !!config.change.enable_attention_set;
+      return true;
+    }
+
+    /**
+     * @param {!Array<string>} columns
+     * @param {Object} config
+     * @param {!Array<string>} experiments
+     * @return {!Array<string>} enabled columns, see isColumnEnabled().
+     */
+    getEnabledColumns(columns, config, experiments) {
+      return columns.filter(
+          col => this.isColumnEnabled(col, config, experiments));
+    }
+
+    /**
+     * The Project column was renamed to Repo, but some users may have
+     * preferences that use its old name. If that column is found, rename it
+     * before use.
+     *
+     * @param {!Array<string>} columns
+     * @return {!Array<string>} If the column was renamed, returns a new array
+     *     with the corrected name. Otherwise, it returns the original param.
+     */
+    getVisibleColumns(columns) {
+      const projectIndex = columns.indexOf('Project');
+      if (projectIndex === -1) { return columns; }
+      const newColumns = columns.slice(0);
+      newColumns[projectIndex] = 'Repo';
+      return newColumns;
+    }
+  }
+
+  return Mixin;
+});
+
diff --git a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js
new file mode 100644
index 0000000..daf10ce
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {ChangeTableMixin} from './gr-change-table-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+
+class GrChangeTableMixinTestElement extends
+  ChangeTableMixin(PolymerElement) {
+  static get is() { return 'gr-change-table-mixin-test-element'; }
+}
+
+customElements.define(GrChangeTableMixinTestElement.is,
+    GrChangeTableMixinTestElement);
+
+const basicFixture = fixtureFromElement(
+    'gr-change-table-mixin-test-element');
+
+suite('gr-change-table-mixin tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('getComplementColumns', () => {
+    let columns = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Repo',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+    assert.deepEqual(element.getComplementColumns(columns), []);
+
+    columns = [
+      'Subject',
+      'Status',
+      'Assignee',
+      'Reviewers',
+      'Comments',
+      'Repo',
+      'Branch',
+      'Size',
+    ];
+    assert.deepEqual(element.getComplementColumns(columns),
+        ['Owner', 'Updated']);
+  });
+
+  test('isColumnHidden', () => {
+    const columnToCheck = 'Repo';
+    let columnsToDisplay = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Repo',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+    assert.isFalse(element.isColumnHidden(columnToCheck, columnsToDisplay));
+
+    columnsToDisplay = [
+      'Subject',
+      'Status',
+      'Owner',
+      'Assignee',
+      'Branch',
+      'Updated',
+      'Size',
+    ];
+    assert.isTrue(element.isColumnHidden(columnToCheck, columnsToDisplay));
+  });
+
+  test('getVisibleColumns maps Project to Repo', () => {
+    const columns = [
+      'Subject',
+      'Status',
+      'Owner',
+    ];
+    assert.deepEqual(element.getVisibleColumns(columns), columns.slice(0));
+    assert.deepEqual(
+        element.getVisibleColumns(columns.concat(['Project'])),
+        columns.slice(0).concat(['Repo']));
+  });
+});
+
diff --git a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts
new file mode 100644
index 0000000..167d622
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {encodeURL, getBaseUrl} from '../../utils/url-util';
+import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
+import {PolymerElement} from '@polymer/polymer';
+import {Constructor} from '../../utils/common-util';
+
+/**
+ * @polymer
+ * @mixinFunction
+ */
+export const ListViewMixin = dedupingMixin(
+  <T extends Constructor<PolymerElement>>(
+    superClass: T
+  ): T & Constructor<ListViewMixinInterface> => {
+    /**
+     * @polymer
+     * @mixinClass
+     */
+    class Mixin extends superClass {
+      computeLoadingClass(loading: boolean): string {
+        return loading ? 'loading' : '';
+      }
+
+      computeShownItems<T>(items: T[]): T[] {
+        return items.slice(0, 25);
+      }
+
+      getUrl(path: string, item: string) {
+        return getBaseUrl() + path + encodeURL(item, true);
+      }
+
+      getFilterValue(params: ListViewParams): string {
+        if (!params) {
+          return '';
+        }
+        return params.filter || '';
+      }
+
+      getOffsetValue(params: ListViewParams): number {
+        if (params && params.offset) {
+          return params.offset;
+        }
+        return 0;
+      }
+    }
+
+    return Mixin;
+  }
+);
+
+export interface ListViewMixinInterface {
+  computeLoadingClass(loading: boolean): string;
+  computeShownItems<T>(items: T[]): T[];
+  getUrl(path: string, item: string): string;
+  getFilterValue(params: ListViewParams): string;
+  getOffsetValue(params: ListViewParams): number;
+}
+
+export interface ListViewParams {
+  filter?: string;
+  offset?: number;
+}
diff --git a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin_test.js b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin_test.js
new file mode 100644
index 0000000..407f29f
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin_test.js
@@ -0,0 +1,79 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {ListViewMixin} from './gr-list-view-mixin.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+
+const basicFixture = fixtureFromElement(
+    'gr-list-view-mixin-test-element');
+
+class GrListViewMixinTestElement extends
+  ListViewMixin(PolymerElement) {
+  static get is() { return 'gr-list-view-mixin-test-element'; }
+}
+
+customElements.define(GrListViewMixinTestElement.is,
+    GrListViewMixinTestElement);
+
+suite('gr-list-view-mixin tests', () => {
+  let element;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('computeLoadingClass', () => {
+    assert.equal(element.computeLoadingClass(true), 'loading');
+    assert.equal(element.computeLoadingClass(false), '');
+  });
+
+  test('computeShownItems', () => {
+    const myArr = new Array(26);
+    assert.equal(element.computeShownItems(myArr).length, 25);
+  });
+
+  test('getUrl', () => {
+    assert.equal(element.getUrl('/path/to/something/', 'item'),
+        '/path/to/something/item');
+    assert.equal(element.getUrl('/path/to/something/', 'item%test'),
+        '/path/to/something/item%2525test');
+  });
+
+  test('getFilterValue', () => {
+    let params;
+    assert.equal(element.getFilterValue(params), '');
+
+    params = {filter: null};
+    assert.equal(element.getFilterValue(params), '');
+
+    params = {filter: 'test'};
+    assert.equal(element.getFilterValue(params), 'test');
+  });
+
+  test('getOffsetValue', () => {
+    let params;
+    assert.equal(element.getOffsetValue(params), 0);
+
+    params = {offset: null};
+    assert.equal(element.getOffsetValue(params), 0);
+
+    params = {offset: 1};
+    assert.equal(element.getOffsetValue(params), 1);
+  });
+});
+
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
new file mode 100644
index 0000000..516877e
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
@@ -0,0 +1,221 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../elements/shared/gr-tooltip/gr-tooltip';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {getRootElement} from '../../scripts/rootElement';
+import {property, observe} from '@polymer/decorators';
+import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
+import {GrTooltip} from '../../elements/shared/gr-tooltip/gr-tooltip';
+import {PolymerElement} from '@polymer/polymer';
+import {Constructor} from '../../utils/common-util';
+
+const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
+
+/** The interface corresponding to TooltipMixin */
+export interface TooltipMixinInterface {
+  hasTooltip: boolean;
+  positionBelow: boolean;
+  _isTouchDevice: boolean;
+  _tooltip: GrTooltip | null;
+  _titleText: string;
+  _hasSetupTooltipListeners: boolean;
+}
+
+/**
+ * @polymer
+ * @mixinFunction
+ */
+export const TooltipMixin = dedupingMixin(
+  <T extends Constructor<PolymerElement>>(
+    superClass: T
+  ): T & Constructor<TooltipMixinInterface> => {
+    /**
+     * @polymer
+     * @mixinClass
+     */
+    class Mixin extends superClass {
+      @property({type: Boolean})
+      hasTooltip = false;
+
+      @property({type: Boolean, reflectToAttribute: true})
+      positionBelow = false;
+
+      @property({type: Boolean})
+      _isTouchDevice = 'ontouchstart' in document.documentElement;
+
+      @property({type: Object})
+      _tooltip: GrTooltip | null = null;
+
+      @property({type: String})
+      _titleText = '';
+
+      @property({type: Boolean})
+      _hasSetupTooltipListeners = false;
+
+      // Handler for mouseenter event
+      private mouseenterHandler?: (e: MouseEvent) => void;
+
+      // Hanlder for scrolling on window
+      private readonly windowScrollHandler: () => void;
+
+      // Hanlder for showing the tooltip, will be attached to certain events
+      private readonly showHandler: () => void;
+
+      // Hanlder for hiding the tooltip, will be attached to certain events
+      private readonly hideHandler: () => void;
+
+      // tslint:disable-next-line:no-any Required for constructor signature.
+      constructor(..._: any[]) {
+        super();
+        this.windowScrollHandler = () => this._handleWindowScroll();
+        this.showHandler = () => this._handleShowTooltip();
+        this.hideHandler = () => this._handleHideTooltip();
+      }
+
+      /** @override */
+      disconnectedCallback() {
+        super.disconnectedCallback();
+        // NOTE: if you define your own `detached` in your component
+        // then this won't take affect (as its not a class yet)
+        this._handleHideTooltip();
+        if (this.mouseenterHandler) {
+          this.removeEventListener('mouseenter', this.mouseenterHandler);
+        }
+        window.removeEventListener('scroll', this.windowScrollHandler);
+      }
+
+      @observe('hasTooltip')
+      _setupTooltipListeners() {
+        if (!this.mouseenterHandler) {
+          this.mouseenterHandler = () => this._handleShowTooltip();
+        }
+
+        if (!this.hasTooltip) {
+          // if attribute set to false, remove the listener
+          this.removeEventListener('mouseenter', this.mouseenterHandler);
+          this._hasSetupTooltipListeners = false;
+          return;
+        }
+
+        if (this._hasSetupTooltipListeners) {
+          return;
+        }
+        this._hasSetupTooltipListeners = true;
+
+        this.addEventListener('mouseenter', this.mouseenterHandler);
+      }
+
+      _handleShowTooltip() {
+        if (this._isTouchDevice) {
+          return;
+        }
+
+        if (
+          !this.hasAttribute('title') ||
+          this.getAttribute('title') === '' ||
+          this._tooltip
+        ) {
+          return;
+        }
+
+        // Store the title attribute text then set it to an empty string to
+        // prevent it from showing natively.
+        this._titleText = this.getAttribute('title') || '';
+        this.setAttribute('title', '');
+
+        const tooltip = document.createElement('gr-tooltip');
+        tooltip.text = this._titleText;
+        tooltip.maxWidth = this.getAttribute('max-width') || '';
+        tooltip.positionBelow = this.hasAttribute('position-below');
+
+        // Set visibility to hidden before appending to the DOM so that
+        // calculations can be made based on the element’s size.
+        tooltip.style.visibility = 'hidden';
+        getRootElement().appendChild(tooltip);
+        this._positionTooltip(tooltip);
+        tooltip.style.visibility = 'initial';
+
+        this._tooltip = tooltip;
+        window.addEventListener('scroll', this.windowScrollHandler);
+        this.addEventListener('mouseleave', this.showHandler);
+        this.addEventListener('click', this.hideHandler);
+      }
+
+      _handleHideTooltip() {
+        if (this._isTouchDevice) {
+          return;
+        }
+        if (!this.hasAttribute('title') || !this._titleText) {
+          return;
+        }
+
+        window.removeEventListener('scroll', this.windowScrollHandler);
+        this.removeEventListener('mouseleave', this.hideHandler);
+        this.removeEventListener('click', this.hideHandler);
+        this.setAttribute('title', this._titleText);
+
+        if (this._tooltip && this._tooltip.parentNode) {
+          this._tooltip.parentNode.removeChild(this._tooltip);
+        }
+        this._tooltip = null;
+      }
+
+      _handleWindowScroll() {
+        if (!this._tooltip) {
+          return;
+        }
+
+        this._positionTooltip(this._tooltip);
+      }
+
+      _positionTooltip(tooltip: GrTooltip) {
+        // This flush is needed for tooltips to be positioned correctly in Firefox
+        // and Safari.
+        flush();
+        const rect = this.getBoundingClientRect();
+        const boxRect = tooltip.getBoundingClientRect();
+        if (!tooltip.parentElement) {
+          return;
+        }
+        const parentRect = tooltip.parentElement.getBoundingClientRect();
+        const top = rect.top - parentRect.top;
+        const left =
+          rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
+        const right = parentRect.width - left - boxRect.width;
+        if (left < 0) {
+          tooltip.updateStyles({
+            '--gr-tooltip-arrow-center-offset': `${left}px`,
+          });
+        } else if (right < 0) {
+          tooltip.updateStyles({
+            '--gr-tooltip-arrow-center-offset': `${-0.5 * right}px`,
+          });
+        }
+        tooltip.style.left = `${Math.max(0, left)}px`;
+
+        if (!this.positionBelow) {
+          tooltip.style.top = `${Math.max(0, top)}px`;
+          tooltip.style.transform = `translateY(calc(-100% - ${BOTTOM_OFFSET}px))`;
+        } else {
+          tooltip.style.top = `${top + rect.height + BOTTOM_OFFSET}px`;
+        }
+      }
+    }
+
+    return Mixin;
+  }
+);
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js
new file mode 100644
index 0000000..589307d
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js
@@ -0,0 +1,137 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {TooltipMixin} from './gr-tooltip-mixin.js';
+
+const basicFixture = fixtureFromElement('gr-tooltip-mixin-element');
+
+class GrTooltipMixinTestElement extends TooltipMixin(PolymerElement) {
+  static get is() {
+    return 'gr-tooltip-mixin-element';
+  }
+}
+
+customElements.define(GrTooltipMixinTestElement.is,
+    GrTooltipMixinTestElement);
+
+suite('gr-tooltip-mixin tests', () => {
+  let element;
+
+  function makeTooltip(tooltipRect, parentRect) {
+    return {
+      getBoundingClientRect() { return tooltipRect; },
+      updateStyles: sinon.stub(),
+      style: {left: 0, top: 0},
+      parentElement: {
+        getBoundingClientRect() { return parentRect; },
+      },
+    };
+  }
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('normal position', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      return {top: 100, left: 100, width: 200};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 50},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isFalse(tooltip.updateStyles.called);
+    assert.equal(tooltip.style.left, '175px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('left side position', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      return {top: 100, left: 10, width: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '0px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('right side position', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      return {top: 100, left: 950, width: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('position to bottom', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      return {top: 100, left: 950, width: 50, height: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element.positionBelow = true;
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '157.2px');
+  });
+
+  test('hides tooltip when detached', () => {
+    sinon.stub(element, '_handleHideTooltip');
+    element.remove();
+    flushAsynchronousOperations();
+    assert.isTrue(element._handleHideTooltip.called);
+  });
+
+  test('sets up listeners when has-tooltip is changed', () => {
+    const addListenerStub = sinon.stub(element, 'addEventListener');
+    element.hasTooltip = true;
+    assert.isTrue(addListenerStub.called);
+  });
+
+  test('clean up listeners when has-tooltip changed to false', () => {
+    const removeListenerStub = sinon.stub(element, 'removeEventListener');
+    element.hasTooltip = true;
+    element.hasTooltip = false;
+    assert.isTrue(removeListenerStub.called);
+  });
+});
+
diff --git a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
new file mode 100644
index 0000000..cfbad9d
--- /dev/null
+++ b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {Constructor} from '../../utils/common-util';
+
+// The mixinBehaviors clears all type information about superClass.
+// As a workaround, we define IronFitMixin with correct type.
+export const IronFitMixin = <T extends Constructor<PolymerElement>>(
+  superClass: T
+): T & Constructor<IronFitBehavior> =>
+  // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
+  // which will fail the type check due to missing IronFitBehavior interface
+  mixinBehaviors([IronFitBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
new file mode 100644
index 0000000..daed2b8
--- /dev/null
+++ b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {Constructor} from '../../utils/common-util';
+
+// The mixinBehaviors clears all type information about superClass.
+// As a workaround, we define IronOverlayMixin with correct type.
+export const IronOverlayMixin = <T extends Constructor<PolymerElement>>(
+  superClass: T
+): T & Constructor<IronOverlayBehavior> =>
+  // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
+  // which will fail the type check due to missing IronOverlayBehavior interface
+  mixinBehaviors([IronOverlayBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
new file mode 100644
index 0000000..07d59b4
--- /dev/null
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -0,0 +1,1067 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+
+How to Add a Keyboard Shortcut
+==============================
+
+A keyboard shortcut is composed of the following parts:
+
+  1. A semantic identifier (e.g. OPEN_CHANGE, NEXT_PAGE)
+  2. Documentation for the keyboard shortcut help dialog
+  3. A binding between key combos and the semantic identifier
+  4. A binding between the semantic identifier and a listener
+
+Parts (1) and (2) for all shortcuts are defined in this file. The semantic
+identifier is declared in the Shortcut enum near the head of this script:
+
+  const Shortcut = {
+    // ...
+    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
+    // ...
+  };
+
+Immediately following the Shortcut enum definition, there is a _describe
+function defined which is then invoked many times to populate the help dialog.
+Add a new invocation here to document the shortcut:
+
+  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
+      'Hide/show left diff');
+
+When an attached view binds one or more key combos to this shortcut, the help
+dialog will display this text in the given section (in this case, "Diffs"). See
+the ShortcutSection enum immediately below for the list of supported sections.
+
+Part (3), the actual key bindings, are declared by gr-app. In the future, this
+system may be expanded to allow key binding customizations by plugins or user
+preferences. Key bindings are defined in the following forms:
+
+  // Ordinary shortcut with a single binding.
+  this.bindShortcut(
+      Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+
+  // Ordinary shortcut with multiple bindings.
+  this.bindShortcut(
+      Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+
+  // A "go-key" keyboard shortcut, which is combined with a previously and
+  // continuously pressed "go" key (the go-key is hard-coded as 'g').
+  this.bindShortcut(
+      Shortcut.GO_TO_OPENED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'o');
+
+  // A "doc-only" keyboard shortcut. This declares the key-binding for help
+  // dialog purposes, but doesn't actually implement the binding. It is up
+  // to some element to implement this binding using iron-a11y-keys-behavior's
+  // keyBindings property.
+  this.bindShortcut(
+      Shortcut.EXPAND_ALL_COMMENT_THREADS, SPECIAL_SHORTCUT.DOC_ONLY, 'e');
+
+Part (4), the listener definitions, are declared by the view or element that
+implements the shortcut behavior. This is done by implementing a method named
+keyboardShortcuts() in an element that mixes in this behavior, returning an
+object that maps semantic identifiers (as property names) to listener method
+names, like this:
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+    };
+  },
+
+You can implement key bindings in an element that is hosted by a view IF that
+element is always attached exactly once under that view (e.g. the search bar in
+gr-app). When that is not the case, you will have to define a doc-only binding
+in gr-app, declare the shortcut in the view that hosts the element, and use
+iron-a11y-keys-behavior's keyBindings attribute to implement the binding in the
+element. An example of this is in comment threads. A diff view supports actions
+on comment threads, but there may be zero or many comment threads attached at
+any given point. So the shortcut is declared as doc-only by the diff view and
+by gr-app, and actually implemented by gr-comment-thread.
+
+NOTE: doc-only shortcuts will not be customizable in the same way that other
+shortcuts are.
+*/
+
+import {IronA11yKeysBehavior} from '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
+import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
+import {property} from '@polymer/decorators';
+import {PolymerElement} from '@polymer/polymer';
+import {Constructor} from '../../utils/common-util';
+
+/** Enum for all special shortcuts */
+export enum SPECIAL_SHORTCUT {
+  DOC_ONLY = 'DOC_ONLY',
+  GO_KEY = 'GO_KEY',
+  V_KEY = 'V_KEY',
+}
+
+// The maximum age of a keydown event to be used in a jump navigation. This
+// is only for cases when the keyup event is lost.
+const GO_KEY_TIMEOUT_MS = 1000;
+
+const V_KEY_TIMEOUT_MS = 1000;
+
+/**
+ * Enum for all shortcut sections, where that shortcut should be applied to.
+ */
+export enum ShortcutSection {
+  ACTIONS = 'Actions',
+  DIFFS = 'Diffs',
+  EVERYWHERE = 'Everywhere',
+  FILE_LIST = 'File list',
+  NAVIGATION = 'Navigation',
+  REPLY_DIALOG = 'Reply dialog',
+}
+
+/**
+ * Enum for all possible shortcut names.
+ */
+export enum Shortcut {
+  OPEN_SHORTCUT_HELP_DIALOG = 'OPEN_SHORTCUT_HELP_DIALOG',
+  GO_TO_USER_DASHBOARD = 'GO_TO_USER_DASHBOARD',
+  GO_TO_OPENED_CHANGES = 'GO_TO_OPENED_CHANGES',
+  GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
+  GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
+  GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
+
+  CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
+  CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
+  OPEN_CHANGE = 'OPEN_CHANGE',
+  NEXT_PAGE = 'NEXT_PAGE',
+  PREV_PAGE = 'PREV_PAGE',
+  TOGGLE_CHANGE_REVIEWED = 'TOGGLE_CHANGE_REVIEWED',
+  TOGGLE_CHANGE_STAR = 'TOGGLE_CHANGE_STAR',
+  REFRESH_CHANGE_LIST = 'REFRESH_CHANGE_LIST',
+
+  OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
+  OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
+  EXPAND_ALL_MESSAGES = 'EXPAND_ALL_MESSAGES',
+  COLLAPSE_ALL_MESSAGES = 'COLLAPSE_ALL_MESSAGES',
+  UP_TO_DASHBOARD = 'UP_TO_DASHBOARD',
+  UP_TO_CHANGE = 'UP_TO_CHANGE',
+  TOGGLE_DIFF_MODE = 'TOGGLE_DIFF_MODE',
+  REFRESH_CHANGE = 'REFRESH_CHANGE',
+  EDIT_TOPIC = 'EDIT_TOPIC',
+  DIFF_AGAINST_BASE = 'DIFF_AGAINST_BASE',
+  DIFF_AGAINST_LATEST = 'DIFF_AGAINST_LATEST',
+  DIFF_BASE_AGAINST_LEFT = 'DIFF_BASE_AGAINST_LEFT',
+  DIFF_RIGHT_AGAINST_LATEST = 'DIFF_RIGHT_AGAINST_LATEST',
+  DIFF_BASE_AGAINST_LATEST = 'DIFF_BASE_AGAINST_LATEST',
+
+  NEXT_LINE = 'NEXT_LINE',
+  PREV_LINE = 'PREV_LINE',
+  VISIBLE_LINE = 'VISIBLE_LINE',
+  NEXT_CHUNK = 'NEXT_CHUNK',
+  PREV_CHUNK = 'PREV_CHUNK',
+  EXPAND_ALL_DIFF_CONTEXT = 'EXPAND_ALL_DIFF_CONTEXT',
+  NEXT_COMMENT_THREAD = 'NEXT_COMMENT_THREAD',
+  PREV_COMMENT_THREAD = 'PREV_COMMENT_THREAD',
+  EXPAND_ALL_COMMENT_THREADS = 'EXPAND_ALL_COMMENT_THREADS',
+  COLLAPSE_ALL_COMMENT_THREADS = 'COLLAPSE_ALL_COMMENT_THREADS',
+  LEFT_PANE = 'LEFT_PANE',
+  RIGHT_PANE = 'RIGHT_PANE',
+  TOGGLE_LEFT_PANE = 'TOGGLE_LEFT_PANE',
+  NEW_COMMENT = 'NEW_COMMENT',
+  SAVE_COMMENT = 'SAVE_COMMENT',
+  OPEN_DIFF_PREFS = 'OPEN_DIFF_PREFS',
+  TOGGLE_DIFF_REVIEWED = 'TOGGLE_DIFF_REVIEWED',
+
+  NEXT_FILE = 'NEXT_FILE',
+  PREV_FILE = 'PREV_FILE',
+  NEXT_FILE_WITH_COMMENTS = 'NEXT_FILE_WITH_COMMENTS',
+  PREV_FILE_WITH_COMMENTS = 'PREV_FILE_WITH_COMMENTS',
+  NEXT_UNREVIEWED_FILE = 'NEXT_UNREVIEWED_FILE',
+  CURSOR_NEXT_FILE = 'CURSOR_NEXT_FILE',
+  CURSOR_PREV_FILE = 'CURSOR_PREV_FILE',
+  OPEN_FILE = 'OPEN_FILE',
+  TOGGLE_FILE_REVIEWED = 'TOGGLE_FILE_REVIEWED',
+  TOGGLE_ALL_INLINE_DIFFS = 'TOGGLE_ALL_INLINE_DIFFS',
+  TOGGLE_INLINE_DIFF = 'TOGGLE_INLINE_DIFF',
+  TOGGLE_HIDE_ALL_COMMENT_THREADS = 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
+
+  OPEN_FIRST_FILE = 'OPEN_FIRST_FILE',
+  OPEN_LAST_FILE = 'OPEN_LAST_FILE',
+
+  SEARCH = 'SEARCH',
+  SEND_REPLY = 'SEND_REPLY',
+  EMOJI_DROPDOWN = 'EMOJI_DROPDOWN',
+  TOGGLE_BLAME = 'TOGGLE_BLAME',
+}
+
+type SectionView = Array<{binding: string[][]; text: string}>;
+
+/**
+ * The interface for listener for shortcut events.
+ */
+export type ShortcutListener = (
+  viewMap?: Map<ShortcutSection, SectionView>
+) => void;
+
+interface ShortcutEnabledElement extends PolymerElement {
+  // TODO: should replace with Map so we can have proper type here
+  keyboardShortcuts(): {[shortcut: string]: string};
+}
+
+interface ShortcutHelpItem {
+  shortcut: Shortcut;
+  text: string;
+}
+
+// TODO(TS): rename to something more meaningful
+const _help = new Map<ShortcutSection, ShortcutHelpItem[]>();
+
+function _describe(shortcut: Shortcut, section: ShortcutSection, text: string) {
+  if (!_help.has(section)) {
+    _help.set(section, []);
+  }
+  const shortcuts = _help.get(section);
+  if (shortcuts) {
+    shortcuts.push({shortcut, text});
+  }
+}
+
+_describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
+_describe(
+  Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
+  ShortcutSection.EVERYWHERE,
+  'Show this dialog'
+);
+_describe(
+  Shortcut.GO_TO_USER_DASHBOARD,
+  ShortcutSection.EVERYWHERE,
+  'Go to User Dashboard'
+);
+_describe(
+  Shortcut.GO_TO_OPENED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Opened Changes'
+);
+_describe(
+  Shortcut.GO_TO_MERGED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Merged Changes'
+);
+_describe(
+  Shortcut.GO_TO_ABANDONED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Abandoned Changes'
+);
+_describe(
+  Shortcut.GO_TO_WATCHED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Watched Changes'
+);
+
+_describe(
+  Shortcut.CURSOR_NEXT_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Select next change'
+);
+_describe(
+  Shortcut.CURSOR_PREV_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Select previous change'
+);
+_describe(
+  Shortcut.OPEN_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Show selected change'
+);
+_describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
+_describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
+_describe(
+  Shortcut.OPEN_REPLY_DIALOG,
+  ShortcutSection.ACTIONS,
+  'Open reply dialog to publish comments and add reviewers'
+);
+_describe(
+  Shortcut.OPEN_DOWNLOAD_DIALOG,
+  ShortcutSection.ACTIONS,
+  'Open download overlay'
+);
+_describe(
+  Shortcut.EXPAND_ALL_MESSAGES,
+  ShortcutSection.ACTIONS,
+  'Expand all messages'
+);
+_describe(
+  Shortcut.COLLAPSE_ALL_MESSAGES,
+  ShortcutSection.ACTIONS,
+  'Collapse all messages'
+);
+_describe(
+  Shortcut.REFRESH_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Reload the change at the latest patch'
+);
+_describe(
+  Shortcut.TOGGLE_CHANGE_REVIEWED,
+  ShortcutSection.ACTIONS,
+  'Mark/unmark change as reviewed'
+);
+_describe(
+  Shortcut.TOGGLE_FILE_REVIEWED,
+  ShortcutSection.ACTIONS,
+  'Toggle review flag on selected file'
+);
+_describe(
+  Shortcut.REFRESH_CHANGE_LIST,
+  ShortcutSection.ACTIONS,
+  'Refresh list of changes'
+);
+_describe(
+  Shortcut.TOGGLE_CHANGE_STAR,
+  ShortcutSection.ACTIONS,
+  'Star/unstar change'
+);
+_describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic');
+_describe(
+  Shortcut.DIFF_AGAINST_BASE,
+  ShortcutSection.ACTIONS,
+  'Diff against base'
+);
+_describe(
+  Shortcut.DIFF_AGAINST_LATEST,
+  ShortcutSection.ACTIONS,
+  'Diff against latest patchset'
+);
+_describe(
+  Shortcut.DIFF_BASE_AGAINST_LEFT,
+  ShortcutSection.ACTIONS,
+  'Diff base against left'
+);
+_describe(
+  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+  ShortcutSection.ACTIONS,
+  'Diff right against latest'
+);
+_describe(
+  Shortcut.DIFF_BASE_AGAINST_LATEST,
+  ShortcutSection.ACTIONS,
+  'Diff base against latest'
+);
+
+_describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
+_describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
+_describe(
+  Shortcut.DIFF_AGAINST_BASE,
+  ShortcutSection.DIFFS,
+  'Diff against base'
+);
+_describe(
+  Shortcut.DIFF_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff against latest patchset'
+);
+_describe(
+  Shortcut.DIFF_BASE_AGAINST_LEFT,
+  ShortcutSection.DIFFS,
+  'Diff base against left'
+);
+_describe(
+  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff right against latest'
+);
+_describe(
+  Shortcut.DIFF_BASE_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff base against latest'
+);
+_describe(
+  Shortcut.VISIBLE_LINE,
+  ShortcutSection.DIFFS,
+  'Move cursor to currently visible code'
+);
+_describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS, 'Go to next diff chunk');
+_describe(
+  Shortcut.PREV_CHUNK,
+  ShortcutSection.DIFFS,
+  'Go to previous diff chunk'
+);
+_describe(
+  Shortcut.EXPAND_ALL_DIFF_CONTEXT,
+  ShortcutSection.DIFFS,
+  'Expand all diff context'
+);
+_describe(
+  Shortcut.NEXT_COMMENT_THREAD,
+  ShortcutSection.DIFFS,
+  'Go to next comment thread'
+);
+_describe(
+  Shortcut.PREV_COMMENT_THREAD,
+  ShortcutSection.DIFFS,
+  'Go to previous comment thread'
+);
+_describe(
+  Shortcut.EXPAND_ALL_COMMENT_THREADS,
+  ShortcutSection.DIFFS,
+  'Expand all comment threads'
+);
+_describe(
+  Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+  ShortcutSection.DIFFS,
+  'Collapse all comment threads'
+);
+_describe(
+  Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+  ShortcutSection.DIFFS,
+  'Hide/Display all comment threads'
+);
+_describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
+_describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
+_describe(
+  Shortcut.TOGGLE_LEFT_PANE,
+  ShortcutSection.DIFFS,
+  'Hide/show left diff'
+);
+_describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
+_describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
+_describe(
+  Shortcut.OPEN_DIFF_PREFS,
+  ShortcutSection.DIFFS,
+  'Show diff preferences'
+);
+_describe(
+  Shortcut.TOGGLE_DIFF_REVIEWED,
+  ShortcutSection.DIFFS,
+  'Mark/unmark file as reviewed'
+);
+_describe(
+  Shortcut.TOGGLE_DIFF_MODE,
+  ShortcutSection.DIFFS,
+  'Toggle unified/side-by-side diff'
+);
+_describe(
+  Shortcut.NEXT_UNREVIEWED_FILE,
+  ShortcutSection.DIFFS,
+  'Mark file as reviewed and go to next unreviewed file'
+);
+_describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame');
+
+_describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file');
+_describe(
+  Shortcut.PREV_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to previous file'
+);
+_describe(
+  Shortcut.NEXT_FILE_WITH_COMMENTS,
+  ShortcutSection.NAVIGATION,
+  'Go to next file that has comments'
+);
+_describe(
+  Shortcut.PREV_FILE_WITH_COMMENTS,
+  ShortcutSection.NAVIGATION,
+  'Go to previous file that has comments'
+);
+_describe(
+  Shortcut.OPEN_FIRST_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to first file'
+);
+_describe(
+  Shortcut.OPEN_LAST_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to last file'
+);
+_describe(
+  Shortcut.UP_TO_DASHBOARD,
+  ShortcutSection.NAVIGATION,
+  'Up to dashboard'
+);
+_describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
+
+_describe(
+  Shortcut.CURSOR_NEXT_FILE,
+  ShortcutSection.FILE_LIST,
+  'Select next file'
+);
+_describe(
+  Shortcut.CURSOR_PREV_FILE,
+  ShortcutSection.FILE_LIST,
+  'Select previous file'
+);
+_describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST, 'Go to selected file');
+_describe(
+  Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+  ShortcutSection.FILE_LIST,
+  'Show/hide all inline diffs'
+);
+_describe(
+  Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+  ShortcutSection.FILE_LIST,
+  'Hide/Display all comment threads'
+);
+_describe(
+  Shortcut.TOGGLE_INLINE_DIFF,
+  ShortcutSection.FILE_LIST,
+  'Show/hide selected inline diff'
+);
+
+_describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
+_describe(
+  Shortcut.EMOJI_DROPDOWN,
+  ShortcutSection.REPLY_DIALOG,
+  'Emoji dropdown'
+);
+
+// Must be declared outside behavior implementation to be accessed inside
+// behavior functions.
+
+/**
+ * Keyboard events emitted from polymer elements.
+ */
+export interface CustomKeyboardEvent extends CustomEvent, EventApi {
+  event: CustomKeyboardEvent;
+  detail: {
+    keyboardEvent?: EventApi;
+    // TODO(TS): maybe should mark as optional and check before accessing
+    key: string;
+  };
+  readonly altKey: boolean;
+  readonly changedTouches: TouchList;
+  readonly ctrlKey: boolean;
+  readonly metaKey: boolean;
+  readonly shiftKey: boolean;
+  readonly keyCode: number;
+}
+function getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent {
+  const event = dom(e.detail ? e.detail.keyboardEvent : e);
+  // TODO(TS): worth checking if this still holds or not, if no, remove this.
+  // When e is a keyboardEvent, e.event is not null.
+  if ('event' in event && (event as CustomKeyboardEvent).event) {
+    return (event as CustomKeyboardEvent).event;
+  }
+  return event as CustomKeyboardEvent;
+}
+
+/**
+ * Shortcut manager, holds all hosts, bindings and listners.
+ */
+export class ShortcutManager {
+  private readonly activeHosts = new Map<PolymerElement, Map<string, string>>();
+
+  private readonly bindings = new Map<Shortcut, string[]>();
+
+  private readonly listeners = new Set<ShortcutListener>();
+
+  bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
+    this.bindings.set(shortcut, bindings);
+  }
+
+  getBindingsForShortcut(shortcut: Shortcut) {
+    return this.bindings.get(shortcut);
+  }
+
+  attachHost(host: PolymerElement | ShortcutEnabledElement) {
+    if (!('keyboardShortcuts' in host)) {
+      return;
+    }
+    const shortcuts = host.keyboardShortcuts();
+    this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
+    this.notifyListeners();
+    return shortcuts;
+  }
+
+  detachHost(host: PolymerElement) {
+    if (this.activeHosts.delete(host)) {
+      this.notifyListeners();
+      return true;
+    }
+    return false;
+  }
+
+  addListener(listener: ShortcutListener) {
+    this.listeners.add(listener);
+    listener(this.directoryView());
+  }
+
+  removeListener(listener: ShortcutListener) {
+    return this.listeners.delete(listener);
+  }
+
+  getDescription(section: ShortcutSection, shortcutName: Shortcut) {
+    const bindings = _help.get(section);
+    let desc = '';
+    if (bindings) {
+      const binding = bindings.find(
+        binding => binding.shortcut === shortcutName
+      );
+      desc = binding ? binding.text : '';
+    }
+    return desc;
+  }
+
+  getShortcut(shortcutName: Shortcut) {
+    const bindings = this.bindings.get(shortcutName);
+    return bindings
+      ? bindings.map(binding => this.describeBinding(binding)).join(', ')
+      : '';
+  }
+
+  activeShortcutsBySection() {
+    const activeShortcuts = new Set<string>();
+    this.activeHosts.forEach(shortcuts => {
+      shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
+    });
+
+    const activeShortcutsBySection = new Map<
+      ShortcutSection,
+      ShortcutHelpItem[]
+    >();
+    _help.forEach((shortcutList, section) => {
+      shortcutList.forEach(shortcutHelp => {
+        if (activeShortcuts.has(shortcutHelp.shortcut)) {
+          if (!activeShortcutsBySection.has(section)) {
+            activeShortcutsBySection.set(section, []);
+          }
+          // From previous condition, the `get(section)`
+          // should always return a valid result
+          activeShortcutsBySection.get(section)!.push(shortcutHelp);
+        }
+      });
+    });
+    return activeShortcutsBySection;
+  }
+
+  directoryView() {
+    const view = new Map<ShortcutSection, SectionView>();
+    this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
+      const sectionView: Array<{binding: string[][]; text: string}> = [];
+      shortcutHelps.forEach(shortcutHelp => {
+        const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
+        if (!bindingDesc) {
+          return;
+        }
+        this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
+          sectionView.push({
+            binding: bindingDesc,
+            text: shortcutHelp.text,
+          });
+        });
+      });
+      view.set(section, sectionView);
+    });
+    return view;
+  }
+
+  distributeBindingDesc(bindingDesc: string[][]): string[][][] {
+    if (
+      bindingDesc.length === 1 ||
+      this.comboSetDisplayWidth(bindingDesc) < 21
+    ) {
+      return [bindingDesc];
+    }
+    // Find the largest prefix of bindings that is under the
+    // size threshold.
+    const head = [bindingDesc[0]];
+    for (let i = 1; i < bindingDesc.length; i++) {
+      head.push(bindingDesc[i]);
+      if (this.comboSetDisplayWidth(head) >= 21) {
+        head.pop();
+        return [head].concat(this.distributeBindingDesc(bindingDesc.slice(i)));
+      }
+    }
+    return [];
+  }
+
+  comboSetDisplayWidth(bindingDesc: string[][]) {
+    const bindingSizer = (binding: string[]) =>
+      binding.reduce((acc, key) => acc + key.length, 0);
+    // Width is the sum of strings + (n-1) * 2 to account for the word
+    // "or" joining them.
+    return (
+      bindingDesc.reduce((acc, binding) => acc + bindingSizer(binding), 0) +
+      2 * (bindingDesc.length - 1)
+    );
+  }
+
+  describeBindings(shortcut: Shortcut): string[][] | null {
+    const bindings = this.bindings.get(shortcut);
+    if (!bindings) {
+      return null;
+    }
+    // TODO(TS): should check base on length to differentiate two
+    // cases
+    if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
+      return bindings
+        .slice(1)
+        .map(binding => this._describeKey(binding))
+        .map(binding => ['g'].concat(binding));
+    }
+    if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
+      return bindings
+        .slice(1)
+        .map(binding => this._describeKey(binding))
+        .map(binding => ['v'].concat(binding));
+    }
+
+    return bindings
+      .filter(binding => binding !== SPECIAL_SHORTCUT.DOC_ONLY)
+      .map(binding => this.describeBinding(binding));
+  }
+
+  _describeKey(key: string) {
+    switch (key) {
+      case 'shift':
+        return 'Shift';
+      case 'meta':
+        return 'Meta';
+      case 'ctrl':
+        return 'Ctrl';
+      case 'enter':
+        return 'Enter';
+      case 'up':
+        return '\u2191'; // ↑
+      case 'down':
+        return '\u2193'; // ↓
+      case 'left':
+        return '\u2190'; // ←
+      case 'right':
+        return '\u2192'; // →
+      default:
+        return key;
+    }
+  }
+
+  describeBinding(binding: string) {
+    // single key bindings
+    if (binding.length === 1) {
+      return [binding];
+    }
+    return binding
+      .split(':')[0]
+      .split('+')
+      .map(part => this._describeKey(part));
+  }
+
+  notifyListeners() {
+    const view = this.directoryView();
+    this.listeners.forEach(listener => listener(view));
+  }
+}
+
+const shortcutManager = new ShortcutManager();
+
+/**
+ * Enum for supported modifiers.
+ */
+export enum Modifier {
+  SHIFT_KEY = 'shiftKey',
+  CTRL_KEY = 'ctrlKey',
+  META_KEY = 'metaKey',
+  // Add when you need it
+}
+
+interface IronA11yKeysMixinConstructor {
+  // Note: this is needed to have same interface as other mixins
+  new (...args: any[]): IronA11yKeysBehavior;
+}
+/**
+ * @polymer
+ * @mixinFunction
+ */
+const InternalKeyboardShortcutMixin = dedupingMixin(
+  <T extends Constructor<PolymerElement> & IronA11yKeysMixinConstructor>(
+    superClass: T
+  ): T & Constructor<KeyboardShortcutMixinInterface> => {
+    /**
+     * @polymer
+     * @mixinClass
+     */
+    class Mixin extends superClass {
+      @property({type: Number})
+      _shortcut_go_key_last_pressed: number | null = null;
+
+      @property({type: Number})
+      _shortcut_v_key_last_pressed: number | null = null;
+
+      @property({type: Object})
+      _shortcut_go_table: Map<string, string> = new Map();
+
+      @property({type: Object})
+      _shortcut_v_table: Map<string, string> = new Map();
+
+      modifierPressed(event: CustomKeyboardEvent) {
+        /* We are checking for g/v as modifiers pressed. There are cases such as
+         * pressing v and then /, where we want the handler for / to be triggered.
+         * TODO(dhruvsri): find a way to support that keyboard combination
+         */
+        const e = getKeyboardEvent(event);
+        return (
+          e.altKey ||
+          e.ctrlKey ||
+          e.metaKey ||
+          e.shiftKey ||
+          !!this._inGoKeyMode() ||
+          !!this.inVKeyMode()
+        );
+      }
+
+      isModifierPressed(e: CustomKeyboardEvent, modifier: Modifier) {
+        return getKeyboardEvent(e)[modifier];
+      }
+
+      shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent) {
+        const e = getKeyboardEvent(event);
+        // TODO(TS): maybe override the EventApi, narrow it down to Element always
+        const tagName = ((dom(e) as EventApi).rootTarget as Element).tagName;
+        if (
+          tagName === 'INPUT' ||
+          tagName === 'TEXTAREA' ||
+          (e.keyCode === 13 && tagName === 'A')
+        ) {
+          // Suppress shortcuts if the key is 'enter' and target is an anchor.
+          return true;
+        }
+        for (let i = 0; e.path && i < e.path.length; i++) {
+          // TODO(TS): narrow this down to Element from EventTarget first
+          if ((e.path[i] as Element).tagName === 'GR-OVERLAY') {
+            return true;
+          }
+        }
+
+        this.dispatchEvent(
+          new CustomEvent('shortcut-triggered', {
+            detail: {
+              event: e,
+              goKey: this._inGoKeyMode(),
+              vKey: this.inVKeyMode(),
+            },
+            composed: true,
+            bubbles: true,
+          })
+        );
+        return false;
+      }
+
+      // Alias for getKeyboardEvent.
+      getKeyboardEvent(e: CustomKeyboardEvent) {
+        return getKeyboardEvent(e);
+      }
+
+      // TODO(TS): maybe remove, no reference in the code base
+      getRootTarget(e: CustomKeyboardEvent) {
+        // TODO(TS): worth checking if we can limit this to EventApi only
+        // dom currently returns DomNativeApi|EventApi
+        return (dom(getKeyboardEvent(e)) as EventApi).rootTarget;
+      }
+
+      bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
+        shortcutManager.bindShortcut(shortcut, ...bindings);
+      }
+
+      createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+        const desc = shortcutManager.getDescription(section, shortcutName);
+        const shortcut = shortcutManager.getShortcut(shortcutName);
+        return desc && shortcut ? `${desc} (shortcut: ${shortcut})` : '';
+      }
+
+      _addOwnKeyBindings(shortcut: Shortcut, handler: string) {
+        const bindings = shortcutManager.getBindingsForShortcut(shortcut);
+        if (!bindings) {
+          return;
+        }
+        if (bindings[0] === SPECIAL_SHORTCUT.DOC_ONLY) {
+          return;
+        }
+        if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
+          bindings
+            .slice(1)
+            .forEach(binding => this._shortcut_go_table.set(binding, handler));
+        } else if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
+          // for each binding added with the go/v key, we set the handler to be
+          // handleVKeyAction. handleVKeyAction then looks up in th
+          // shortcut_table to see what the relevant handler should be
+          bindings
+            .slice(1)
+            .forEach(binding => this._shortcut_v_table.set(binding, handler));
+        } else {
+          this.addOwnKeyBinding(bindings.join(' '), handler);
+        }
+      }
+
+      /** @override */
+      connectedCallback() {
+        super.connectedCallback();
+        const shortcuts = shortcutManager.attachHost(this);
+        if (!shortcuts) {
+          return;
+        }
+
+        for (const key of Object.keys(shortcuts)) {
+          // TODO(TS): not needed if convert shortcuts to Map
+          this._addOwnKeyBindings(key as Shortcut, shortcuts[key]);
+        }
+
+        // each component that uses this behaviour must be aware if go key is
+        // pressed or not, since it needs to check it as a modifier
+        this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
+        this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
+
+        // If any of the shortcuts utilized GO_KEY, then they are handled
+        // directly by this behavior.
+        if (this._shortcut_go_table.size > 0) {
+          this._shortcut_go_table.forEach((_, key) => {
+            this.addOwnKeyBinding(key, '_handleGoAction');
+          });
+        }
+
+        this.addOwnKeyBinding('v:keydown', '_handleVKeyDown');
+        this.addOwnKeyBinding('v:keyup', '_handleVKeyUp');
+        if (this._shortcut_v_table.size > 0) {
+          this._shortcut_v_table.forEach((_, key) => {
+            this.addOwnKeyBinding(key, '_handleVAction');
+          });
+        }
+      }
+
+      /** @override */
+      disconnectedCallback() {
+        super.disconnectedCallback();
+        if (shortcutManager.detachHost(this)) {
+          this.removeOwnKeyBindings();
+        }
+      }
+
+      keyboardShortcuts() {
+        return {};
+      }
+
+      addKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
+        shortcutManager.addListener(listener);
+      }
+
+      removeKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
+        shortcutManager.removeListener(listener);
+      }
+
+      _handleVKeyDown(e: CustomKeyboardEvent) {
+        if (this.shouldSuppressKeyboardShortcut(e)) return;
+        this._shortcut_v_key_last_pressed = Date.now();
+      }
+
+      _handleVKeyUp() {
+        setTimeout(() => {
+          this._shortcut_v_key_last_pressed = null;
+        }, V_KEY_TIMEOUT_MS);
+      }
+
+      private inVKeyMode() {
+        return (
+          this._shortcut_v_key_last_pressed &&
+          Date.now() - this._shortcut_v_key_last_pressed <= V_KEY_TIMEOUT_MS
+        );
+      }
+
+      _handleVAction(e: CustomKeyboardEvent) {
+        if (
+          !this.inVKeyMode() ||
+          !this._shortcut_v_table.has(e.detail.key) ||
+          this.shouldSuppressKeyboardShortcut(e)
+        ) {
+          return;
+        }
+        e.preventDefault();
+        const handler = this._shortcut_v_table.get(e.detail.key);
+        if (handler) {
+          // TODO(TS): should fix this
+          (this as any)[handler](e);
+        }
+      }
+
+      _handleGoKeyDown(e: CustomKeyboardEvent) {
+        if (this.shouldSuppressKeyboardShortcut(e)) return;
+        this._shortcut_go_key_last_pressed = Date.now();
+      }
+
+      _handleGoKeyUp() {
+        // Set go_key_last_pressed to null `GO_KEY_TIMEOUT_MS` after keyup event
+        // so that users can trigger `g + i` by pressing g and i quickly.
+        setTimeout(() => {
+          this._shortcut_go_key_last_pressed = null;
+        }, GO_KEY_TIMEOUT_MS);
+      }
+
+      _inGoKeyMode() {
+        return (
+          this._shortcut_go_key_last_pressed &&
+          Date.now() - this._shortcut_go_key_last_pressed <= GO_KEY_TIMEOUT_MS
+        );
+      }
+
+      _handleGoAction(e: CustomKeyboardEvent) {
+        if (
+          !this._inGoKeyMode() ||
+          !this._shortcut_go_table.has(e.detail.key) ||
+          this.shouldSuppressKeyboardShortcut(e)
+        ) {
+          return;
+        }
+        e.preventDefault();
+        const handler = this._shortcut_go_table.get(e.detail.key);
+        if (handler) {
+          // TODO(TS): should fix this
+          (this as any)[handler](e);
+        }
+      }
+    }
+
+    return Mixin;
+  }
+);
+
+// The following doesn't work (IronA11yKeysBehavior crashes):
+// const KeyboardShortcutMixin = dedupingMixin(superClass => {
+//    class Mixin extends mixinBehaviors([IronA11yKeysBehavior], superClass) {
+//    ...
+//    }
+//    return Mixin;
+// }
+// This is a workaround
+export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
+  superClass: T
+): T & Constructor<KeyboardShortcutMixinInterface> => {
+  return InternalKeyboardShortcutMixin(
+    // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
+    // which will fail the type check due to missing IronA11yKeysBehavior interface
+    mixinBehaviors([IronA11yKeysBehavior], superClass) as any
+  );
+};
+
+/** The interface corresponding to KeyboardShortcutMixin */
+export interface KeyboardShortcutMixinInterface {
+  _shortcut_go_key_last_pressed: number | null;
+  _shortcut_v_key_last_pressed: number | null;
+  _shortcut_go_table: Map<string, string>;
+  _shortcut_v_table: Map<string, string>;
+  keyboardShortcuts(): {[key: string]: string};
+  createTitle(name: Shortcut, section: ShortcutSection): string;
+  bindShortcut(shortcut: Shortcut, ...bindings: string[]): void;
+  shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent): boolean;
+  modifierPressed(event: CustomKeyboardEvent): boolean;
+  isModifierPressed(event: CustomKeyboardEvent, modifier: Modifier): boolean;
+  getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent;
+}
+
+export function _testOnly_getShortcutManagerInstance() {
+  return shortcutManager;
+}
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
new file mode 100644
index 0000000..534ead4
--- /dev/null
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
@@ -0,0 +1,432 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {
+  KeyboardShortcutMixin, Shortcut,
+  ShortcutManager, ShortcutSection, SPECIAL_SHORTCUT,
+} from './keyboard-shortcut-mixin.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+
+const basicFixture =
+    fixtureFromElement('keyboard-shortcut-mixin-test-element');
+
+const withinOverlayFixture = fixtureFromTemplate(html`
+<gr-overlay>
+  <keyboard-shortcut-mixin-test-element>
+  </keyboard-shortcut-mixin-test-element>
+</gr-overlay>
+`);
+
+class GrKeyboardShortcutMixinTestElement extends
+  KeyboardShortcutMixin(PolymerElement) {
+  static get is() {
+    return 'keyboard-shortcut-mixin-test-element';
+  }
+
+  get keyBindings() {
+    return {
+      k: '_handleKey',
+      enter: '_handleKey',
+    };
+  }
+
+  _handleKey() {}
+}
+
+customElements.define(GrKeyboardShortcutMixinTestElement.is,
+    GrKeyboardShortcutMixinTestElement);
+
+suite('keyboard-shortcut-mixin tests', () => {
+  let element;
+  let overlay;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    overlay = withinOverlayFixture.instantiate();
+  });
+
+  suite('ShortcutManager', () => {
+    test('bindings management', () => {
+      const mgr = new ShortcutManager();
+      const NEXT_FILE = Shortcut.NEXT_FILE;
+
+      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+      mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
+      assert.deepEqual(
+          mgr.getBindingsForShortcut(NEXT_FILE),
+          [']', '}', 'right']);
+    });
+
+    test('getShortcut', () => {
+      const mgr = new ShortcutManager();
+      const NEXT_FILE = Shortcut.NEXT_FILE;
+
+      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+      mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
+      assert.equal(mgr.getShortcut(NEXT_FILE), '], }, →');
+    });
+
+    suite('binding descriptions', () => {
+      function mapToObject(m) {
+        const o = {};
+        m.forEach((v, k) => o[k] = v);
+        return o;
+      }
+
+      test('single combo description', () => {
+        const mgr = new ShortcutManager();
+        assert.deepEqual(mgr.describeBinding('a'), ['a']);
+        assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
+        assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
+        assert.deepEqual(
+            mgr.describeBinding('ctrl+shift+up:keyup'),
+            ['Ctrl', 'Shift', '↑']);
+      });
+
+      test('combo set description', () => {
+        const mgr = new ShortcutManager();
+        assert.isNull(mgr.describeBindings(Shortcut.NEXT_FILE));
+
+        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES,
+            SPECIAL_SHORTCUT.GO_KEY, 'o');
+        assert.deepEqual(
+            mgr.describeBindings(Shortcut.GO_TO_OPENED_CHANGES),
+            [['g', 'o']]);
+
+        mgr.bindShortcut(Shortcut.NEXT_FILE, SPECIAL_SHORTCUT.DOC_ONLY,
+            ']', 'ctrl+shift+right:keyup');
+        assert.deepEqual(
+            mgr.describeBindings(Shortcut.NEXT_FILE),
+            [[']'], ['Ctrl', 'Shift', '→']]);
+
+        mgr.bindShortcut(Shortcut.PREV_FILE, '[');
+        assert.deepEqual(mgr.describeBindings(Shortcut.PREV_FILE), [['[']]);
+      });
+
+      test('combo set description width', () => {
+        const mgr = new ShortcutManager();
+        assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
+        assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
+        assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
+        assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
+        assert.strictEqual(
+            mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
+            12);
+      });
+
+      test('distribute shortcut help', () => {
+        const mgr = new ShortcutManager();
+        assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
+        assert.deepEqual(
+            mgr.distributeBindingDesc([['g', 'o']]),
+            [[['g', 'o']]]);
+        assert.deepEqual(
+            mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
+            [[['ctrl', 'shift', 'meta', 'enter']]]);
+        assert.deepEqual(
+            mgr.distributeBindingDesc([
+              ['ctrl', 'shift', 'meta', 'enter'],
+              ['o'],
+            ]),
+            [
+              [['ctrl', 'shift', 'meta', 'enter']],
+              [['o']],
+            ]);
+        assert.deepEqual(
+            mgr.distributeBindingDesc([
+              ['ctrl', 'enter'],
+              ['meta', 'enter'],
+              ['ctrl', 's'],
+              ['meta', 's'],
+            ]),
+            [
+              [['ctrl', 'enter'], ['meta', 'enter']],
+              [['ctrl', 's'], ['meta', 's']],
+            ]);
+      });
+
+      test('active shortcuts by section', () => {
+        const mgr = new ShortcutManager();
+        mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
+        mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
+        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES, 'g+o');
+        mgr.bindShortcut(Shortcut.SEARCH, '/');
+
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {});
+
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [Shortcut.NEXT_FILE]: null,
+            };
+          },
+        });
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {
+              [ShortcutSection.NAVIGATION]: [
+                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
+              ],
+            });
+
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [Shortcut.NEXT_LINE]: null,
+            };
+          },
+        });
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {
+              [ShortcutSection.DIFFS]: [
+                {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
+              ],
+              [ShortcutSection.NAVIGATION]: [
+                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
+              ],
+            });
+
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [Shortcut.SEARCH]: null,
+              [Shortcut.GO_TO_OPENED_CHANGES]: null,
+            };
+          },
+        });
+        assert.deepEqual(
+            mapToObject(mgr.activeShortcutsBySection()),
+            {
+              [ShortcutSection.DIFFS]: [
+                {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
+              ],
+              [ShortcutSection.EVERYWHERE]: [
+                {shortcut: Shortcut.SEARCH, text: 'Search'},
+                {
+                  shortcut: Shortcut.GO_TO_OPENED_CHANGES,
+                  text: 'Go to Opened Changes',
+                },
+              ],
+              [ShortcutSection.NAVIGATION]: [
+                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
+              ],
+            });
+      });
+
+      test('directory view', () => {
+        const mgr = new ShortcutManager();
+        mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
+        mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
+        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES,
+            SPECIAL_SHORTCUT.GO_KEY, 'o');
+        mgr.bindShortcut(Shortcut.SEARCH, '/');
+        mgr.bindShortcut(
+            Shortcut.SAVE_COMMENT, 'ctrl+enter', 'meta+enter',
+            'ctrl+s', 'meta+s');
+
+        assert.deepEqual(mapToObject(mgr.directoryView()), {});
+
+        mgr.attachHost({
+          keyboardShortcuts() {
+            return {
+              [Shortcut.GO_TO_OPENED_CHANGES]: null,
+              [Shortcut.NEXT_FILE]: null,
+              [Shortcut.NEXT_LINE]: null,
+              [Shortcut.SAVE_COMMENT]: null,
+              [Shortcut.SEARCH]: null,
+            };
+          },
+        });
+        assert.deepEqual(
+            mapToObject(mgr.directoryView()),
+            {
+              [ShortcutSection.DIFFS]: [
+                {binding: [['j']], text: 'Go to next line'},
+                {
+                  binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']],
+                  text: 'Save comment',
+                },
+                {
+                  binding: [['Ctrl', 's'], ['Meta', 's']],
+                  text: 'Save comment',
+                },
+              ],
+              [ShortcutSection.EVERYWHERE]: [
+                {binding: [['/']], text: 'Search'},
+                {binding: [['g', 'o']], text: 'Go to Opened Changes'},
+              ],
+              [ShortcutSection.NAVIGATION]: [
+                {binding: [[']']], text: 'Go to next file'},
+              ],
+            });
+      });
+    });
+  });
+
+  test('doesn’t block kb shortcuts for non-allowed els', done => {
+    const divEl = document.createElement('div');
+    element.appendChild(divEl);
+    element._handleKey = e => {
+      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(divEl, 75, null, 'k');
+  });
+
+  test('blocks kb shortcuts for input els', done => {
+    const inputEl = document.createElement('input');
+    element.appendChild(inputEl);
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+  });
+
+  test('blocks kb shortcuts for textarea els', done => {
+    const textareaEl = document.createElement('textarea');
+    element.appendChild(textareaEl);
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
+  });
+
+  test('blocks kb shortcuts for anything in a gr-overlay', done => {
+    const divEl = document.createElement('div');
+    const element =
+        overlay.querySelector('keyboard-shortcut-mixin-test-element');
+    element.appendChild(divEl);
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(divEl, 75, null, 'k');
+  });
+
+  test('blocks enter shortcut on an anchor', done => {
+    const anchorEl = document.createElement('a');
+    const element =
+        overlay.querySelector('keyboard-shortcut-mixin-test-element');
+    element.appendChild(anchorEl);
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
+  });
+
+  test('modifierPressed returns accurate values', () => {
+    const spy = sinon.spy(element, 'modifierPressed');
+    element._handleKey = e => {
+      element.modifierPressed(e);
+    };
+    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+  });
+
+  test('isModifierPressed returns accurate value', () => {
+    const spy = sinon.spy(element, 'isModifierPressed');
+    element._handleKey = e => {
+      element.isModifierPressed(e, 'shiftKey');
+    };
+    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+  });
+
+  suite('GO_KEY timing', () => {
+    let handlerStub;
+
+    setup(() => {
+      element._shortcut_go_table.set('a', '_handleA');
+      handlerStub = element._handleA = sinon.stub();
+      sinon.stub(Date, 'now').returns(10000);
+    });
+
+    test('success', () => {
+      const e = {detail: {key: 'a'}, preventDefault: () => {}};
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isTrue(handlerStub.calledOnce);
+      assert.strictEqual(handlerStub.lastCall.args[0], e);
+    });
+
+    test('go key not pressed', () => {
+      const e = {detail: {key: 'a'}, preventDefault: () => {}};
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = null;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('go key pressed too long ago', () => {
+      const e = {detail: {key: 'a'}, preventDefault: () => {}};
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 3000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('should suppress', () => {
+      const e = {detail: {key: 'a'}, preventDefault: () => {}};
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('unrecognized key', () => {
+      const e = {detail: {key: 'f'}, preventDefault: () => {}};
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/node_modules_licenses/BUILD b/polygerrit-ui/app/node_modules_licenses/BUILD
index 92a3db8..7652ddc 100644
--- a/polygerrit-ui/app/node_modules_licenses/BUILD
+++ b/polygerrit-ui/app/node_modules_licenses/BUILD
@@ -1,4 +1,4 @@
-load("@npm_bazel_typescript//:index.bzl", "ts_library")
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
 load("//tools/node_tools/node_modules_licenses:node_modules_licenses.bzl", "node_modules_licenses")
 
 filegroup(
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index fe07569..3315e50b 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -86,6 +86,10 @@
 
 const packages: PackageInfo[] = [
   {
+    name: "@polymer/decorators",
+    license: SharedLicenses.Polymer2017,
+  },
+  {
     name: "@polymer/font-roboto",
     license: SharedLicenses.Polymer2015,
   },
@@ -261,26 +265,10 @@
     }
   },
   {
-    name: "es6-promise",
-    license: {
-      name: "es6-promise",
-      type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
-  },
-  {
     name: "isarray",
     license: SharedLicenses.IsArray
   },
   {
-    name: "moment",
-    license: {
-      name: "moment",
-      type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
-  },
-  {
     name: "page",
     license: SharedLicenses.Page
   },
@@ -299,14 +287,6 @@
   {
     name: "polymer-bridges",
     license: SharedLicenses.Polymer2018
-  },
-  {
-    name: "whatwg-fetch",
-    license: {
-      name: "whatwg-fetch",
-      type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
   }
 ];
 
diff --git a/polygerrit-ui/app/node_modules_licenses/tsconfig.json b/polygerrit-ui/app/node_modules_licenses/tsconfig.json
index 6f4254f..c562a0c 100644
--- a/polygerrit-ui/app/node_modules_licenses/tsconfig.json
+++ b/polygerrit-ui/app/node_modules_licenses/tsconfig.json
@@ -6,7 +6,7 @@
     "esModuleInterop": true,
     "strict": true,
     "moduleResolution": "node",
-    "outDir": "out",
+    "outDir": "../../../.ts-out/polygerrit-ui/node_modules_licenses", // Not used in bazel
     "types": ["node"]
   },
   "include": ["**/*.ts"]
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 5989493..d6e94fc 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -3,35 +3,33 @@
   "description": "Gerrit Code Review - Polygerrit dependencies",
   "browser": true,
   "dependencies": {
+    "@polymer/decorators": "^3.0.0",
     "@polymer/font-roboto-local": "^3.0.2",
     "@polymer/iron-a11y-keys-behavior": "^3.0.1",
-    "@polymer/iron-autogrow-textarea": "^3.0.1",
+    "@polymer/iron-autogrow-textarea": "^3.0.3",
     "@polymer/iron-dropdown": "^3.0.1",
-    "@polymer/iron-fit-behavior": "^3.0.1",
+    "@polymer/iron-fit-behavior": "^3.0.2",
     "@polymer/iron-icon": "^3.0.1",
     "@polymer/iron-iconset-svg": "^3.0.1",
     "@polymer/iron-input": "^3.0.1",
-    "@polymer/iron-overlay-behavior": "^3.0.2",
+    "@polymer/iron-overlay-behavior": "^3.0.3",
     "@polymer/iron-selector": "^3.0.1",
     "@polymer/paper-button": "^3.0.1",
     "@polymer/paper-dialog": "^3.0.1",
     "@polymer/paper-dialog-behavior": "^3.0.1",
     "@polymer/paper-dialog-scrollable": "^3.0.1",
-    "@polymer/paper-input": "^3.0.2",
+    "@polymer/paper-input": "^3.2.1",
     "@polymer/paper-item": "^3.0.1",
     "@polymer/paper-listbox": "^3.0.1",
     "@polymer/paper-tabs": "^3.1.0",
     "@polymer/paper-toggle-button": "^3.0.1",
-    "@polymer/polymer": "^3.3.0",
+    "@polymer/polymer": "^3.4.1",
     "@webcomponents/shadycss": "^1.9.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
-    "es6-promise": "^3.3.1",
-    "moment": "^2.24.0",
+    "ba-linkify": "file:../../lib/ba-linkify/src/",
     "page": "^1.11.5",
     "polymer-bridges": "file:../../polymer-bridges/",
-    "ba-linkify": "file:../../lib/ba-linkify/src/",
-    "polymer-resin": "^2.0.1",
-    "whatwg-fetch": "^3.0.0"
+    "polymer-resin": "^2.0.1"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/polygerrit-ui/app/polylint_test.sh b/polygerrit-ui/app/polylint_test.sh
index bc06c1c..0c7118d 100755
--- a/polygerrit-ui/app/polylint_test.sh
+++ b/polygerrit-ui/app/polylint_test.sh
@@ -5,7 +5,7 @@
 DIR=$(pwd)
 ln -s $RUNFILES_DIR/ui_npm/node_modules $TEST_TMPDIR/node_modules
 cp $2 $TEST_TMPDIR/polymer.json
-cp -R -L polygerrit-ui/app/* $TEST_TMPDIR
+cp -R -L polygerrit-ui/app/_pg_ts_out/* $TEST_TMPDIR
 
 #Can't use --root with polymer.json - see https://github.com/Polymer/tools/issues/2616
 #Change current directory to the root folder
diff --git a/polygerrit-ui/app/polymer.json b/polygerrit-ui/app/polymer.json
index 411c969..11793e2 100644
--- a/polygerrit-ui/app/polymer.json
+++ b/polygerrit-ui/app/polymer.json
@@ -1,14 +1,14 @@
 {
-  "entrypoint": "elements/gr-app.html",
+  "shell": "elements/gr-app.js",
   "sources": [
-    "behaviors/**/*",
     "elements/**/*",
+    "mixins/**/*",
     "scripts/**/*",
     "styles/*",
     "types/**/*"
   ],
   "lint": {
-    "rules": ["polymer-2"],
+    "rules": ["polymer-3"],
     "ignoreWarnings": ["deprecated-dom-call"]
   }
 }
diff --git a/polygerrit-ui/app/rollup.config.js b/polygerrit-ui/app/rollup.config.js
index d83f24f..db0e2f7 100644
--- a/polygerrit-ui/app/rollup.config.js
+++ b/polygerrit-ui/app/rollup.config.js
@@ -60,12 +60,6 @@
 export default {
   treeshake: false,
   onwarn: warning => {
-    if(warning.code === 'CIRCULAR_DEPENDENCY') {
-      // Temporary allow CIRCULAR_DEPENDENCY.
-      // See https://bugs.chromium.org/p/gerrit/issues/detail?id=12090
-      // Delete this code after bug is fixed.
-      return;
-    }
     // No warnings from rollupjs are allowed.
     // Most of the warnings are real error in our code (for example,
     // if some import couldn't be resolved we can't continue, but rollup
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 9303f2b..8d9be62 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -1,5 +1,70 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load("@npm_bazel_rollup//:index.bzl", "rollup_bundle")
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+
+def _get_ts_compiled_path(outdir, file_name):
+    """Calculates the typescript output path for a file_name.
+
+    Args:
+      outdir: the typescript output directory (relative to polygerrit-ui/app/)
+      file_name: the original file name (relative to polygerrit-ui/app/)
+
+    Returns:
+      String - the path to the file produced by the typescript compiler
+    """
+    if file_name.endswith(".js"):
+        return outdir + "/" + file_name
+    if file_name.endswith(".ts"):
+        return outdir + "/" + file_name[:-2] + "js"
+    fail("The file " + file_name + " has unsupported extension")
+
+def _get_ts_output_files(outdir, srcs):
+    """Calculates the files paths produced by the typescript compiler
+
+    Args:
+      outdir: the typescript output directory (relative to polygerrit-ui/app/)
+      srcs: list of input files (all paths relative to polygerrit-ui/app/)
+
+    Returns:
+      List of strings
+    """
+    result = []
+    for f in srcs:
+        if f.endswith(".d.ts"):
+            continue
+        result.append(_get_ts_compiled_path(outdir, f))
+    return result
+
+def compile_ts(name, srcs, ts_outdir):
+    """Compiles srcs files with the typescript compiler
+
+    Args:
+      name: rule name
+      srcs: list of input files (.js, .d.ts and .ts)
+      ts_outdir: typescript output directory
+
+    Returns:
+      The list of compiled files
+    """
+    ts_rule_name = name + "_ts_compiled"
+
+    # List of files produced by the typescript compiler
+    generated_js = _get_ts_output_files(ts_outdir, srcs)
+
+    # Run the compiler
+    native.genrule(
+        name = ts_rule_name,
+        srcs = srcs + [
+            ":tsconfig.json",
+            "@ui_npm//:node_modules",
+        ],
+        outs = generated_js,
+        cmd = " && ".join([
+            "$(location //tools/node_tools:tsc-bin) --project $(location :tsconfig.json) --outdir $(RULEDIR)/" + ts_outdir + " --baseUrl ./external/ui_npm/node_modules",
+        ]),
+        tools = ["//tools/node_tools:tsc-bin"],
+    )
+
+    return generated_js
 
 def polygerrit_bundle(name, srcs, outs, entry_point):
     """Build .zip bundle from source code
@@ -8,10 +73,10 @@
         name: rule name
         srcs: source files
         outs: array with a single item - the output file name
-        entry_point: application entry-point
+        entry_point: application js entry-point
     """
 
-    app_name = entry_point.split(".html")[0].split("/").pop()  # eg: gr-app
+    app_name = entry_point.split(".js")[0].split("/").pop()  # eg: gr-app
 
     native.filegroup(
         name = app_name + "-full-src",
@@ -24,7 +89,7 @@
         name = app_name + "-bundle-js",
         srcs = [app_name + "-full-src"],
         config_file = ":rollup.config.js",
-        entry_point = "elements/" + app_name + ".js",
+        entry_point = entry_point,
         rollup_bin = "//tools/node_tools:rollup-bin",
         sourcemap = "hidden",
         deps = [
@@ -36,7 +101,6 @@
         name = name + "_app_sources",
         srcs = [
             app_name + "-bundle-js.js",
-            entry_point,
         ],
     )
 
@@ -46,15 +110,6 @@
     )
 
     native.filegroup(
-        name = name + "_theme_sources",
-        srcs = native.glob(
-            ["styles/themes/*.html"],
-            # app-theme.html already included via an import in gr-app.html.
-            exclude = ["styles/themes/app-theme.html"],
-        ),
-    )
-
-    native.filegroup(
         name = name + "_top_sources",
         srcs = [
             "favicon.ico",
@@ -68,7 +123,6 @@
         srcs = [
             name + "_app_sources",
             name + "_css_sources",
-            name + "_theme_sources",
             name + "_top_sources",
             "//lib/fonts:robotofonts",
             "//lib/js:highlightjs__files",
@@ -84,7 +138,6 @@
             "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
             "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
             "for f in $(locations " + name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
-            "for f in $(locations " + name + "_theme_sources); do cp $$f $$TMP/polygerrit_ui/styles/themes; done",
             "for f in $(locations //lib/js:highlightjs__files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
             "cp $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js) $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-lite.js",
             "cp $$FONT_DIR/roboto/*.ttf $$TMP/polygerrit_ui/fonts/roboto/",
@@ -94,62 +147,3 @@
             "zip -qr $$ROOT/$@ *",
         ]),
     )
-
-def _wct_test(name, srcs, split_index, split_count):
-    """Macro to define single WCT suite
-
-    Defines a private macro for a portion of test files with split_index.
-    The actual split happens in test/tests.js file
-
-    Args:
-        name: name of generated sh_test
-        srcs: source files
-        split_index: index WCT suite. Must be less than split_count
-        split_count: total number of WCT suites
-    """
-    str_index = str(split_index)
-    config_json = struct(splitIndex = split_index, splitCount = split_count).to_json()
-    native.sh_test(
-        name = name,
-        size = "enormous",
-        srcs = ["wct_test.sh"],
-        args = [
-            "$(location @ui_dev_npm//web-component-tester/bin:wct)",
-            config_json,
-        ],
-        data = [
-            "@ui_dev_npm//web-component-tester/bin:wct",
-        ] + srcs,
-        # Should not run sandboxed.
-        tags = [
-            "local",
-            "manual",
-        ],
-    )
-
-def wct_suite(name, srcs, split_count):
-    """Define test suites for WCT tests.
-
-    All tests files are splited to split_count WCT suites
-
-    Args:
-        name: rule name. The macro create a test suite rule with the name name+"_test"
-        srcs: source files
-        split_count: number of sh_test (i.e. WCT suites)
-    """
-    tests = []
-    for i in range(split_count):
-        test_name = "wct_test_" + str(i)
-        _wct_test(test_name, srcs, i, split_count)
-        tests.append(test_name)
-
-    native.test_suite(
-        name = name + "_test",
-        tests = tests,
-        # Setup tags for suite as well.
-        # This excludes tests from the wildcard expansion (//...)
-        tags = [
-            "local",
-            "manual",
-        ],
-    )
diff --git a/polygerrit-ui/app/run_test.sh b/polygerrit-ui/app/run_test.sh
index 5f61de7..2ca1118 100755
--- a/polygerrit-ui/app/run_test.sh
+++ b/polygerrit-ui/app/run_test.sh
@@ -6,12 +6,6 @@
     bazel_bin=bazel
 fi
 
-# WCT tests are not hermetic, and need extra environment variables.
-# TODO(hanwen): does $DISPLAY even work on OSX?
 ${bazel_bin} test \
-      --test_env="HOME=$HOME" \
-      --test_env="WCT_ARGS=${WCT_ARGS}" \
-      --test_env="DISPLAY=${DISPLAY}" \
-      --test_env="WCT_HEADLESS_MODE=${WCT_HEADLESS_MODE}" \
       "$@" \
-      //polygerrit-ui/app:wct_test
+      //polygerrit-ui:karma_test
diff --git a/polygerrit-ui/app/samples/add-from-favorite-reviewers.js b/polygerrit-ui/app/samples/add-from-favorite-reviewers.js
new file mode 100644
index 0000000..76b2787
--- /dev/null
+++ b/polygerrit-ui/app/samples/add-from-favorite-reviewers.js
@@ -0,0 +1,145 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * This plugin will a button to quickly add favorite reviewers to
+ * reviewers in reply dialog.
+ */
+
+const onToggleButtonClicks = [];
+function toggleButtonClicked(expanded) {
+  onToggleButtonClicks.forEach(cb => {
+    cb(expanded);
+  });
+}
+
+class ReviewerShortcut extends Polymer.Element {
+  static get is() { return 'reviewer-shortcut'; }
+
+  static get properties() {
+    return {
+      change: Object,
+      expanded: {
+        type: Boolean,
+        value: false,
+      },
+    };
+  }
+
+  static get template() {
+    return Polymer.html`
+      <button on-click="toggleControlContent">
+        [[computeButtonText(expanded)]]
+      </button>
+    `;
+  }
+
+  toggleControlContent() {
+    this.expanded = !this.expanded;
+    toggleButtonClicked(this.expanded);
+  }
+
+  computeButtonText(expanded) {
+    return expanded ? 'Collapse' : 'Add favorite reviewers';
+  }
+}
+
+customElements.define(ReviewerShortcut.is, ReviewerShortcut);
+
+class ReviewerShortcutContent extends Polymer.Element {
+  static get is() { return 'reviewer-shortcut-content'; }
+
+  static get properties() {
+    return {
+      change: Object,
+      hidden: {
+        type: Boolean,
+        value: true,
+        reflectToAttribute: true,
+      },
+    };
+  }
+
+  static get template() {
+    return Polymer.html`
+      <style>
+      :host([hidden]) {
+        display: none;
+      }
+      :host {
+        display: block;
+      }
+      </style>
+      <ul>
+        <li><button on-click="addApple">Apple</button></li>
+        <li><button on-click="addBanana">Banana</button></li>
+        <li><button on-click="addCherry">Cherry</button></li>
+      </ul>
+    `;
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    onToggleButtonClicks.push(expanded => {
+      this.hidden = !expanded;
+    });
+  }
+
+  addApple() {
+    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
+      reviewer: {
+        display_name: 'Apple',
+        email: 'apple@gmail.com',
+        name: 'Apple',
+        _account_id: 0,
+      },
+    },
+    composed: true, bubbles: true}));
+  }
+
+  addBanana() {
+    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
+      reviewer: {
+        display_name: 'Banana',
+        email: 'banana@gmail.com',
+        name: 'B',
+        _account_id: 1,
+      },
+    },
+    composed: true, bubbles: true}));
+  }
+
+  addCherry() {
+    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
+      reviewer: {
+        display_name: 'Cherry',
+        email: 'cherry@gmail.com',
+        name: 'C',
+        _account_id: 2,
+      },
+    },
+    composed: true, bubbles: true}));
+  }
+}
+
+customElements.define(ReviewerShortcutContent.is, ReviewerShortcutContent);
+
+Gerrit.install(plugin => {
+  plugin.registerCustomComponent(
+      'reply-reviewers', ReviewerShortcut.is, {slot: 'right'});
+  plugin.registerCustomComponent(
+      'reply-reviewers', ReviewerShortcutContent.is, {slot: 'below'});
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/bind-parameters.js b/polygerrit-ui/app/samples/bind-parameters.js
index 8f08e27..30c7c3d 100644
--- a/polygerrit-ui/app/samples/bind-parameters.js
+++ b/polygerrit-ui/app/samples/bind-parameters.js
@@ -14,9 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-const {Element, html} = Polymer;
 
-class MyBindSample extends Element {
+// Element class exists in all browsers:
+// https://developer.mozilla.org/en-US/docs/Web/API/Element
+// Rename it to PolymerElement to avoid conflicts. Also,
+// typescript reports the following error:
+// error TS2451: Cannot redeclare block-scoped variable 'Element'.
+const {html, Element: PolymerElement} = Polymer;
+
+class MyBindSample extends PolymerElement {
   static get is() { return 'my-bind-sample'; }
 
   static get properties() {
@@ -45,7 +51,7 @@
   }
 
   _onRevisionChanged(value) {
-    console.log(`(attributeHelper.bind) revision number: ${value._number}`);
+    console.info(`(attributeHelper.bind) revision number: ${value._number}`);
   }
 }
 
@@ -62,4 +68,4 @@
   // between the file list and the change log
   plugin.registerCustomComponent(
       'change-view-integration', 'my-bind-sample');
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/samples/custom-wip-requirement.js b/polygerrit-ui/app/samples/custom-wip-requirement.js
new file mode 100644
index 0000000..1d2663c
--- /dev/null
+++ b/polygerrit-ui/app/samples/custom-wip-requirement.js
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This plugin will add a text next to WIP requirement if shown.
+ */
+class WipRequirementValue extends Polymer.Element {
+  static get is() {
+    return 'wip-requirement-value';
+  }
+
+  static get template() {
+    return Polymer.html`
+        <style include="shared-styles">
+        :host {
+          color: var(--deemphasized-text-color);
+        }
+        </style>
+        <span>Will be removed once active.</span>
+      `;
+  }
+
+  static get properties() {
+    return {
+      change: Object,
+      requirement: Object,
+    };
+  }
+}
+
+customElements.define(WipRequirementValue.is, WipRequirementValue);
+
+Gerrit.install(plugin => {
+  plugin.registerCustomComponent(
+      'submit-requirement-item-wip', WipRequirementValue.is, {slot: 'value'});
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/extra-column-on-file-list.js b/polygerrit-ui/app/samples/extra-column-on-file-list.js
new file mode 100644
index 0000000..2e37c01
--- /dev/null
+++ b/polygerrit-ui/app/samples/extra-column-on-file-list.js
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * This plugin will an extra column to file list on change page to show
+ * the first character of the path.
+ */
+
+// Header of this extra column
+class ColumnHeader extends Polymer.Element {
+  static get is() { return 'column-header'; }
+
+  static get template() {
+    return Polymer.html`
+      <style>
+      :host {
+        display: block;
+        padding-right: var(--spacing-m);
+        min-width: 5em;
+      }
+      </style>
+      <div>First Char</div>
+    `;
+  }
+}
+
+customElements.define(ColumnHeader.is, ColumnHeader);
+
+// Content of this extra column
+class ColumnContent extends Polymer.Element {
+  static get is() { return 'column-content'; }
+
+  static get properties() {
+    return {
+      path: String,
+    };
+  }
+
+  static get template() {
+    return Polymer.html`
+      <style>
+      :host {
+        display:block;
+        padding-right: var(--spacing-m);
+        min-width: 5em;
+      }
+      </style>
+      <div>[[getStatus(path)]]</div>
+    `;
+  }
+
+  getStatus(path) {
+    return path.charAt(0);
+  }
+}
+
+customElements.define(ColumnContent.is, ColumnContent);
+
+Gerrit.install(plugin => {
+  plugin.registerDynamicCustomComponent(
+      'change-view-file-list-header-prepend', ColumnHeader.is);
+  plugin.registerDynamicCustomComponent(
+      'change-view-file-list-content-prepend', ColumnContent.is);
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/samples/repo-command.js b/polygerrit-ui/app/samples/repo-command.js
index 00f95f5..4f64059 100644
--- a/polygerrit-ui/app/samples/repo-command.js
+++ b/polygerrit-ui/app/samples/repo-command.js
@@ -14,9 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-const {Element, html} = Polymer;
 
-class RepoCommandLow extends Element {
+// Element class exists in all browsers:
+// https://developer.mozilla.org/en-US/docs/Web/API/Element
+// Rename it to PolymerElement to avoid conflicts. Also,
+// typescript reports the following error:
+// error TS2451: Cannot redeclare block-scoped variable 'Element'.
+const {html, Element: PolymerElement} = Polymer;
+
+class RepoCommandLow extends PolymerElement {
   static get is() { return 'repo-command-low'; }
 
   static get properties() {
@@ -27,17 +33,25 @@
 
   static get template() {
     return html`
-    <gr-repo-command
-      title="Low-level bork"
-      on-command-tap="_handleCommandTap">
-    </gr-repo-command>
-    `;
+    <style include="shared-styles">
+    :host {
+      display: block;
+      margin-bottom: var(--spacing-xxl);
+    }
+    </style>
+    <h3>Low-level bork</h3>
+    <gr-button
+      on-click="_handleCommandTap"
+    >
+      Low-level bork
+    </gr-button>
+   `;
   }
 
   connectedCallback() {
     super.connectedCallback();
-    console.log(this.repoName);
-    console.log(this.config);
+    console.info(this.repoName);
+    console.info(this.config);
     this.hidden = this.repoName !== 'All-Projects';
   }
 
@@ -70,4 +84,4 @@
   // Low-level API
   plugin.registerCustomComponent(
       'repo-command', 'repo-command-low');
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/samples/some-screen.js b/polygerrit-ui/app/samples/some-screen.js
index 09acc81..c600fe4 100644
--- a/polygerrit-ui/app/samples/some-screen.js
+++ b/polygerrit-ui/app/samples/some-screen.js
@@ -14,9 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-const {Element, html} = Polymer;
 
-class SomeScreenMain extends Element {
+// Element class exists in all browsers:
+// https://developer.mozilla.org/en-US/docs/Web/API/Element
+// Rename it to PolymerElement to avoid conflicts. Also,
+// typescript reports the following error:
+// error TS2451: Cannot redeclare block-scoped variable 'Element'.
+const {html, Element: PolymerElement} = Polymer;
+
+class SomeScreenMain extends PolymerElement {
   static get is() { return 'some-screen-main'; }
 
   static get properties() {
@@ -64,4 +70,4 @@
   plugin.hook('change-metadata-item').onAttached(el => {
     el.innerHTML = `<a href="${mainUrl}">Plugin screen</a>`;
   });
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/samples/theme-plugin.js b/polygerrit-ui/app/samples/theme-plugin.js
index f3a8931..b3d4033 100644
--- a/polygerrit-ui/app/samples/theme-plugin.js
+++ b/polygerrit-ui/app/samples/theme-plugin.js
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 const customTheme = document.createElement('dom-module');
-customTheme.id = 'theme-plugin';
 customTheme.innerHTML = `
   <template>
     <style>
@@ -25,9 +24,9 @@
     </style>
   </template>
 `;
+customTheme.register('theme-plugin');
 
 const darkCustomTheme = document.createElement('dom-module');
-darkCustomTheme.id = 'dark-theme-plugin';
 darkCustomTheme.innerHTML = `
   <template>
     <style>
@@ -37,6 +36,7 @@
     </style>
   </template>
 `;
+darkCustomTheme.register('dark-theme-plugin');
 
 /**
  * This plugin will change the primary text color to red.
diff --git a/polygerrit-ui/app/scripts/bundled-polymer.js b/polygerrit-ui/app/scripts/bundled-polymer.js
index 711d587..780d82a 100644
--- a/polygerrit-ui/app/scripts/bundled-polymer.js
+++ b/polygerrit-ui/app/scripts/bundled-polymer.js
@@ -17,9 +17,9 @@
 
 // This file is a replacement for the
 // polymer-bridges/polymer/polymer.html file. The polymer.html file loads
-// other scripts to setup different global variables. Because polygerrit
-// code still uses global variables (like Polymer.importHref and other),
-// we must setup this global variables after conversion to es6 modules.
+// other scripts to setup different global variables. Because plugins
+// expects that Polymer is available we must setup all Polymer global
+// variables
 //
 // The bundled-polymer.js imports all scripts in the same order as the
 // polymer.html does and must be imported in all es6-modules instead
@@ -68,4 +68,4 @@
 import 'polymer-bridges/polymer/polymer-legacy_bridge.js';
 import {importHref} from './import-href.js';
 
-Polymer.importHref = importHref;
+window.Polymer.importHref = importHref;
diff --git a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js
deleted file mode 100644
index 62dc3ee..0000000
--- a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const ANONYMOUS_NAME = 'Anonymous';
-
-export class GrDisplayNameUtils {
-  static getUserName(config, account) {
-    if (account && account.name) {
-      return account.name;
-    } else if (account && account.username) {
-      return account.username;
-    } else if (account && account.email) {
-      return account.email;
-    } else if (config && config.user &&
-        config.user.anonymous_coward_name !== 'Anonymous Coward') {
-      return config.user.anonymous_coward_name;
-    }
-
-    return ANONYMOUS_NAME;
-  }
-
-  static getDisplayName(config, account) {
-    if (account && account.display_name) {
-      return account.display_name;
-    }
-    if (!account || !account.name || !config || !config.accounts) {
-      return this.getUserName(config, account);
-    }
-    if (config.accounts.default_display_name === 'USERNAME'
-        && account.username) {
-      return account.username;
-    }
-    if (config.accounts.default_display_name === 'FIRST_NAME') {
-      return account.name.trim().split(' ')[0];
-    }
-    // Treat every other value as FULL_NAME.
-    return account.name;
-  }
-
-  static getAccountDisplayName(config, account) {
-    const reviewerName = this.getUserName(config, account);
-    const reviewerEmail = this._accountEmail(account.email);
-    const reviewerStatus = account.status ? '(' + account.status + ')' : '';
-    return [reviewerName, reviewerEmail, reviewerStatus]
-        .filter(p => p.length > 0).join(' ');
-  }
-
-  static _accountEmail(email) {
-    if (typeof email !== 'undefined') {
-      return '<' + email + '>';
-    }
-    return '';
-  }
-
-  static getGroupDisplayName(group) {
-    return group.name + ' (group)';
-  }
-}
diff --git a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html b/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
deleted file mode 100644
index 818ddaa..0000000
--- a/polygerrit-ui/app/scripts/gr-display-name-utils/gr-display-name-utils_test.html
+++ /dev/null
@@ -1,203 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-display-name-utils</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-<script type="module">
-import '../../test/common-test-setup.js';
-import {GrDisplayNameUtils} from './gr-display-name-utils.js';
-
-suite('gr-display-name-utils tests', () => {
-  // eslint-disable-next-line no-unused-vars
-  const config = {
-    user: {
-      anonymous_coward_name: 'Anonymous Coward',
-    },
-  };
-
-  test('getDisplayName name only', () => {
-    const account = {
-      name: 'test-name',
-    };
-    assert.equal(GrDisplayNameUtils.getDisplayName(config, account),
-        'test-name');
-  });
-
-  test('getDisplayName prefer displayName', () => {
-    const account = {
-      name: 'test-name',
-      display_name: 'better-name',
-    };
-    assert.equal(GrDisplayNameUtils.getDisplayName(config, account),
-        'better-name');
-  });
-
-  test('getDisplayName prefer username default', () => {
-    const account = {
-      name: 'test-name',
-      username: 'user-name',
-    };
-    const config = {
-      accounts: {
-        default_display_name: 'USERNAME',
-      },
-    };
-    assert.equal(GrDisplayNameUtils.getDisplayName(config, account),
-        'user-name');
-  });
-
-  test('getDisplayName prefer first name default', () => {
-    const account = {
-      name: 'firstname lastname',
-    };
-    const config = {
-      accounts: {
-        default_display_name: 'FIRST_NAME',
-      },
-    };
-    assert.equal(GrDisplayNameUtils.getDisplayName(config, account),
-        'firstname');
-  });
-
-  test('getDisplayName ignore leading whitespace for first name', () => {
-    const account = {
-      name: '   firstname lastname',
-    };
-    const config = {
-      accounts: {
-        default_display_name: 'FIRST_NAME',
-      },
-    };
-    assert.equal(GrDisplayNameUtils.getDisplayName(config, account),
-        'firstname');
-  });
-
-  test('getDisplayName full name default', () => {
-    const account = {
-      name: 'firstname lastname',
-    };
-    const config = {
-      accounts: {
-        default_display_name: 'FULL_NAME',
-      },
-    };
-    assert.equal(GrDisplayNameUtils.getDisplayName(config, account),
-        'firstname lastname');
-  });
-
-  test('getDisplayName name only', () => {
-    const account = {
-      name: 'test-name',
-    };
-    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account),
-        'test-name');
-  });
-
-  test('getUserName username only', () => {
-    const account = {
-      username: 'test-user',
-    };
-    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account),
-        'test-user');
-  });
-
-  test('getUserName email only', () => {
-    const account = {
-      email: 'test-user@test-url.com',
-    };
-    assert.deepEqual(GrDisplayNameUtils.getUserName(config, account),
-        'test-user@test-url.com');
-  });
-
-  test('getUserName returns not Anonymous Coward as the anon name', () => {
-    assert.deepEqual(GrDisplayNameUtils.getUserName(config, null),
-        'Anonymous');
-  });
-
-  test('getUserName for the config returning the anon name', () => {
-    const config = {
-      user: {
-        anonymous_coward_name: 'Test Anon',
-      },
-    };
-    assert.deepEqual(GrDisplayNameUtils.getUserName(config, null),
-        'Test Anon');
-  });
-
-  test('getAccountDisplayName - account with name only', () => {
-    assert.equal(
-        GrDisplayNameUtils.getAccountDisplayName(config,
-            {name: 'Some user name'}),
-        'Some user name');
-  });
-
-  test('getAccountDisplayName - account with email only', () => {
-    assert.equal(
-        GrDisplayNameUtils.getAccountDisplayName(config,
-            {email: 'my@example.com'}),
-        'my@example.com <my@example.com>');
-  });
-
-  test('getAccountDisplayName - account with name and status', () => {
-    assert.equal(
-        GrDisplayNameUtils.getAccountDisplayName(config, {
-          name: 'Some name',
-          status: 'OOO',
-        }),
-        'Some name (OOO)');
-  });
-
-  test('getAccountDisplayName - account with name and email', () => {
-    assert.equal(
-        GrDisplayNameUtils.getAccountDisplayName(config, {
-          name: 'Some name',
-          email: 'my@example.com',
-        }),
-        'Some name <my@example.com>');
-  });
-
-  test('getAccountDisplayName - account with name, email and status', () => {
-    assert.equal(
-        GrDisplayNameUtils.getAccountDisplayName(config, {
-          name: 'Some name',
-          email: 'my@example.com',
-          status: 'OOO',
-        }),
-        'Some name <my@example.com> (OOO)');
-  });
-
-  test('getGroupDisplayName', () => {
-    assert.equal(
-        GrDisplayNameUtils.getGroupDisplayName({name: 'Some user name'}),
-        'Some user name (group)');
-  });
-
-  test('_accountEmail', () => {
-    assert.equal(
-        GrDisplayNameUtils._accountEmail('email@gerritreview.com'),
-        '<email@gerritreview.com>');
-    assert.equal(GrDisplayNameUtils._accountEmail(undefined), '');
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
index 2d2deac..248217c 100644
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GrDisplayNameUtils} from '../gr-display-name-utils/gr-display-name-utils.js';
+import {getAccountDisplayName} from '../../utils/display-name-util.js';
 
 export class GrEmailSuggestionsProvider {
   constructor(restAPI) {
@@ -31,7 +31,7 @@
 
   makeSuggestionItem(account) {
     return {
-      name: GrDisplayNameUtils.getAccountDisplayName(null, account),
+      name: getAccountDisplayName(null, account),
       value: {account, count: 1},
     };
   }
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html
deleted file mode 100644
index 80d3590..0000000
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.html
+++ /dev/null
@@ -1,97 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-email-suggestions-provider</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {GrEmailSuggestionsProvider} from './gr-email-suggestions-provider.js';
-
-suite('GrEmailSuggestionsProvider tests', () => {
-  let sandbox;
-  let restAPI;
-  let provider;
-  const account1 = {
-    name: 'Some name',
-    email: 'some@example.com',
-  };
-  const account2 = {
-    email: 'other@example.com',
-    _account_id: 3,
-  };
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
-    restAPI = fixture('basic');
-    provider = new GrEmailSuggestionsProvider(restAPI);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('getSuggestions', done => {
-    const getSuggestedAccountsStub =
-        sandbox.stub(restAPI, 'getSuggestedAccounts')
-            .returns(Promise.resolve([account1, account2]));
-
-    provider.getSuggestions('Some input').then(res => {
-      assert.deepEqual(res, [account1, account2]);
-      assert.isTrue(getSuggestedAccountsStub.calledOnce);
-      assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-      done();
-    });
-  });
-
-  test('makeSuggestionItem', () => {
-    assert.deepEqual(provider.makeSuggestionItem(account1), {
-      name: 'Some name <some@example.com>',
-      value: {
-        account: account1,
-        count: 1,
-      },
-    });
-
-    assert.deepEqual(provider.makeSuggestionItem(account2), {
-      name: 'other@example.com <other@example.com>',
-      value: {
-        account: account2,
-        count: 1,
-      },
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
new file mode 100644
index 0000000..7c40b7a
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GrEmailSuggestionsProvider} from './gr-email-suggestions-provider.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`);
+
+suite('GrEmailSuggestionsProvider tests', () => {
+  let restAPI;
+  let provider;
+  const account1 = {
+    name: 'Some name',
+    email: 'some@example.com',
+  };
+  const account2 = {
+    email: 'other@example.com',
+    _account_id: 3,
+  };
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    restAPI = basicFixture.instantiate();
+    provider = new GrEmailSuggestionsProvider(restAPI);
+  });
+
+  test('getSuggestions', done => {
+    const getSuggestedAccountsStub =
+        sinon.stub(restAPI, 'getSuggestedAccounts')
+            .returns(Promise.resolve([account1, account2]));
+
+    provider.getSuggestions('Some input').then(res => {
+      assert.deepEqual(res, [account1, account2]);
+      assert.isTrue(getSuggestedAccountsStub.calledOnce);
+      assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
+      done();
+    });
+  });
+
+  test('makeSuggestionItem', () => {
+    assert.deepEqual(provider.makeSuggestionItem(account1), {
+      name: 'Some name <some@example.com>',
+      value: {
+        account: account1,
+        count: 1,
+      },
+    });
+
+    assert.deepEqual(provider.makeSuggestionItem(account2), {
+      name: 'other@example.com <other@example.com>',
+      value: {
+        account: account2,
+        count: 1,
+      },
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js
index 16b6aae..ae63c56 100644
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js
@@ -25,7 +25,7 @@
         .then(groups => {
           if (!groups) { return []; }
           const keys = Object.keys(groups);
-          return keys.map(key => Object.assign({}, groups[key], {name: key}));
+          return keys.map(key => { return {...groups[key], name: key}; });
         });
   }
 
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html
deleted file mode 100644
index 2111b7e..0000000
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.html
+++ /dev/null
@@ -1,105 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-group-suggestions-provider</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {GrGroupSuggestionsProvider} from './gr-group-suggestions-provider.js';
-
-suite('GrGroupSuggestionsProvider tests', () => {
-  let sandbox;
-  let restAPI;
-  let provider;
-  const group1 = {
-    name: 'Some name',
-    id: 1,
-  };
-  const group2 = {
-    name: 'Other name',
-    id: 3,
-    url: 'abcd',
-  };
-
-  setup(() => {
-    sandbox = sinon.sandbox.create();
-
-    stub('gr-rest-api-interface', {
-      getConfig() { return Promise.resolve({}); },
-    });
-    restAPI = fixture('basic');
-    provider = new GrGroupSuggestionsProvider(restAPI);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-
-  test('getSuggestions', done => {
-    const getSuggestedAccountsStub =
-        sandbox.stub(restAPI, 'getSuggestedGroups')
-            .returns(Promise.resolve({
-              'Some name': {id: 1},
-              'Other name': {id: 3, url: 'abcd'},
-            }));
-
-    provider.getSuggestions('Some input').then(res => {
-      assert.deepEqual(res, [group1, group2]);
-      assert.isTrue(getSuggestedAccountsStub.calledOnce);
-      assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-      done();
-    });
-  });
-
-  test('makeSuggestionItem', () => {
-    assert.deepEqual(provider.makeSuggestionItem(group1), {
-      name: 'Some name',
-      value: {
-        group: {
-          name: 'Some name',
-          id: 1,
-        },
-      },
-    });
-
-    assert.deepEqual(provider.makeSuggestionItem(group2), {
-      name: 'Other name',
-      value: {
-        group: {
-          name: 'Other name',
-          id: 3,
-        },
-      },
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
new file mode 100644
index 0000000..0939f76
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
@@ -0,0 +1,86 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GrGroupSuggestionsProvider} from './gr-group-suggestions-provider.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`);
+
+suite('GrGroupSuggestionsProvider tests', () => {
+  let restAPI;
+  let provider;
+  const group1 = {
+    name: 'Some name',
+    id: 1,
+  };
+  const group2 = {
+    name: 'Other name',
+    id: 3,
+    url: 'abcd',
+  };
+
+  setup(() => {
+    stub('gr-rest-api-interface', {
+      getConfig() { return Promise.resolve({}); },
+    });
+    restAPI = basicFixture.instantiate();
+    provider = new GrGroupSuggestionsProvider(restAPI);
+  });
+
+  test('getSuggestions', done => {
+    const getSuggestedAccountsStub =
+        sinon.stub(restAPI, 'getSuggestedGroups')
+            .returns(Promise.resolve({
+              'Some name': {id: 1},
+              'Other name': {id: 3, url: 'abcd'},
+            }));
+
+    provider.getSuggestions('Some input').then(res => {
+      assert.deepEqual(res, [group1, group2]);
+      assert.isTrue(getSuggestedAccountsStub.calledOnce);
+      assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
+      done();
+    });
+  });
+
+  test('makeSuggestionItem', () => {
+    assert.deepEqual(provider.makeSuggestionItem(group1), {
+      name: 'Some name',
+      value: {
+        group: {
+          name: 'Some name',
+          id: 1,
+        },
+      },
+    });
+
+    assert.deepEqual(provider.makeSuggestionItem(group2), {
+      name: 'Other name',
+      value: {
+        group: {
+          name: 'Other name',
+          id: 3,
+        },
+      },
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
deleted file mode 100644
index 3a47ed3..0000000
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {GrDisplayNameUtils} from '../gr-display-name-utils/gr-display-name-utils.js';
-
-/**
- * @enum {string}
- */
-export const SUGGESTIONS_PROVIDERS_USERS_TYPES = {
-  REVIEWER: 'reviewers',
-  CC: 'ccs',
-  ANY: 'any',
-};
-
-export class GrReviewerSuggestionsProvider {
-  static create(restApi, changeNumber, usersType) {
-    switch (usersType) {
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
-        return new GrReviewerSuggestionsProvider(restApi, changeNumber,
-            input => restApi.getChangeSuggestedReviewers(changeNumber,
-                input));
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
-        return new GrReviewerSuggestionsProvider(restApi, changeNumber,
-            input => restApi.getChangeSuggestedCCs(changeNumber, input));
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
-        return new GrReviewerSuggestionsProvider(restApi, changeNumber,
-            input => restApi.getSuggestedAccounts(
-                `cansee:${changeNumber} ${input}`));
-      default:
-        throw new Error(`Unknown users type: ${usersType}`);
-    }
-  }
-
-  constructor(restAPI, changeNumber, apiCall) {
-    this._changeNumber = changeNumber;
-    this._apiCall = apiCall;
-    this._restAPI = restAPI;
-  }
-
-  init() {
-    if (this._initPromise) {
-      return this._initPromise;
-    }
-    const getConfigPromise = this._restAPI.getConfig().then(cfg => {
-      this._config = cfg;
-    });
-    const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-    });
-    this._initPromise = Promise.all([getConfigPromise, getLoggedInPromise])
-        .then(() => {
-          this._initialized = true;
-        });
-    return this._initPromise;
-  }
-
-  getSuggestions(input) {
-    if (!this._initialized || !this._loggedIn) {
-      return Promise.resolve([]);
-    }
-
-    return this._apiCall(input)
-        .then(reviewers => (reviewers || []));
-  }
-
-  makeSuggestionItem(suggestion) {
-    if (suggestion.account) {
-      // Reviewer is an account suggestion from getChangeSuggestedReviewers.
-      return {
-        name: GrDisplayNameUtils.getAccountDisplayName(this._config,
-            suggestion.account),
-        value: suggestion,
-      };
-    }
-
-    if (suggestion.group) {
-      // Reviewer is a group suggestion from getChangeSuggestedReviewers.
-      return {
-        name: GrDisplayNameUtils.getGroupDisplayName(suggestion.group),
-        value: suggestion,
-      };
-    }
-
-    if (suggestion._account_id) {
-      // Reviewer is an account suggestion from getSuggestedAccounts.
-      return {
-        name: GrDisplayNameUtils.getAccountDisplayName(this._config,
-            suggestion),
-        value: {account: suggestion, count: 1},
-      };
-    }
-  }
-}
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
new file mode 100644
index 0000000..84569ee
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -0,0 +1,153 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+  getAccountDisplayName,
+  getGroupDisplayName,
+} from '../../utils/display-name-util';
+import {
+  RestApiService,
+  SuggestedReviewerAccountInfo,
+  SuggestedReviewerGroupInfo,
+  SuggestedReviewerInfo,
+} from '../../services/services/gr-rest-api/gr-rest-api';
+import {AccountInfo, NumericChangeId, ServerInfo} from '../../types/common';
+import {assertNever} from '../../utils/common-util';
+
+// TODO(TS): enum name doesn't follow typescript style guid rules
+// Rename it
+export enum SUGGESTIONS_PROVIDERS_USERS_TYPES {
+  REVIEWER = 'reviewers',
+  CC = 'ccs',
+  ANY = 'any',
+}
+
+export type Suggestion = SuggestedReviewerInfo | AccountInfo;
+
+export function isAccountSuggestions(s: Suggestion): s is AccountInfo {
+  return (s as AccountInfo)._account_id !== undefined;
+}
+
+export function isReviewerAccountSuggestion(
+  s: Suggestion
+): s is SuggestedReviewerAccountInfo {
+  return (s as SuggestedReviewerAccountInfo).account !== undefined;
+}
+
+export function isReviewerGroupSuggestion(
+  s: Suggestion
+): s is SuggestedReviewerGroupInfo {
+  return (s as SuggestedReviewerGroupInfo).group !== undefined;
+}
+
+type ApiCallCallback = (input: string) => Promise<Suggestion[]>;
+
+export interface SuggestionItem {
+  name: string;
+  value: SuggestedReviewerInfo;
+}
+
+export class GrReviewerSuggestionsProvider {
+  static create(
+    restApi: RestApiService,
+    changeNumber: NumericChangeId,
+    userType: SUGGESTIONS_PROVIDERS_USERS_TYPES
+  ) {
+    switch (userType) {
+      case SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
+        return new GrReviewerSuggestionsProvider(restApi, input =>
+          restApi.getChangeSuggestedReviewers(changeNumber, input)
+        );
+      case SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
+        return new GrReviewerSuggestionsProvider(restApi, input =>
+          restApi.getChangeSuggestedCCs(changeNumber, input)
+        );
+      case SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
+        return new GrReviewerSuggestionsProvider(restApi, input =>
+          restApi.getSuggestedAccounts(`cansee:${changeNumber} ${input}`)
+        );
+      default:
+        throw new Error(`Unknown users type: ${userType}`);
+    }
+  }
+
+  private _initPromise?: Promise<void>;
+
+  private _config?: ServerInfo;
+
+  private _loggedIn = false;
+
+  private _initialized = false;
+
+  private constructor(
+    private readonly _restAPI: RestApiService,
+    private readonly _apiCall: ApiCallCallback
+  ) {}
+
+  init() {
+    if (this._initPromise) {
+      return this._initPromise;
+    }
+    const getConfigPromise = this._restAPI.getConfig().then(cfg => {
+      this._config = cfg;
+    });
+    const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+    this._initPromise = Promise.all([
+      getConfigPromise,
+      getLoggedInPromise,
+    ]).then(() => {
+      this._initialized = true;
+    });
+    return this._initPromise;
+  }
+
+  getSuggestions(input: string): Promise<Suggestion[]> {
+    if (!this._initialized || !this._loggedIn) {
+      return Promise.resolve([]);
+    }
+
+    return this._apiCall(input).then(reviewers => reviewers || []);
+  }
+
+  makeSuggestionItem(suggestion: Suggestion): SuggestionItem {
+    if (isReviewerAccountSuggestion(suggestion)) {
+      // Reviewer is an account suggestion from getChangeSuggestedReviewers.
+      return {
+        name: getAccountDisplayName(this._config, suggestion.account),
+        value: suggestion,
+      };
+    }
+
+    if (isReviewerGroupSuggestion(suggestion)) {
+      // Reviewer is a group suggestion from getChangeSuggestedReviewers.
+      return {
+        name: getGroupDisplayName(suggestion.group),
+        value: suggestion,
+      };
+    }
+
+    if (isAccountSuggestions(suggestion)) {
+      // Reviewer is an account suggestion from getSuggestedAccounts.
+      return {
+        name: getAccountDisplayName(this._config, suggestion),
+        value: {account: suggestion, count: 1},
+      };
+    }
+    assertNever(suggestion, 'Received an incorrect suggestion');
+  }
+}
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html
deleted file mode 100644
index 8774d48..0000000
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html
+++ /dev/null
@@ -1,261 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-reviewer-suggestions-provider</title>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
-  </template>
-</test-fixture>
-
-<script type="module">
-import '../../test/common-test-setup.js';
-import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {GrDisplayNameUtils} from '../gr-display-name-utils/gr-display-name-utils.js';
-import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from './gr-reviewer-suggestions-provider.js';
-
-suite('GrReviewerSuggestionsProvider tests', () => {
-  let sandbox;
-  let _nextAccountId = 0;
-  const makeAccount = function(opt_status) {
-    const accountId = ++_nextAccountId;
-    return {
-      _account_id: accountId,
-      name: 'name ' + accountId,
-      email: 'email ' + accountId,
-      status: opt_status,
-    };
-  };
-  let _nextAccountId2 = 0;
-  const makeAccount2 = function(opt_status) {
-    const accountId2 = ++_nextAccountId2;
-    return {
-      _account_id: accountId2,
-      name: 'name ' + accountId2,
-      status: opt_status,
-    };
-  };
-
-  let owner;
-  let existingReviewer1;
-  let existingReviewer2;
-  let suggestion1;
-  let suggestion2;
-  let suggestion3;
-  let restAPI;
-  let provider;
-
-  let redundantSuggestion1;
-  let redundantSuggestion2;
-  let redundantSuggestion3;
-  let change;
-
-  setup(done => {
-    owner = makeAccount();
-    existingReviewer1 = makeAccount();
-    existingReviewer2 = makeAccount();
-    suggestion1 = {account: makeAccount()};
-    suggestion2 = {account: makeAccount()};
-    suggestion3 = {
-      group: {
-        id: 'suggested group id',
-        name: 'suggested group',
-      },
-    };
-
-    stub('gr-rest-api-interface', {
-      getLoggedIn() { return Promise.resolve(true); },
-      getConfig() { return Promise.resolve({}); },
-    });
-
-    restAPI = fixture('basic');
-    change = {
-      _number: 42,
-      owner,
-      reviewers: {
-        CC: [existingReviewer1],
-        REVIEWER: [existingReviewer2],
-      },
-    };
-    sandbox = sinon.sandbox.create();
-    return flush(done);
-  });
-
-  teardown(() => {
-    sandbox.restore();
-  });
-  suite('allowAnyUser set to false', () => {
-    setup(done => {
-      provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
-          SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
-      provider.init().then(done);
-    });
-    suite('stubbed values for _getReviewerSuggestions', () => {
-      setup(() => {
-        stub('gr-rest-api-interface', {
-          getChangeSuggestedReviewers() {
-            redundantSuggestion1 = {account: existingReviewer1};
-            redundantSuggestion2 = {account: existingReviewer2};
-            redundantSuggestion3 = {account: owner};
-            return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
-              redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
-          },
-        });
-      });
-
-      test('makeSuggestionItem formats account or group accordingly', () => {
-        let account = makeAccount();
-        const account3 = makeAccount2();
-        let suggestion = provider.makeSuggestionItem({account});
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '>',
-          value: {account},
-        });
-
-        const group = {name: 'test'};
-        suggestion = provider.makeSuggestionItem({group});
-        assert.deepEqual(suggestion, {
-          name: group.name + ' (group)',
-          value: {group},
-        });
-
-        suggestion = provider.makeSuggestionItem(account);
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '>',
-          value: {account, count: 1},
-        });
-
-        suggestion = provider.makeSuggestionItem({account: {}});
-        assert.deepEqual(suggestion, {
-          name: 'Anonymous',
-          value: {account: {}},
-        });
-
-        provider._config = {
-          user: {
-            anonymous_coward_name: 'Anonymous Coward Name',
-          },
-        };
-
-        suggestion = provider.makeSuggestionItem({account: {}});
-        assert.deepEqual(suggestion, {
-          name: 'Anonymous Coward Name',
-          value: {account: {}},
-        });
-
-        account = makeAccount('OOO');
-
-        suggestion = provider.makeSuggestionItem({account});
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account},
-        });
-
-        suggestion = provider.makeSuggestionItem(account);
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account, count: 1},
-        });
-
-        sandbox.stub(GrDisplayNameUtils, '_accountEmail',
-            () => '');
-
-        suggestion = provider.makeSuggestionItem(account3);
-        assert.deepEqual(suggestion, {
-          name: account3.name,
-          value: {account: account3, count: 1},
-        });
-      });
-
-      test('getSuggestions', done => {
-        provider.getSuggestions()
-            .then(reviewers => {
-              // Default is no filtering.
-              assert.equal(reviewers.length, 6);
-              assert.deepEqual(reviewers,
-                  [redundantSuggestion1, redundantSuggestion2,
-                    redundantSuggestion3, suggestion1,
-                    suggestion2, suggestion3]);
-            })
-            .then(done);
-      });
-
-      test('getSuggestions short circuits when logged out', () => {
-        // API call is already stubbed.
-        const xhrSpy = restAPI.getChangeSuggestedReviewers;
-        provider._loggedIn = false;
-        return provider.getSuggestions('').then(() => {
-          assert.isFalse(xhrSpy.called);
-          provider._loggedIn = true;
-          return provider.getSuggestions('').then(() => {
-            assert.isTrue(xhrSpy.called);
-          });
-        });
-      });
-    });
-
-    test('getChangeSuggestedReviewers is used', done => {
-      const suggestReviewerStub =
-          sandbox.stub(restAPI, 'getChangeSuggestedReviewers')
-              .returns(Promise.resolve([]));
-      const suggestAccountStub =
-          sandbox.stub(restAPI, 'getSuggestedAccounts')
-              .returns(Promise.resolve([]));
-
-      provider.getSuggestions('').then(() => {
-        assert.isTrue(suggestReviewerStub.calledOnce);
-        assert.isTrue(suggestReviewerStub.calledWith(42, ''));
-        assert.isFalse(suggestAccountStub.called);
-        done();
-      });
-    });
-  });
-
-  suite('allowAnyUser set to true', () => {
-    setup(done => {
-      provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
-          SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
-      provider.init().then(done);
-    });
-
-    test('getSuggestedAccounts is used', done => {
-      const suggestReviewerStub =
-          sandbox.stub(restAPI, 'getChangeSuggestedReviewers')
-              .returns(Promise.resolve([]));
-      const suggestAccountStub =
-          sandbox.stub(restAPI, 'getSuggestedAccounts')
-              .returns(Promise.resolve([]));
-
-      provider.getSuggestions('').then(() => {
-        assert.isFalse(suggestReviewerStub.called);
-        assert.isTrue(suggestAccountStub.calledOnce);
-        assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
-        done();
-      });
-    });
-  });
-});
-</script>
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
new file mode 100644
index 0000000..fe13c1c
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
@@ -0,0 +1,243 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import '../../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from './gr-reviewer-suggestions-provider.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+const basicFixture = fixtureFromTemplate(html`
+<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+`);
+
+suite('GrReviewerSuggestionsProvider tests', () => {
+  let _nextAccountId = 0;
+  const makeAccount = function(opt_status) {
+    const accountId = ++_nextAccountId;
+    return {
+      _account_id: accountId,
+      name: 'name ' + accountId,
+      email: 'email ' + accountId,
+      status: opt_status,
+    };
+  };
+  let _nextAccountId2 = 0;
+  const makeAccount2 = function(opt_status) {
+    const accountId2 = ++_nextAccountId2;
+    return {
+      _account_id: accountId2,
+      name: 'name ' + accountId2,
+      status: opt_status,
+    };
+  };
+
+  let owner;
+  let existingReviewer1;
+  let existingReviewer2;
+  let suggestion1;
+  let suggestion2;
+  let suggestion3;
+  let restAPI;
+  let provider;
+
+  let redundantSuggestion1;
+  let redundantSuggestion2;
+  let redundantSuggestion3;
+  let change;
+
+  setup(done => {
+    owner = makeAccount();
+    existingReviewer1 = makeAccount();
+    existingReviewer2 = makeAccount();
+    suggestion1 = {account: makeAccount()};
+    suggestion2 = {account: makeAccount()};
+    suggestion3 = {
+      group: {
+        id: 'suggested group id',
+        name: 'suggested group',
+      },
+    };
+
+    stub('gr-rest-api-interface', {
+      getLoggedIn() { return Promise.resolve(true); },
+      getConfig() { return Promise.resolve({}); },
+    });
+
+    restAPI = basicFixture.instantiate();
+    change = {
+      _number: 42,
+      owner,
+      reviewers: {
+        CC: [existingReviewer1],
+        REVIEWER: [existingReviewer2],
+      },
+    };
+
+    return flush(done);
+  });
+
+  suite('allowAnyUser set to false', () => {
+    setup(done => {
+      provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
+          SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
+      provider.init().then(done);
+    });
+    suite('stubbed values for _getReviewerSuggestions', () => {
+      setup(() => {
+        stub('gr-rest-api-interface', {
+          getChangeSuggestedReviewers() {
+            redundantSuggestion1 = {account: existingReviewer1};
+            redundantSuggestion2 = {account: existingReviewer2};
+            redundantSuggestion3 = {account: owner};
+            return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
+              redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
+          },
+        });
+      });
+
+      test('makeSuggestionItem formats account or group accordingly', () => {
+        let account = makeAccount();
+        const account3 = makeAccount2();
+        let suggestion = provider.makeSuggestionItem({account});
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '>',
+          value: {account},
+        });
+
+        const group = {name: 'test'};
+        suggestion = provider.makeSuggestionItem({group});
+        assert.deepEqual(suggestion, {
+          name: group.name + ' (group)',
+          value: {group},
+        });
+
+        suggestion = provider.makeSuggestionItem(account);
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '>',
+          value: {account, count: 1},
+        });
+
+        suggestion = provider.makeSuggestionItem({account: {}});
+        assert.deepEqual(suggestion, {
+          name: 'Anonymous',
+          value: {account: {}},
+        });
+
+        provider._config = {
+          user: {
+            anonymous_coward_name: 'Anonymous Coward Name',
+          },
+        };
+
+        suggestion = provider.makeSuggestionItem({account: {}});
+        assert.deepEqual(suggestion, {
+          name: 'Anonymous Coward Name',
+          value: {account: {}},
+        });
+
+        account = makeAccount('OOO');
+
+        suggestion = provider.makeSuggestionItem({account});
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '> (OOO)',
+          value: {account},
+        });
+
+        suggestion = provider.makeSuggestionItem(account);
+        assert.deepEqual(suggestion, {
+          name: account.name + ' <' + account.email + '> (OOO)',
+          value: {account, count: 1},
+        });
+
+        account3.email = undefined;
+
+        suggestion = provider.makeSuggestionItem(account3);
+        assert.deepEqual(suggestion, {
+          name: account3.name,
+          value: {account: account3, count: 1},
+        });
+      });
+
+      test('getSuggestions', done => {
+        provider.getSuggestions()
+            .then(reviewers => {
+              // Default is no filtering.
+              assert.equal(reviewers.length, 6);
+              assert.deepEqual(reviewers,
+                  [redundantSuggestion1, redundantSuggestion2,
+                    redundantSuggestion3, suggestion1,
+                    suggestion2, suggestion3]);
+            })
+            .then(done);
+      });
+
+      test('getSuggestions short circuits when logged out', () => {
+        // API call is already stubbed.
+        const xhrSpy = restAPI.getChangeSuggestedReviewers;
+        provider._loggedIn = false;
+        return provider.getSuggestions('').then(() => {
+          assert.isFalse(xhrSpy.called);
+          provider._loggedIn = true;
+          return provider.getSuggestions('').then(() => {
+            assert.isTrue(xhrSpy.called);
+          });
+        });
+      });
+    });
+
+    test('getChangeSuggestedReviewers is used', done => {
+      const suggestReviewerStub =
+          sinon.stub(restAPI, 'getChangeSuggestedReviewers')
+              .returns(Promise.resolve([]));
+      const suggestAccountStub =
+          sinon.stub(restAPI, 'getSuggestedAccounts')
+              .returns(Promise.resolve([]));
+
+      provider.getSuggestions('').then(() => {
+        assert.isTrue(suggestReviewerStub.calledOnce);
+        assert.isTrue(suggestReviewerStub.calledWith(42, ''));
+        assert.isFalse(suggestAccountStub.called);
+        done();
+      });
+    });
+  });
+
+  suite('allowAnyUser set to true', () => {
+    setup(done => {
+      provider = GrReviewerSuggestionsProvider.create(restAPI, change._number,
+          SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
+      provider.init().then(done);
+    });
+
+    test('getSuggestedAccounts is used', done => {
+      const suggestReviewerStub =
+          sinon.stub(restAPI, 'getChangeSuggestedReviewers')
+              .returns(Promise.resolve([]));
+      const suggestAccountStub =
+          sinon.stub(restAPI, 'getSuggestedAccounts')
+              .returns(Promise.resolve([]));
+
+      provider.getSuggestions('').then(() => {
+        assert.isFalse(suggestReviewerStub.called);
+        assert.isTrue(suggestAccountStub.calledOnce);
+        assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
+        done();
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/scripts/hiddenscroll.js b/polygerrit-ui/app/scripts/hiddenscroll.js
deleted file mode 100644
index a580b05..0000000
--- a/polygerrit-ui/app/scripts/hiddenscroll.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-let hiddenscroll = undefined;
-
-window.addEventListener('WebComponentsReady', () => {
-  const elem = document.createElement('div');
-  elem.setAttribute(
-      'style', 'width:100px;height:100px;overflow:scroll');
-  document.body.appendChild(elem);
-  hiddenscroll = elem.offsetWidth === elem.clientWidth;
-  elem.remove();
-});
-
-export function _setHiddenScroll(value) {
-  hiddenscroll = value;
-}
-
-export function getHiddenScroll() {
-  return hiddenscroll;
-}
diff --git a/polygerrit-ui/app/scripts/hiddenscroll.ts b/polygerrit-ui/app/scripts/hiddenscroll.ts
new file mode 100644
index 0000000..b4364be
--- /dev/null
+++ b/polygerrit-ui/app/scripts/hiddenscroll.ts
@@ -0,0 +1,34 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+let hiddenscroll: boolean | undefined = undefined;
+
+window.addEventListener('WebComponentsReady', () => {
+  const elem = document.createElement('div');
+  elem.setAttribute('style', 'width:100px;height:100px;overflow:scroll');
+  document.body.appendChild(elem);
+  hiddenscroll = elem.offsetWidth === elem.clientWidth;
+  elem.remove();
+});
+
+export function _setHiddenScroll(value: boolean) {
+  hiddenscroll = value;
+}
+
+export function getHiddenScroll() {
+  return hiddenscroll;
+}
diff --git a/polygerrit-ui/app/scripts/import-href.js b/polygerrit-ui/app/scripts/import-href.js
deleted file mode 100644
index 6ff40a5..0000000
--- a/polygerrit-ui/app/scripts/import-href.js
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// This file is a replacement for the
-// polymer-bridges/polymer/lib/utils/import-href.html file. The html
-// file contains code inside <script>...</script> and can't be imported
-// in es6 modules.
-
-// run a callback when HTMLImports are ready or immediately if
-// this api is not available.
-function whenImportsReady(cb) {
-  if (window.HTMLImports) {
-    HTMLImports.whenReady(cb);
-  } else {
-    cb();
-  }
-}
-
-/**
- * Convenience method for importing an HTML document imperatively.
- *
- * This method creates a new `<link rel="import">` element with
- * the provided URL and appends it to the document to start loading.
- * In the `onload` callback, the `import` property of the `link`
- * element will contain the imported document contents.
- *
- * @memberof Polymer
- * @param {string} href URL to document to load.
- * @param {?function(!Event):void=} onload Callback to notify when an import successfully
- *   loaded.
- * @param {?function(!ErrorEvent):void=} onerror Callback to notify when an import
- *   unsuccessfully loaded.
- * @param {boolean=} optAsync True if the import should be loaded `async`.
- *   Defaults to `false`.
- * @return {!HTMLLinkElement} The link element for the URL to be loaded.
- */
-export function importHref(href, onload, onerror, optAsync) {
-  let link = /** @type {HTMLLinkElement} */
-      (document.head.querySelector('link[href="' + href + '"][import-href]'));
-  if (!link) {
-    link = /** @type {HTMLLinkElement} */ (document.createElement('link'));
-    link.rel = 'import';
-    link.href = href;
-    link.setAttribute('import-href', '');
-  }
-  // always ensure link has `async` attribute if user specified one,
-  // even if it was previously not async. This is considered less confusing.
-  if (optAsync) {
-    link.setAttribute('async', '');
-  }
-  // NOTE: the link may now be in 3 states: (1) pending insertion,
-  // (2) inflight, (3) already loaded. In each case, we need to add
-  // event listeners to process callbacks.
-  const cleanup = function() {
-    link.removeEventListener('load', loadListener);
-    link.removeEventListener('error', errorListener);
-  };
-  const loadListener = function(event) {
-    cleanup();
-    // In case of a successful load, cache the load event on the link so
-    // that it can be used to short-circuit this method in the future when
-    // it is called with the same href param.
-    link.__dynamicImportLoaded = true;
-    if (onload) {
-      whenImportsReady(() => {
-        onload(event);
-      });
-    }
-  };
-  const errorListener = function(event) {
-    cleanup();
-    // In case of an error, remove the link from the document so that it
-    // will be automatically created again the next time `importHref` is
-    // called.
-    if (link.parentNode) {
-      link.parentNode.removeChild(link);
-    }
-    if (onerror) {
-      whenImportsReady(() => {
-        onerror(event);
-      });
-    }
-  };
-  link.addEventListener('load', loadListener);
-  link.addEventListener('error', errorListener);
-  if (link.parentNode == null) {
-    document.head.appendChild(link);
-    // if the link already loaded, dispatch a fake load event
-    // so that listeners are called and get a proper event argument.
-  } else if (link.__dynamicImportLoaded) {
-    link.dispatchEvent(new Event('load'));
-  }
-  return link;
-}
diff --git a/polygerrit-ui/app/scripts/import-href.ts b/polygerrit-ui/app/scripts/import-href.ts
new file mode 100644
index 0000000..3249c56
--- /dev/null
+++ b/polygerrit-ui/app/scripts/import-href.ts
@@ -0,0 +1,119 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// This file is a replacement for the
+// polymer-bridges/polymer/lib/utils/import-href.html file. The html
+// file contains code inside <script>...</script> and can't be imported
+// in es6 modules.
+
+interface ImportHrefElement extends HTMLLinkElement {
+  __dynamicImportLoaded?: boolean;
+}
+
+// run a callback when HTMLImports are ready or immediately if
+// this api is not available.
+function whenImportsReady(cb: () => void) {
+  const win = window as Window;
+  if (win.HTMLImports) {
+    win.HTMLImports.whenReady(cb);
+  } else {
+    cb();
+  }
+}
+
+/**
+ * Convenience method for importing an HTML document imperatively.
+ *
+ * This method creates a new `<link rel="import">` element with
+ * the provided URL and appends it to the document to start loading.
+ * In the `onload` callback, the `import` property of the `link`
+ * element will contain the imported document contents.
+ *
+ * @memberof Polymer
+ * @param href URL to document to load.
+ * @param onload Callback to notify when an import successfully
+ *   loaded.
+ * @param onerror Callback to notify when an import
+ *   unsuccessfully loaded.
+ * @param async True if the import should be loaded `async`.
+ *   Defaults to `false`.
+ * @return The link element for the URL to be loaded.
+ */
+export function importHref(
+  href: string,
+  onload: (e: Event) => void,
+  onerror: (e: Event) => void,
+  async = false
+): HTMLLinkElement {
+  let link = document.head.querySelector(
+    'link[href="' + href + '"][import-href]'
+  ) as ImportHrefElement;
+  if (!link) {
+    link = document.createElement('link') as ImportHrefElement;
+    link.setAttribute('rel', 'import');
+    link.setAttribute('href', href);
+    link.setAttribute('import-href', '');
+  }
+  // always ensure link has `async` attribute if user specified one,
+  // even if it was previously not async. This is considered less confusing.
+  if (async) {
+    link.setAttribute('async', '');
+  }
+  // NOTE: the link may now be in 3 states: (1) pending insertion,
+  // (2) inflight, (3) already loaded. In each case, we need to add
+  // event listeners to process callbacks.
+  const cleanup = function () {
+    link.removeEventListener('load', loadListener);
+    link.removeEventListener('error', errorListener);
+  };
+  const loadListener = function (event: Event) {
+    cleanup();
+    // In case of a successful load, cache the load event on the link so
+    // that it can be used to short-circuit this method in the future when
+    // it is called with the same href param.
+    link.__dynamicImportLoaded = true;
+    if (onload) {
+      whenImportsReady(() => {
+        onload(event);
+      });
+    }
+  };
+  const errorListener = function (event: Event) {
+    cleanup();
+    // In case of an error, remove the link from the document so that it
+    // will be automatically created again the next time `importHref` is
+    // called.
+    if (link.parentNode) {
+      link.parentNode.removeChild(link);
+    }
+    if (onerror) {
+      whenImportsReady(() => {
+        onerror(event);
+      });
+    }
+  };
+  link.addEventListener('load', loadListener);
+  link.addEventListener('error', errorListener);
+  if (link.parentNode === null) {
+    document.head.appendChild(link);
+    // if the link already loaded, dispatch a fake load event
+    // so that listeners are called and get a proper event argument.
+  } else if (link.__dynamicImportLoaded) {
+    link.dispatchEvent(new Event('load'));
+  }
+  return link;
+}
diff --git a/polygerrit-ui/app/scripts/rootElement.js b/polygerrit-ui/app/scripts/rootElement.js
deleted file mode 100644
index 4900ba2..0000000
--- a/polygerrit-ui/app/scripts/rootElement.js
+++ /dev/null
@@ -1,18 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-export const getRootElement = () => document.body;
diff --git a/polygerrit-ui/app/scripts/rootElement.ts b/polygerrit-ui/app/scripts/rootElement.ts
new file mode 100644
index 0000000..2217bf9
--- /dev/null
+++ b/polygerrit-ui/app/scripts/rootElement.ts
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Returns the root element of the dom: body.
+ */
+export const getRootElement = () => document.body;
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
index 46fa1ad..e4be858 100644
--- a/polygerrit-ui/app/scripts/util.js
+++ b/polygerrit-ui/app/scripts/util.js
@@ -15,28 +15,9 @@
  * limitations under the License.
  */
 
-function getPathFromNode(el) {
-  if (!el.tagName || el.tagName === 'GR-APP'
-      || el instanceof DocumentFragment
-      || el instanceof HTMLSlotElement) {
-    return '';
-  }
-  let path = el.tagName.toLowerCase();
-  if (el.id) path += `#${el.id}`;
-  if (el.className) path += `.${el.className.replace(/ /g, '.')}`;
-  return path;
-}
 // TODO (dmfilippov): Each function must be exported separately. According to
 // the code style guide, a namespacing is not allowed.
 export const util = {
-  parseDate(dateStr) {
-    // Timestamps are given in UTC and have the format
-    // "'yyyy-mm-dd hh:mm:ss.fffffffff'" where "'ffffffffff'" represents
-    // nanoseconds.
-    // Munge the date into an ISO 8061 format and parse that.
-    return new Date(dateStr.replace(' ', 'T') + 'Z');
-  },
-
   getCookie(name) {
     const key = name + '=';
     const cookies = document.cookie.split(';');
@@ -84,133 +65,4 @@
     };
     return wrappedPromise;
   },
-
-  /**
-   * Get computed style value.
-   *
-   * If ShadyCSS is provided, use ShadyCSS api.
-   * If `getComputedStyleValue` is provided on the elment, use it.
-   * Otherwise fallback to native method (in polymer 2).
-   *
-   */
-  getComputedStyleValue: (name, el) => {
-    let style;
-    if (window.ShadyCSS) {
-      style = ShadyCSS.getComputedStyleValue(el, name);
-    } else if (el.getComputedStyleValue) {
-      style = el.getComputedStyleValue(name);
-    } else {
-      style = getComputedStyle(el).getPropertyValue(name);
-    }
-    return style;
-  },
-
-  /**
-   * Query selector on a dom element.
-   *
-   * This is shadow DOM compatible, but only works when selector is within
-   * one shadow host, won't work if your selector is crossing
-   * multiple shadow hosts.
-   *
-   */
-  querySelector: (el, selector) => {
-    let nodes = [el];
-    let result = null;
-    while (nodes.length) {
-      const node = nodes.pop();
-
-      // Skip if it's an invalid node.
-      if (!node || !node.querySelector) continue;
-
-      // Try find it with native querySelector directly
-      result = node.querySelector(selector);
-
-      if (result) {
-        break;
-      }
-
-      // Add all nodes with shadowRoot and loop through
-      const allShadowNodes = [...node.querySelectorAll('*')]
-          .filter(child => !!child.shadowRoot)
-          .map(child => child.shadowRoot);
-      nodes = nodes.concat(allShadowNodes);
-
-      // Add shadowRoot of current node if has one
-      // as its not included in node.querySelectorAll('*')
-      if (node.shadowRoot) {
-        nodes.push(node.shadowRoot);
-      }
-    }
-    return result;
-  },
-
-  /**
-   * Query selector all dom elements matching with certain selector.
-   *
-   * This is shadow DOM compatible, but only works when selector is within
-   * one shadow host, won't work if your selector is crossing
-   * multiple shadow hosts.
-   *
-   * Note: this can be very expensive, only use when have to.
-   */
-  querySelectorAll: (el, selector) => {
-    let nodes = [el];
-    const results = new Set();
-    while (nodes.length) {
-      const node = nodes.pop();
-
-      if (!node || !node.querySelectorAll) continue;
-
-      // Try find all from regular children
-      [...node.querySelectorAll(selector)]
-          .forEach(el => results.add(el));
-
-      // Add all nodes with shadowRoot and loop through
-      const allShadowNodes = [...node.querySelectorAll('*')]
-          .filter(child => !!child.shadowRoot)
-          .map(child => child.shadowRoot);
-      nodes = nodes.concat(allShadowNodes);
-
-      // Add shadowRoot of current node if has one
-      // as its not included in node.querySelectorAll('*')
-      if (node.shadowRoot) {
-        nodes.push(node.shadowRoot);
-      }
-    }
-    return [...results];
-  },
-
-  /**
-   * Retrieves the dom path of the current event.
-   *
-   * If the event object contains a `path` property, then use it,
-   * otherwise, construct the dom path based on the event target.
-   *
-   * @param {!Event} e
-   * @return {string}
-   * @example
-   *
-   * domNode.onclick = e => {
-   *  getEventPath(e); // eg: div.class1>p#pid.class2
-   * }
-   */
-  getEventPath: e => {
-    if (!e) return '';
-
-    let path = e.path;
-    if (!path || !path.length) {
-      path = [];
-      let el = e.target;
-      while (el) {
-        path.push(el);
-        el = el.parentNode || el.host;
-      }
-    }
-
-    return path.reduce((domPath, curEl) => {
-      const pathForEl = getPathFromNode(curEl);
-      if (!pathForEl) return domPath;
-      return domPath ? `${pathForEl}>${domPath}` : pathForEl;
-    }, '');
-  },
 };
diff --git a/polygerrit-ui/app/scripts/util_test.html b/polygerrit-ui/app/scripts/util_test.html
deleted file mode 100644
index a3893d2..0000000
--- a/polygerrit-ui/app/scripts/util_test.html
+++ /dev/null
@@ -1,89 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2020 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<test-fixture id="basic">
-  <template>
-    <div id="test" class="a b c">
-      <a class="testBtn"></a>
-    </div>
-  </template>
-</test-fixture>
-
-<script type="module">
-  import '../test/common-test-setup.js';
-  import {util} from './util.js';
-  suite('util tests', () => {
-    suite('getEventPath', () => {
-      test('empty event', () => {
-        assert.equal(util.getEventPath(), '');
-        assert.equal(util.getEventPath(null), '');
-        assert.equal(util.getEventPath(undefined), '');
-        assert.equal(util.getEventPath({}), '');
-      });
-
-      test('event with fake path', () => {
-        assert.equal(util.getEventPath({path: []}), '');
-        assert.equal(util.getEventPath({path: [
-          {tagName: 'dd'},
-        ]}), 'dd');
-      });
-
-      test('event with fake complicated path', () => {
-        assert.equal(util.getEventPath({path: [
-          {tagName: 'dd', id: 'test', className: 'a b'},
-          {tagName: 'DIV', id: 'test2', className: 'a b c'},
-        ]}), 'div#test2.a.b.c>dd#test.a.b');
-      });
-
-      test('event with fake target', () => {
-        const fakeTargetParent2 = {
-          tagName: 'DIV', id: 'test2', className: 'a b c',
-        };
-        const fakeTargetParent1 = {
-          parentNode: fakeTargetParent2,
-          tagName: 'dd',
-          id: 'test',
-          className: 'a b',
-        };
-        const fakeTarget = {tagName: 'SPAN', parentNode: fakeTargetParent1};
-        assert.equal(
-            util.getEventPath({target: fakeTarget}),
-            'div#test2.a.b.c>dd#test.a.b>span'
-        );
-      });
-
-      test('event with real click', () => {
-        const element = fixture('basic');
-        const aLink = element.querySelector('a');
-        let path;
-        aLink.onclick = e => path = util.getEventPath(e);
-        MockInteractions.click(aLink);
-        assert.equal(
-            path,
-            'html>body>test-fixture#basic>div#test.a.b.c>a.testBtn'
-        );
-      });
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/services/app-context-init.js b/polygerrit-ui/app/services/app-context-init.js
deleted file mode 100644
index 1c32eee..0000000
--- a/polygerrit-ui/app/services/app-context-init.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {appContext} from './app-context.js';
-import {FlagsService} from './flags.js';
-
-const initializedServices = new Map();
-
-function getService(serviceName, serviceInit) {
-  if (!initializedServices[serviceName]) {
-    initializedServices[serviceName] = serviceInit();
-  }
-  return initializedServices[serviceName];
-}
-
-/**
- * The AppContext lazy initializator for all services
- */
-export function initAppContext() {
-  const registeredServices = {};
-  function addService(serviceName, serviceCreator) {
-    if (registeredServices[serviceName]) {
-      throw new Error(`Service ${serviceName} already registered.`);
-    }
-    registeredServices[serviceName] = {
-      get() {
-        return getService(serviceName, serviceCreator);
-      },
-    };
-  }
-
-  addService('flagsService', () => new FlagsService());
-
-  Object.defineProperties(appContext, registeredServices);
-}
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
new file mode 100644
index 0000000..b249d16
--- /dev/null
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {appContext, AppContext} from './app-context';
+import {FlagsServiceImplementation} from './flags/flags_impl';
+import {GrReporting} from './gr-reporting/gr-reporting_impl';
+import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
+import {Auth} from './gr-auth/gr-auth_impl';
+
+type ServiceName = keyof AppContext;
+type ServiceCreator<T> = () => T;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const initializedServices: Map<ServiceName, any> = new Map();
+
+function getService<K extends ServiceName>(
+  serviceName: K,
+  serviceCreator: ServiceCreator<AppContext[K]>
+): AppContext[K] {
+  if (!initializedServices.has(serviceName)) {
+    initializedServices.set(serviceName, serviceCreator());
+  }
+  return initializedServices.get(serviceName);
+}
+
+/**
+ * The AppContext lazy initializator for all services
+ */
+export function initAppContext() {
+  function populateAppContext(
+    serviceCreators: {[P in ServiceName]: ServiceCreator<AppContext[P]>}
+  ) {
+    const registeredServices = Object.keys(serviceCreators).reduce(
+      (registeredServices, key) => {
+        const serviceName = key as ServiceName;
+        const serviceCreator = serviceCreators[serviceName];
+        registeredServices[serviceName] = {
+          configurable: true, // Tests can mock properties
+          get() {
+            return getService(serviceName, serviceCreator);
+          },
+        };
+        return registeredServices;
+      },
+      {} as PropertyDescriptorMap
+    );
+    Object.defineProperties(appContext, registeredServices);
+  }
+
+  populateAppContext({
+    flagsService: () => new FlagsServiceImplementation(),
+    reportingService: () => new GrReporting(appContext.flagsService),
+    eventEmitter: () => new EventEmitter(),
+    authService: () => new Auth(appContext.eventEmitter),
+  });
+}
diff --git a/polygerrit-ui/app/services/app-context-init_test.html b/polygerrit-ui/app/services/app-context-init_test.html
deleted file mode 100644
index f5dc7d1..0000000
--- a/polygerrit-ui/app/services/app-context-init_test.html
+++ /dev/null
@@ -1,43 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2020 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<script type="module">
-  import '../test/common-test-setup.js';
-  import {appContext} from './app-context.js';
-  import {initAppContext} from './app-context-init.js';
-  suite('app context initializer tests', () => {
-    setup(() => {
-      initAppContext();
-    });
-
-    test('all services initialized and are singletons', () => {
-      Object.keys(appContext).forEach(serviceName => {
-        const service = appContext[serviceName];
-        assert.isNotNull(service);
-        const service2 = appContext[serviceName];
-        assert.strictEqual(service, service2);
-      });
-    });
-  });
-</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/services/app-context-init_test.js b/polygerrit-ui/app/services/app-context-init_test.js
new file mode 100644
index 0000000..9d22ec2
--- /dev/null
+++ b/polygerrit-ui/app/services/app-context-init_test.js
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {appContext} from './app-context.js';
+import {initAppContext} from './app-context-init.js';
+suite('app context initializer tests', () => {
+  setup(() => {
+    initAppContext();
+  });
+
+  test('all services initialized and are singletons', () => {
+    Object.keys(appContext).forEach(serviceName => {
+      const service = appContext[serviceName];
+      assert.isNotNull(service);
+      const service2 = appContext[serviceName];
+      assert.strictEqual(service, service2);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/services/app-context.js b/polygerrit-ui/app/services/app-context.js
deleted file mode 100644
index e10ced5..0000000
--- a/polygerrit-ui/app/services/app-context.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * The AppContext holds immortal singleton instances of services. It's a
- * convenient way to provide singletons that can be swapped out for testing.
- *
- * AppContext is initialized in ./app-context-init.js
- */
-export const appContext = {
-  flagsService: null,
-};
\ No newline at end of file
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
new file mode 100644
index 0000000..c08ee7a
--- /dev/null
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {FlagsService} from './flags/flags';
+import {EventEmitterService} from './gr-event-interface/gr-event-interface';
+import {ReportingService} from './gr-reporting/gr-reporting';
+import {AuthService} from './gr-auth/gr-auth';
+
+export interface AppContext {
+  flagsService: FlagsService;
+  reportingService: ReportingService;
+  eventEmitter: EventEmitterService;
+  authService: AuthService;
+}
+
+/**
+ * The AppContext holds immortal singleton instances of services. It's a
+ * convenient way to provide singletons that can be swapped out for testing.
+ *
+ * AppContext is initialized in ./app-context-init.js
+ *
+ * It is guaranteed that all fields in appContext are always initialized
+ * (except for shared gr-diff)
+ */
+export const appContext: AppContext = {} as AppContext;
diff --git a/polygerrit-ui/app/services/flags.js b/polygerrit-ui/app/services/flags.js
deleted file mode 100644
index 8f04f4a..0000000
--- a/polygerrit-ui/app/services/flags.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * Flags service.
- *
- * Provides all related methods / properties regarding on feature flags.
- */
-export class FlagsService {
-  constructor() {
-    // stores all enabled experiments
-    this._experiments = new Set();
-    this._loadExperiments();
-  }
-
-  /**
-   * @param {string} experimentId
-   * @returns {boolean}
-   */
-  isEnabled(experimentId) {
-    return this._experiments.has(experimentId);
-  }
-
-  _loadExperiments() {
-    this._experiments = new Set(window.ENABLED_EXPERIMENTS);
-  }
-
-  /**
-   * @returns {string[]} array of all enabled experiments.
-   */
-  get enabledExperiments() {
-    return [...this._experiments];
-  }
-}
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
new file mode 100644
index 0000000..b45710b
--- /dev/null
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface FlagsService {
+  isEnabled(experimentId: string): boolean;
+  enabledExperiments: string[];
+}
+
+/**
+ * @desc Experiment ids used in Gerrit.
+ */
+export enum KnownExperimentId {
+  PATCHSET_COMMENTS = 'UiFeature__patchset_comments',
+  PATCHSET_CHOICE_FOR_COMMENT_LINKS = 'UiFeature__patchset_choice_for_comment_links',
+}
diff --git a/polygerrit-ui/app/services/flags/flags_impl.ts b/polygerrit-ui/app/services/flags/flags_impl.ts
new file mode 100644
index 0000000..835eb56
--- /dev/null
+++ b/polygerrit-ui/app/services/flags/flags_impl.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {FlagsService} from './flags';
+
+declare global {
+  interface Window {
+    ENABLED_EXPERIMENTS: string[];
+  }
+}
+
+/**
+ * Flags service.
+ *
+ * Provides all related methods / properties regarding on feature flags.
+ */
+export class FlagsServiceImplementation implements FlagsService {
+  private readonly _experiments: Set<string>;
+
+  constructor() {
+    // stores all enabled experiments
+    this._experiments = this._loadExperiments();
+  }
+
+  /**
+   * @param {string} experimentId
+   * @returns {boolean}
+   */
+  isEnabled(experimentId: string): boolean {
+    return this._experiments.has(experimentId);
+  }
+
+  _loadExperiments(): Set<string> {
+    return new Set(window.ENABLED_EXPERIMENTS);
+  }
+
+  /**
+   * @returns {string[]} array of all enabled experiments.
+   */
+  get enabledExperiments() {
+    return [...this._experiments];
+  }
+}
diff --git a/polygerrit-ui/app/services/flags/flags_test.js b/polygerrit-ui/app/services/flags/flags_test.js
new file mode 100644
index 0000000..33508af
--- /dev/null
+++ b/polygerrit-ui/app/services/flags/flags_test.js
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {FlagsServiceImplementation} from './flags_impl.js';
+
+suite('flags tests', () => {
+  let originalEnabledExperiments;
+  let flags;
+
+  suiteSetup(() => {
+    originalEnabledExperiments = window.ENABLED_EXPERIMENTS;
+    window.ENABLED_EXPERIMENTS = ['a', 'a'];
+    flags = new FlagsServiceImplementation();
+  });
+
+  suiteTeardown(() => {
+    window.ENABLED_EXPERIMENTS = originalEnabledExperiments;
+  });
+
+  test('isEnabled', () => {
+    assert.equal(flags.isEnabled('a'), true);
+    assert.equal(flags.isEnabled('random'), false);
+  });
+
+  test('enabledExperiments', () => {
+    assert.deepEqual(flags.enabledExperiments, ['a']);
+  });
+});
+
diff --git a/polygerrit-ui/app/services/flags_test.html b/polygerrit-ui/app/services/flags_test.html
deleted file mode 100644
index 51efb0d..0000000
--- a/polygerrit-ui/app/services/flags_test.html
+++ /dev/null
@@ -1,44 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2020 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/components/wct-browser-legacy/browser.js"></script>
-
-<script>
-  window.ENABLED_EXPERIMENTS = ['a', 'a'];
-</script>
-
-<script type="module">
-  import '../test/common-test-setup.js';
-  import {FlagsService} from './flags.js';
-  suite('flags tests', () => {
-    const flags = new FlagsService();
-
-    test('isEnabled', () => {
-      assert.equal(flags.isEnabled('a'), true);
-      assert.equal(flags.isEnabled('random'), false);
-    });
-
-    test('enabledExperiments', () => {
-      assert.deepEqual(flags.enabledExperiments, ['a']);
-    });
-  });
-</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth.ts b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
new file mode 100644
index 0000000..f7fdadf
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export enum AuthType {
+  XSRF_TOKEN = 'xsrf_token',
+  ACCESS_TOKEN = 'access_token',
+}
+
+export enum AuthStatus {
+  UNDETERMINED = 0,
+  AUTHED = 1,
+  NOT_AUTHED = 2,
+  ERROR = 3,
+}
+
+export interface Token {
+  access_token?: string;
+  expires_at?: string;
+}
+
+export type GetTokenCallback = () => Promise<Token | null>;
+
+export interface DefaultAuthOptions {
+  credentials: RequestCredentials;
+}
+
+export interface AuthRequestInit extends RequestInit {
+  // RequestInit define headers as HeadersInit, i.e.
+  // Headers | string[][] | Record<string, string>
+  // Auth class supports only Headers in options
+  headers?: Headers;
+}
+
+export interface AuthService {
+  baseUrl: string;
+  isAuthed: boolean;
+
+  /**
+   * Returns if user is authed or not.
+   */
+  authCheck(): Promise<boolean>;
+
+  clearCache(): void;
+
+  /**
+   * Enable cross-domain authentication using OAuth access token.
+   */
+  setup(getToken: GetTokenCallback, defaultOptions: DefaultAuthOptions): void;
+
+  /**
+   * Perform network fetch with authentication.
+   */
+  fetch(url: string, opt_options?: AuthRequestInit): Promise<Response>;
+}
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
new file mode 100644
index 0000000..b5330e7
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -0,0 +1,291 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {getBaseUrl} from '../../utils/url-util';
+import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
+import {
+  AuthRequestInit,
+  AuthService,
+  AuthStatus,
+  AuthType,
+  DefaultAuthOptions,
+  GetTokenCallback,
+  Token,
+} from './gr-auth';
+
+const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
+const MAX_GET_TOKEN_RETRIES = 2;
+
+interface ValidToken extends Token {
+  access_token: string;
+  expires_at: string;
+}
+
+interface AuthRequestInitWithHeaders extends AuthRequestInit {
+  // RequestInit define headers as optional property with a type
+  // Headers | string[][] | Record<string, string>
+  // In Auth class headers property is always set and has type Headers
+  headers: Headers;
+}
+
+/**
+ * Auth class.
+ */
+export class Auth implements AuthService {
+  // TODO(dmfilippov): Remove Type and Status properties, expose AuthType and
+  // AuthStatus to API
+  static TYPE = {
+    XSRF_TOKEN: AuthType.XSRF_TOKEN,
+    ACCESS_TOKEN: AuthType.ACCESS_TOKEN,
+  };
+
+  static STATUS = {
+    UNDETERMINED: AuthStatus.UNDETERMINED,
+    AUTHED: AuthStatus.AUTHED,
+    NOT_AUTHED: AuthStatus.NOT_AUTHED,
+    ERROR: AuthStatus.ERROR,
+  };
+
+  static CREDS_EXPIRED_MSG = 'Credentials expired.';
+
+  private _authCheckPromise?: Promise<Response>;
+
+  private _last_auth_check_time: number = Date.now();
+
+  private _status = AuthStatus.UNDETERMINED;
+
+  private _retriesLeft = MAX_GET_TOKEN_RETRIES;
+
+  private _cachedTokenPromise: Promise<Token | null> | null = null;
+
+  private _type?: AuthType;
+
+  private _defaultOptions: AuthRequestInit = {};
+
+  private _getToken: GetTokenCallback;
+
+  public eventEmitter: EventEmitterService;
+
+  constructor(eventEmitter: EventEmitterService) {
+    this._getToken = () => Promise.resolve(this._cachedTokenPromise);
+    this.eventEmitter = eventEmitter;
+  }
+
+  get baseUrl() {
+    return getBaseUrl();
+  }
+
+  /**
+   * Returns if user is authed or not.
+   */
+  authCheck(): Promise<boolean> {
+    if (
+      !this._authCheckPromise ||
+      Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS
+    ) {
+      // Refetch after last check expired
+      this._authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
+      this._last_auth_check_time = Date.now();
+    }
+
+    return this._authCheckPromise
+      .then(res => {
+        // auth-check will return 204 if authed
+        // treat the rest as unauthed
+        if (res.status === 204) {
+          this._setStatus(Auth.STATUS.AUTHED);
+          return true;
+        } else {
+          this._setStatus(Auth.STATUS.NOT_AUTHED);
+          return false;
+        }
+      })
+      .catch(() => {
+        this._setStatus(AuthStatus.ERROR);
+        // Reset _authCheckPromise to avoid caching the failed promise
+        this._authCheckPromise = undefined;
+        return false;
+      });
+  }
+
+  clearCache() {
+    this._authCheckPromise = undefined;
+  }
+
+  private _setStatus(status: AuthStatus) {
+    if (this._status === status) return;
+
+    if (this._status === AuthStatus.AUTHED) {
+      this.eventEmitter.emit('auth-error', {
+        message: Auth.CREDS_EXPIRED_MSG,
+        action: 'Refresh credentials',
+      });
+    }
+    this._status = status;
+  }
+
+  get status() {
+    return this._status;
+  }
+
+  get isAuthed() {
+    return this._status === Auth.STATUS.AUTHED;
+  }
+
+  /**
+   * Enable cross-domain authentication using OAuth access token.
+   */
+  setup(getToken: GetTokenCallback, defaultOptions: DefaultAuthOptions) {
+    this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+    if (getToken) {
+      this._type = AuthType.ACCESS_TOKEN;
+      this._cachedTokenPromise = null;
+      this._getToken = getToken;
+    }
+    this._defaultOptions = {};
+    if (defaultOptions) {
+      this._defaultOptions.credentials = defaultOptions.credentials;
+    }
+  }
+
+  /**
+   * Perform network fetch with authentication.
+   */
+  fetch(url: string, opt_options?: AuthRequestInit): Promise<Response> {
+    const options: AuthRequestInitWithHeaders = {
+      headers: new Headers(),
+      ...this._defaultOptions,
+      ...opt_options,
+    };
+    if (this._type === AuthType.ACCESS_TOKEN) {
+      return this._getAccessToken().then(accessToken =>
+        this._fetchWithAccessToken(url, options, accessToken)
+      );
+    } else {
+      return this._fetchWithXsrfToken(url, options);
+    }
+  }
+
+  private _getCookie(name: string): string {
+    const key = name + '=';
+    let result = '';
+    document.cookie.split(';').some(c => {
+      c = c.trim();
+      if (c.startsWith(key)) {
+        result = c.substring(key.length);
+        return true;
+      }
+      return false;
+    });
+    return result;
+  }
+
+  private _isTokenValid(token: Token | null): token is ValidToken {
+    if (!token) {
+      return false;
+    }
+    if (!token.access_token || !token.expires_at) {
+      return false;
+    }
+
+    const expiration = new Date(parseInt(token.expires_at, 10) * 1000);
+    if (Date.now() >= expiration.getTime()) {
+      return false;
+    }
+
+    return true;
+  }
+
+  private _fetchWithXsrfToken(
+    url: string,
+    options: AuthRequestInitWithHeaders
+  ): Promise<Response> {
+    if (options.method && options.method !== 'GET') {
+      const token = this._getCookie('XSRF_TOKEN');
+      if (token) {
+        options.headers.append('X-Gerrit-Auth', token);
+      }
+    }
+    options.credentials = 'same-origin';
+    return fetch(url, options);
+  }
+
+  private _getAccessToken(): Promise<string | null> {
+    if (!this._cachedTokenPromise) {
+      this._cachedTokenPromise = this._getToken();
+    }
+    return this._cachedTokenPromise.then(token => {
+      if (this._isTokenValid(token)) {
+        this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+        return token.access_token;
+      }
+      if (this._retriesLeft > 0) {
+        this._retriesLeft--;
+        this._cachedTokenPromise = null;
+        return this._getAccessToken();
+      }
+      // Fall back to anonymous access.
+      return null;
+    });
+  }
+
+  private _fetchWithAccessToken(
+    url: string,
+    options: AuthRequestInitWithHeaders,
+    accessToken: string | null
+  ): Promise<Response> {
+    const params = [];
+
+    if (accessToken) {
+      params.push(`access_token=${accessToken}`);
+      const baseUrl = this.baseUrl;
+      const pathname = baseUrl
+        ? url.substring(url.indexOf(baseUrl) + baseUrl.length)
+        : url;
+      if (!pathname.startsWith('/a/')) {
+        url = url.replace(pathname, '/a' + pathname);
+      }
+    }
+
+    const method = options.method || 'GET';
+    let contentType = options.headers.get('Content-Type');
+
+    // For all requests with body, ensure json content type.
+    if (!contentType && options.body) {
+      contentType = 'application/json';
+    }
+
+    if (method !== 'GET') {
+      options.method = 'POST';
+      params.push(`$m=${method}`);
+      // If a request is not GET, and does not have a body, ensure text/plain
+      // content type.
+      if (!contentType) {
+        contentType = 'text/plain';
+      }
+    }
+
+    if (contentType) {
+      options.headers.set('Content-Type', 'text/plain');
+      params.push(`$ct=${encodeURIComponent(contentType)}`);
+    }
+
+    if (params.length) {
+      url = url + (url.indexOf('?') === -1 ? '?' : '&') + params.join('&');
+    }
+    return fetch(url, options);
+  }
+}
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.js b/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
new file mode 100644
index 0000000..432518c
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
@@ -0,0 +1,378 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {Auth} from './gr-auth_impl.js';
+import {appContext} from '../app-context.js';
+import {stubBaseUrl} from '../../test/test-utils.js';
+
+suite('gr-auth', () => {
+  let auth;
+
+  setup(() => {
+    auth = appContext.authService;
+  });
+
+  suite('Auth class methods', () => {
+    let fakeFetch;
+    setup(() => {
+      auth = new Auth(appContext.eventEmitter);
+      fakeFetch = sinon.stub(window, 'fetch');
+    });
+
+    test('auth-check returns 403', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        done();
+      });
+    });
+
+    test('auth-check returns 204', done => {
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      auth.authCheck().then(authed => {
+        assert.isTrue(authed);
+        assert.equal(auth.status, Auth.STATUS.AUTHED);
+        done();
+      });
+    });
+
+    test('auth-check returns 502', done => {
+      fakeFetch.returns(Promise.resolve({status: 502}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        done();
+      });
+    });
+
+    test('auth-check failed', done => {
+      fakeFetch.returns(Promise.reject(new Error('random error')));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.ERROR);
+        done();
+      });
+    });
+  });
+
+  suite('cache and events behavior', () => {
+    let fakeFetch;
+    let clock;
+    setup(() => {
+      auth = new Auth(appContext.eventEmitter);
+      clock = sinon.useFakeTimers();
+      fakeFetch = sinon.stub(window, 'fetch');
+    });
+
+    test('cache auth-check result', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        auth.authCheck().then(authed2 => {
+          assert.isFalse(authed);
+          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+          done();
+        });
+      });
+    });
+
+    test('clearCache should refetch auth-check result', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        auth.clearCache();
+        auth.authCheck().then(authed2 => {
+          assert.isTrue(authed2);
+          assert.equal(auth.status, Auth.STATUS.AUTHED);
+          done();
+        });
+      });
+    });
+
+    test('cache expired on auth-check after certain time', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        clock.tick(1000 * 10000);
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        auth.authCheck().then(authed2 => {
+          assert.isTrue(authed2);
+          assert.equal(auth.status, Auth.STATUS.AUTHED);
+          done();
+        });
+      });
+    });
+
+    test('no cache if auth-check failed', done => {
+      fakeFetch.returns(Promise.reject(new Error('random error')));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.ERROR);
+        assert.equal(fakeFetch.callCount, 1);
+        auth.authCheck().then(() => {
+          assert.equal(fakeFetch.callCount, 2);
+          done();
+        });
+      });
+    });
+
+    test('fire event when switch from authed to unauthed', done => {
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      auth.authCheck().then(authed => {
+        assert.isTrue(authed);
+        assert.equal(auth.status, Auth.STATUS.AUTHED);
+        clock.tick(1000 * 10000);
+        fakeFetch.returns(Promise.resolve({status: 403}));
+        const emitStub = sinon.stub();
+        appContext.eventEmitter.emit = emitStub;
+        auth.authCheck().then(authed2 => {
+          assert.isFalse(authed2);
+          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+          assert.isTrue(emitStub.called);
+          done();
+        });
+      });
+    });
+
+    test('fire event when switch from authed to error', done => {
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      auth.authCheck().then(authed => {
+        assert.isTrue(authed);
+        assert.equal(auth.status, Auth.STATUS.AUTHED);
+        clock.tick(1000 * 10000);
+        fakeFetch.returns(Promise.reject(new Error('random error')));
+        const emitStub = sinon.stub();
+        appContext.eventEmitter.emit = emitStub;
+        auth.authCheck().then(authed2 => {
+          assert.isFalse(authed2);
+          assert.isTrue(emitStub.called);
+          assert.equal(auth.status, Auth.STATUS.ERROR);
+          done();
+        });
+      });
+    });
+
+    test('no event from non-authed to other status', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        clock.tick(1000 * 10000);
+        fakeFetch.returns(Promise.resolve({status: 204}));
+        const emitStub = sinon.stub();
+        appContext.eventEmitter.emit = emitStub;
+        auth.authCheck().then(authed2 => {
+          assert.isTrue(authed2);
+          assert.isFalse(emitStub.called);
+          assert.equal(auth.status, Auth.STATUS.AUTHED);
+          done();
+        });
+      });
+    });
+
+    test('no event from non-authed to other status', done => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      auth.authCheck().then(authed => {
+        assert.isFalse(authed);
+        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+        clock.tick(1000 * 10000);
+        fakeFetch.returns(Promise.reject(new Error('random error')));
+        const emitStub = sinon.stub();
+        appContext.eventEmitter.emit = emitStub;
+        auth.authCheck().then(authed2 => {
+          assert.isFalse(authed2);
+          assert.isFalse(emitStub.called);
+          assert.equal(auth.status, Auth.STATUS.ERROR);
+          done();
+        });
+      });
+    });
+  });
+
+  suite('default (xsrf token header)', () => {
+    setup(() => {
+      sinon.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
+    });
+
+    test('GET', done => {
+      auth.fetch('/url', {bar: 'bar'}).then(() => {
+        const [url, options] = fetch.lastCall.args;
+        assert.equal(url, '/url');
+        assert.equal(options.credentials, 'same-origin');
+        done();
+      });
+    });
+
+    test('POST', done => {
+      sinon.stub(auth, '_getCookie')
+          .withArgs('XSRF_TOKEN')
+          .returns('foobar');
+      auth.fetch('/url', {method: 'POST'}).then(() => {
+        const [url, options] = fetch.lastCall.args;
+        assert.equal(url, '/url');
+        assert.equal(options.credentials, 'same-origin');
+        assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
+        done();
+      });
+    });
+  });
+
+  suite('cors (access token)', () => {
+    setup(() => {
+      sinon.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
+    });
+
+    let getToken;
+
+    const makeToken = opt_accessToken => {
+      return {
+        access_token: opt_accessToken || 'zbaz',
+        expires_at: new Date(Date.now() + 10e8).getTime(),
+      };
+    };
+
+    setup(() => {
+      getToken = sinon.stub();
+      getToken.returns(Promise.resolve(makeToken()));
+      auth.setup(getToken);
+    });
+
+    test('base url support', done => {
+      const baseUrl = 'http://foo';
+      stubBaseUrl(baseUrl);
+      auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => {
+        const [url] = fetch.lastCall.args;
+        assert.equal(url, 'http://foo/a/url?access_token=zbaz');
+        done();
+      });
+    });
+
+    test('fetch not signed in', done => {
+      getToken.returns(Promise.resolve());
+      auth.fetch('/url', {bar: 'bar'}).then(() => {
+        const [url, options] = fetch.lastCall.args;
+        assert.equal(url, '/url');
+        assert.equal(options.bar, 'bar');
+        assert.equal(Object.keys(options.headers).length, 0);
+        done();
+      });
+    });
+
+    test('fetch signed in', done => {
+      auth.fetch('/url', {bar: 'bar'}).then(() => {
+        const [url, options] = fetch.lastCall.args;
+        assert.equal(url, '/a/url?access_token=zbaz');
+        assert.equal(options.bar, 'bar');
+        done();
+      });
+    });
+
+    test('getToken calls are cached', done => {
+      Promise.all([
+        auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => {
+        assert.equal(getToken.callCount, 1);
+        done();
+      });
+    });
+
+    test('getToken refreshes token', done => {
+      sinon.stub(auth, '_isTokenValid');
+      auth._isTokenValid
+          .onFirstCall().returns(true)
+          .onSecondCall()
+          .returns(false)
+          .onThirdCall()
+          .returns(true);
+      auth.fetch('/url-one')
+          .then(() => {
+            getToken.returns(Promise.resolve(makeToken('bzzbb')));
+            return auth.fetch('/url-two');
+          })
+          .then(() => {
+            const [[firstUrl], [secondUrl]] = fetch.args;
+            assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
+            assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
+            done();
+          });
+    });
+
+    test('signed in token error falls back to anonymous', done => {
+      getToken.returns(Promise.resolve('rubbish'));
+      auth.fetch('/url', {bar: 'bar'}).then(() => {
+        const [url, options] = fetch.lastCall.args;
+        assert.equal(url, '/url');
+        assert.equal(options.bar, 'bar');
+        done();
+      });
+    });
+
+    test('_isTokenValid', () => {
+      assert.isFalse(auth._isTokenValid());
+      assert.isFalse(auth._isTokenValid({}));
+      assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
+      assert.isFalse(auth._isTokenValid({
+        access_token: 'foo',
+        expires_at: Date.now()/1000 - 1,
+      }));
+      assert.isTrue(auth._isTokenValid({
+        access_token: 'foo',
+        expires_at: Date.now()/1000 + 1,
+      }));
+    });
+
+    test('HTTP PUT with content type', done => {
+      const originalOptions = {
+        method: 'PUT',
+        headers: new Headers({'Content-Type': 'mail/pigeon'}),
+      };
+      auth.fetch('/url', originalOptions).then(() => {
+        assert.isTrue(getToken.called);
+        const [url, options] = fetch.lastCall.args;
+        assert.include(url, '$ct=mail%2Fpigeon');
+        assert.include(url, '$m=PUT');
+        assert.include(url, 'access_token=zbaz');
+        assert.equal(options.method, 'POST');
+        assert.equal(options.headers.get('Content-Type'), 'text/plain');
+        done();
+      });
+    });
+
+    test('HTTP PUT without content type', done => {
+      const originalOptions = {
+        method: 'PUT',
+      };
+      auth.fetch('/url', originalOptions).then(() => {
+        assert.isTrue(getToken.called);
+        const [url, options] = fetch.lastCall.args;
+        assert.include(url, '$ct=text%2Fplain');
+        assert.include(url, '$m=PUT');
+        assert.include(url, 'access_token=zbaz');
+        assert.equal(options.method, 'POST');
+        assert.equal(options.headers.get('Content-Type'), 'text/plain');
+        done();
+      });
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
new file mode 100644
index 0000000..d59a022
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
@@ -0,0 +1,67 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export type EventCallback = (...args: any) => void;
+export type UnsubscribeMethod = () => void;
+
+export interface EventEmitterService {
+  /**
+   * Register an event listener to an event.
+   */
+  addListener(eventName: string, cb: EventCallback): UnsubscribeMethod;
+
+  /**
+   * Alias for addListener.
+   */
+  on(eventName: string, cb: EventCallback): UnsubscribeMethod;
+
+  /**
+   * Attach event handler only once. Automatically removed.
+   */
+  once(eventName: string, cb: EventCallback): UnsubscribeMethod;
+
+  /**
+   * De-register an event listener to an event.
+   */
+  removeListener(eventName: string, cb: EventCallback): void;
+
+  /**
+   * Alias to removeListener
+   */
+  off(eventName: string, cb: EventCallback): void;
+
+  /**
+   * Synchronously calls each of the listeners registered for
+   * the event named eventName, in the order they were registered,
+   * passing the supplied detail to each.
+   *
+   * @returns true if the event had listeners, false otherwise.
+   */
+  emit(eventName: string, detail: any): boolean;
+
+  /**
+   * Alias to emit.
+   */
+  dispatch(eventName: string, detail: any): boolean;
+
+  /**
+   * Remove listeners for a specific event or all.
+   *
+   * @param eventName if not provided, will remove all
+   */
+  removeAllListeners(eventName: string): void;
+}
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
new file mode 100644
index 0000000..72afbda
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
@@ -0,0 +1,130 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+  EventCallback,
+  EventEmitterService,
+  UnsubscribeMethod,
+} from './gr-event-interface';
+/**
+ * An lite implementation of
+ * https://nodejs.org/api/events.html#events_class_eventemitter.
+ *
+ * This is unrelated to the native DOM events, you should use it when you want
+ * to enable EventEmitter interface on any class.
+ *
+ * @example
+ *
+ * class YourClass extends EventEmitter {
+ *   // now all instance of YourClass will have this EventEmitter interface
+ * }
+ *
+ */
+export class EventEmitter implements EventEmitterService {
+  private _listenersMap = new Map<string, EventCallback[]>();
+
+  /**
+   * Register an event listener to an event.
+   */
+  addListener(eventName: string, cb: EventCallback): UnsubscribeMethod {
+    if (!eventName || !cb) {
+      console.warn('A valid eventname and callback is required!');
+      return () => {};
+    }
+
+    const listeners = this._listenersMap.get(eventName) || [];
+    listeners.push(cb);
+    this._listenersMap.set(eventName, listeners);
+
+    return () => {
+      this.off(eventName, cb);
+    };
+  }
+
+  /**
+   * Alias for addListener.
+   */
+  on(eventName: string, cb: EventCallback): UnsubscribeMethod {
+    return this.addListener(eventName, cb);
+  }
+
+  /**
+   * Attach event handler only once. Automatically removed.
+   */
+  once(eventName: string, cb: EventCallback): UnsubscribeMethod {
+    const onceWrapper = (...args: any[]) => {
+      cb(...args);
+      this.off(eventName, onceWrapper);
+    };
+    return this.on(eventName, onceWrapper);
+  }
+
+  /**
+   * De-register an event listener to an event.
+   */
+  removeListener(eventName: string, cb: EventCallback): void {
+    let listeners = this._listenersMap.get(eventName) || [];
+    listeners = listeners.filter(listener => listener !== cb);
+    this._listenersMap.set(eventName, listeners);
+  }
+
+  /**
+   * Alias to removeListener
+   */
+  off(eventName: string, cb: EventCallback): void {
+    this.removeListener(eventName, cb);
+  }
+
+  /**
+   * Synchronously calls each of the listeners registered for
+   * the event named eventName, in the order they were registered,
+   * passing the supplied detail to each.
+   *
+   * @returns true if the event had listeners, false otherwise.
+   */
+  emit(eventName: string, detail: any): boolean {
+    const listeners = this._listenersMap.get(eventName) || [];
+    for (const listener of listeners) {
+      try {
+        listener(detail);
+      } catch (e) {
+        console.error(e);
+      }
+    }
+    return listeners.length !== 0;
+  }
+
+  /**
+   * Alias to emit.
+   */
+  dispatch(eventName: string, detail: any): boolean {
+    return this.emit(eventName, detail);
+  }
+
+  /**
+   * Remove listeners for a specific event or all.
+   *
+   * @param eventName if not provided, will remove all
+   */
+  removeAllListeners(eventName: string): void {
+    if (eventName) {
+      this._listenersMap.set(eventName, []);
+    } else {
+      this._listenersMap = new Map();
+    }
+  }
+}
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
new file mode 100644
index 0000000..6d0ab7b
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
@@ -0,0 +1,133 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import '../../elements/shared/gr-js-api-interface/gr-js-api-interface.js';
+import {EventEmitter} from './gr-event-interface_impl.js';
+import {_testOnly_initGerritPluginApi} from '../../elements/shared/gr-js-api-interface/gr-gerrit.js';
+
+const basicFixture = fixtureFromElement('gr-js-api-interface');
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-event-interface tests', () => {
+  setup(() => {
+
+  });
+
+  suite('test on Gerrit', () => {
+    setup(() => {
+      basicFixture.instantiate();
+      pluginApi.removeAllListeners();
+    });
+
+    test('communicate between plugin and Gerrit', done => {
+      const eventName = 'test-plugin-event';
+      let p;
+      pluginApi.on(eventName, e => {
+        assert.equal(e.value, 'test');
+        assert.equal(e.plugin, p);
+        done();
+      });
+      pluginApi.install(plugin => {
+        p = plugin;
+        pluginApi.emit(eventName, {value: 'test', plugin});
+      }, '0.1',
+      'http://test.com/plugins/testplugin/static/test.js');
+    });
+
+    test('listen on events from core', done => {
+      const eventName = 'test-plugin-event';
+      pluginApi.on(eventName, e => {
+        assert.equal(e.value, 'test');
+        done();
+      });
+
+      pluginApi.emit(eventName, {value: 'test'});
+    });
+
+    test('communicate across plugins', done => {
+      const eventName = 'test-plugin-event';
+      pluginApi.install(plugin => {
+        pluginApi.on(eventName, e => {
+          assert.equal(e.plugin.getPluginName(), 'testB');
+          done();
+        });
+      }, '0.1',
+      'http://test.com/plugins/testA/static/testA.js');
+
+      pluginApi.install(plugin => {
+        pluginApi.emit(eventName, {plugin});
+      }, '0.1',
+      'http://test.com/plugins/testB/static/testB.js');
+    });
+  });
+
+  suite('test on interfaces', () => {
+    let testObj;
+
+    class TestClass extends EventEmitter {
+    }
+
+    setup(() => {
+      testObj = new TestClass();
+    });
+
+    test('on', () => {
+      const cbStub = sinon.stub();
+      testObj.on('test', cbStub);
+      testObj.emit('test');
+      testObj.emit('test');
+      assert.isTrue(cbStub.calledTwice);
+    });
+
+    test('once', () => {
+      const cbStub = sinon.stub();
+      testObj.once('test', cbStub);
+      testObj.emit('test');
+      testObj.emit('test');
+      assert.isTrue(cbStub.calledOnce);
+    });
+
+    test('unsubscribe', () => {
+      const cbStub = sinon.stub();
+      const unsubscribe = testObj.on('test', cbStub);
+      testObj.emit('test');
+      unsubscribe();
+      testObj.emit('test');
+      assert.isTrue(cbStub.calledOnce);
+    });
+
+    test('off', () => {
+      const cbStub = sinon.stub();
+      testObj.on('test', cbStub);
+      testObj.emit('test');
+      testObj.off('test', cbStub);
+      testObj.emit('test');
+      assert.isTrue(cbStub.calledOnce);
+    });
+
+    test('removeAllListeners', () => {
+      const cbStub = sinon.stub();
+      testObj.on('test', cbStub);
+      testObj.removeAllListeners('test');
+      testObj.emit('test');
+      assert.isTrue(cbStub.notCalled);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
new file mode 100644
index 0000000..8c70df3
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -0,0 +1,97 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export type EventValue = string | number | {error: Error};
+
+// TODO(dmfilippov): TS-fix-any use more specific type instead if possible
+export type EventDetails = any;
+
+export interface Timer {
+  reset(): this;
+  end(): this;
+  withMaximum(maximum: number): this;
+}
+
+export interface ReportingService {
+  reporter(
+    type: string,
+    category: string,
+    eventName: string,
+    eventValue?: EventValue,
+    eventDetails?: EventDetails,
+    opt_noLog?: boolean
+  ): void;
+
+  appStarted(): void;
+  onVisibilityChange(): void;
+  beforeLocationChanged(): void;
+  locationChanged(page: string): void;
+  dashboardDisplayed(): void;
+  changeDisplayed(): void;
+  changeFullyLoaded(): void;
+  diffViewDisplayed(): void;
+  diffViewFullyLoaded(): void;
+  diffViewContentDisplayed(): void;
+  fileListDisplayed(): void;
+  reportExtension(name: string): void;
+  pluginLoaded(name: string): void;
+  pluginsLoaded(pluginsList?: string[]): void;
+  /**
+   * Reset named timer.
+   */
+  time(name: string): void;
+  /**
+   * Finish named timer and report it to server.
+   */
+  timeEnd(name: string, eventDetails?: EventDetails): void;
+  /**
+   * Reports just line timeEnd, but additionally reports an average given a
+   * denominator and a separate reporiting name for the average.
+   *
+   * @param name Timing name.
+   * @param averageName Average timing name.
+   * @param denominator Number by which to divide the total to
+   *     compute the average.
+   */
+  timeEndWithAverage(
+    name: string,
+    averageName: string,
+    denominator: number
+  ): void;
+  /**
+   * Get a timer object to for reporing a user timing. The start time will be
+   * the time that the object has been created, and the end time will be the
+   * time that the "end" method is called on the object.
+   */
+  getTimer(name: string): Timer;
+  /**
+   * Log timing information for an RPC.
+   *
+   * @param anonymizedUrl The URL of the RPC with tokens obfuscated.
+   * @param elapsed The time elapsed of the RPC.
+   */
+  reportRpcTiming(anonymizedUrl: string, elapsed: number): void;
+  reportLifeCycle(eventName: string, details: EventDetails): void;
+  reportInteraction(eventName: string, details: EventDetails): void;
+  /**
+   * A draft interaction was started. Update the time-betweeen-draft-actions
+   * timer.
+   */
+  recordDraftInteraction(): void;
+  reportErrorDialog(message: string): void;
+  setRepoName(repoName: string): void;
+}
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
new file mode 100644
index 0000000..111820b
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -0,0 +1,820 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {AppContext} from '../app-context';
+import {FlagsService} from '../flags/flags';
+import {
+  EventDetails,
+  EventValue,
+  ReportingService,
+  Timer,
+} from './gr-reporting';
+import {hasOwnProperty} from '../../utils/common-util';
+
+// Latency reporting constants.
+
+const TIMING = {
+  TYPE: 'timing-report',
+  CATEGORY: {
+    UI_LATENCY: 'UI Latency',
+    RPC: 'RPC Timing',
+  },
+  EVENT: {
+    APP_STARTED: 'App Started',
+  },
+};
+
+const LIFECYCLE = {
+  TYPE: 'lifecycle',
+  CATEGORY: {
+    DEFAULT: 'Default',
+    EXTENSION_DETECTED: 'Extension detected',
+    PLUGINS_INSTALLED: 'Plugins installed',
+    VISIBILITY: 'Visibility',
+  },
+};
+
+const INTERACTION = {
+  TYPE: 'interaction',
+  CATEGORY: {
+    DEFAULT: 'Default',
+    VISIBILITY: 'Visibility',
+  },
+};
+
+const NAVIGATION = {
+  TYPE: 'nav-report',
+  CATEGORY: {
+    LOCATION_CHANGED: 'Location Changed',
+  },
+  EVENT: {
+    PAGE: 'Page',
+  },
+};
+
+const ERROR = {
+  TYPE: 'error',
+  CATEGORY: {
+    EXCEPTION: 'exception',
+    ERROR_DIALOG: 'Error Dialog',
+  },
+};
+
+const TIMER = {
+  CHANGE_DISPLAYED: 'ChangeDisplayed',
+  CHANGE_LOAD_FULL: 'ChangeFullyLoaded',
+  DASHBOARD_DISPLAYED: 'DashboardDisplayed',
+  DIFF_VIEW_CONTENT_DISPLAYED: 'DiffViewOnlyContent',
+  DIFF_VIEW_DISPLAYED: 'DiffViewDisplayed',
+  DIFF_VIEW_LOAD_FULL: 'DiffViewFullyLoaded',
+  FILE_LIST_DISPLAYED: 'FileListDisplayed',
+  PLUGINS_LOADED: 'PluginsLoaded',
+  STARTUP_CHANGE_DISPLAYED: 'StartupChangeDisplayed',
+  STARTUP_CHANGE_LOAD_FULL: 'StartupChangeFullyLoaded',
+  STARTUP_DASHBOARD_DISPLAYED: 'StartupDashboardDisplayed',
+  STARTUP_DIFF_VIEW_CONTENT_DISPLAYED: 'StartupDiffViewOnlyContent',
+  STARTUP_DIFF_VIEW_DISPLAYED: 'StartupDiffViewDisplayed',
+  STARTUP_DIFF_VIEW_LOAD_FULL: 'StartupDiffViewFullyLoaded',
+  STARTUP_FILE_LIST_DISPLAYED: 'StartupFileListDisplayed',
+  WEB_COMPONENTS_READY: 'WebComponentsReady',
+  METRICS_PLUGIN_LOADED: 'MetricsPluginLoaded',
+};
+
+const STARTUP_TIMERS = {
+  [TIMER.PLUGINS_LOADED]: 0,
+  [TIMER.METRICS_PLUGIN_LOADED]: 0,
+  [TIMER.STARTUP_CHANGE_DISPLAYED]: 0,
+  [TIMER.STARTUP_CHANGE_LOAD_FULL]: 0,
+  [TIMER.STARTUP_DASHBOARD_DISPLAYED]: 0,
+  [TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED]: 0,
+  [TIMER.STARTUP_DIFF_VIEW_DISPLAYED]: 0,
+  [TIMER.STARTUP_DIFF_VIEW_LOAD_FULL]: 0,
+  [TIMER.STARTUP_FILE_LIST_DISPLAYED]: 0,
+  [TIMING.EVENT.APP_STARTED]: 0,
+  // WebComponentsReady timer is triggered from gr-router.
+  [TIMER.WEB_COMPONENTS_READY]: 0,
+};
+
+const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
+const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
+const SLOW_RPC_THRESHOLD = 500;
+
+export function initErrorReporter(appContext: AppContext) {
+  const reportingService = appContext.reportingService;
+  // TODO(dmfilippo): TS-fix-any oldOnError - define correct type
+  const onError = function (
+    oldOnError: Function,
+    msg: string,
+    url: string,
+    line: number,
+    column: number,
+    error: Error
+  ) {
+    if (oldOnError) {
+      oldOnError(msg, url, line, column, error);
+    }
+    if (error) {
+      line = line || (error as any).lineNumber;
+      column = column || (error as any).columnNumber;
+      let shortenedErrorStack = msg;
+      if (error.stack) {
+        const errorStackLines = error.stack.split('\n');
+        shortenedErrorStack = errorStackLines
+          .slice(0, Math.min(3, errorStackLines.length))
+          .join('\n');
+      }
+      msg = shortenedErrorStack || error.toString();
+    }
+    const payload = {
+      url,
+      line,
+      column,
+      error,
+    };
+    reportingService.reporter(
+      ERROR.TYPE,
+      ERROR.CATEGORY.EXCEPTION,
+      msg,
+      payload
+    );
+    return true;
+  };
+  // TODO(dmfilippov): TS-fix-any unclear what is context
+  const catchErrors = function (opt_context?: any) {
+    const context = opt_context || window;
+    context.onerror = onError.bind(null, context.onerror);
+    context.addEventListener(
+      'unhandledrejection',
+      (e: PromiseRejectionEvent) => {
+        const msg = e.reason.message;
+        const payload = {
+          error: e.reason,
+        };
+        reportingService.reporter(
+          ERROR.TYPE,
+          ERROR.CATEGORY.EXCEPTION,
+          msg,
+          payload
+        );
+      }
+    );
+  };
+
+  catchErrors();
+
+  // for testing
+  return {catchErrors};
+}
+
+export function initPerformanceReporter(appContext: AppContext) {
+  const reportingService = appContext.reportingService;
+  // PerformanceObserver interface is a browser API.
+  if (window.PerformanceObserver) {
+    const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || [];
+    // Safari doesn't support longtask yet
+    if (supportedEntryTypes.includes('longtask')) {
+      const catchLongJsTasks = new PerformanceObserver(list => {
+        for (const task of list.getEntries()) {
+          // We are interested in longtask longer than 200 ms (default is 50 ms)
+          if (task.duration > 200) {
+            reportingService.reporter(
+              TIMING.TYPE,
+              TIMING.CATEGORY.UI_LATENCY,
+              `Task ${task.name}`,
+              Math.round(task.duration),
+              {},
+              false
+            );
+          }
+        }
+      });
+      catchLongJsTasks.observe({entryTypes: ['longtask']});
+    }
+  }
+}
+
+export function initVisibilityReporter(appContext: AppContext) {
+  const reportingService = appContext.reportingService;
+  document.addEventListener('visibilitychange', () => {
+    reportingService.onVisibilityChange();
+  });
+}
+
+// Calculates the time of Gerrit being in a background tab. When Gerrit reports
+// a pageLoad metric it’s attached to its details for latency analysis.
+// It resets on locationChange.
+class HiddenDurationTimer {
+  public accHiddenDurationMs = 0;
+
+  public lastVisibleTimestampMs: number | null = null;
+
+  constructor() {
+    this.reset();
+  }
+
+  reset() {
+    this.accHiddenDurationMs = 0;
+    this.lastVisibleTimestampMs = 0;
+  }
+
+  onVisibilityChange() {
+    if (document.visibilityState === 'hidden') {
+      this.lastVisibleTimestampMs = now();
+    } else if (document.visibilityState === 'visible') {
+      if (this.lastVisibleTimestampMs !== null) {
+        this.accHiddenDurationMs += now() - this.lastVisibleTimestampMs;
+        // Set to null for guarding against two 'visible' events in a row.
+        this.lastVisibleTimestampMs = null;
+      }
+    }
+  }
+
+  get hiddenDurationMs() {
+    if (
+      document.visibilityState === 'hidden' &&
+      this.lastVisibleTimestampMs !== null
+    ) {
+      return this.accHiddenDurationMs + now() - this.lastVisibleTimestampMs;
+    }
+    return this.accHiddenDurationMs;
+  }
+}
+
+export function now() {
+  return Math.round(window.performance.now());
+}
+
+type PeformanceTimingEventName = keyof Omit<PerformanceTiming, 'toJSON'>;
+
+interface EventInfo {
+  type: string;
+  category: string;
+  name: string;
+  value?: EventValue;
+  eventStart: number;
+  eventDetails?: string;
+  repoName?: string;
+  inBackgroundTab?: boolean;
+  enabledExperiments?: string;
+}
+
+interface PageLoadDetails {
+  rpcList: SlowRpcCall[];
+  hiddenDurationMs: number;
+  screenSize?: {width: number; height: number};
+  viewport?: {width: number; height: number};
+  usedJSHeapSizeMb?: number;
+}
+
+interface SlowRpcCall {
+  anonymizedUrl: string;
+  elapsed: number;
+}
+
+type PendingReportInfo = [EventInfo, boolean | undefined];
+
+export class GrReporting implements ReportingService {
+  private readonly _flagsService: FlagsService;
+
+  private readonly _baselines = STARTUP_TIMERS;
+
+  private _reportRepoName: string | undefined;
+
+  private _timers: {timeBetweenDraftActions: Timer | null} = {
+    timeBetweenDraftActions: null,
+  };
+
+  private _pending: PendingReportInfo[] = [];
+
+  private _slowRpcList: SlowRpcCall[] = [];
+
+  public readonly hiddenDurationTimer = new HiddenDurationTimer();
+
+  constructor(flagsService: FlagsService) {
+    this._flagsService = flagsService;
+  }
+
+  private get performanceTiming() {
+    return window.performance.timing;
+  }
+
+  private get slowRpcSnapshot() {
+    return (this._slowRpcList || []).slice();
+  }
+
+  private _arePluginsLoaded() {
+    return (
+      this._baselines && !hasOwnProperty(this._baselines, TIMER.PLUGINS_LOADED)
+    );
+  }
+
+  private _isMetricsPluginLoaded() {
+    return (
+      this._arePluginsLoaded() ||
+      (this._baselines &&
+        !hasOwnProperty(this._baselines, TIMER.METRICS_PLUGIN_LOADED))
+    );
+  }
+
+  /**
+   * Reporter reports events. Events will be queued if metrics plugin is not
+   * yet installed.
+   *
+   * @param {string} type
+   * @param {string} category
+   * @param {string} eventName
+   * @param {string|number} eventValue
+   * @param {Object} eventDetails
+   * @param {boolean|undefined} opt_noLog If true, the event will not be
+   *     logged to the JS console.
+   */
+  reporter(
+    type: string,
+    category: string,
+    eventName: string,
+    eventValue?: EventValue,
+    eventDetails?: EventDetails,
+    opt_noLog?: boolean
+  ) {
+    const eventInfo = this._createEventInfo(
+      type,
+      category,
+      eventName,
+      eventValue,
+      eventDetails
+    );
+    if (type === ERROR.TYPE && category === ERROR.CATEGORY.EXCEPTION) {
+      console.error((eventValue && (eventValue as any).error) || eventName);
+    }
+
+    // We report events immediately when metrics plugin is loaded
+    if (this._isMetricsPluginLoaded() && !this._pending.length) {
+      this._reportEvent(eventInfo, opt_noLog);
+    } else {
+      // We cache until metrics plugin is loaded
+      this._pending.push([eventInfo, opt_noLog]);
+      if (this._isMetricsPluginLoaded()) {
+        this._pending.forEach(([eventInfo, opt_noLog]) => {
+          this._reportEvent(eventInfo, opt_noLog);
+        });
+        this._pending = [];
+      }
+    }
+  }
+
+  private _reportEvent(eventInfo: EventInfo, opt_noLog?: boolean) {
+    const {type, value, name} = eventInfo;
+    document.dispatchEvent(new CustomEvent(type, {detail: eventInfo}));
+    if (opt_noLog) {
+      return;
+    }
+    if (type !== ERROR.TYPE) {
+      if (value !== undefined) {
+        console.info(`Reporting: ${name}: ${value}`);
+      } else {
+        console.info(`Reporting: ${name}`);
+      }
+    }
+  }
+
+  private _createEventInfo(
+    type: string,
+    category: string,
+    name: string,
+    value?: EventValue,
+    eventDetails?: EventDetails
+  ): EventInfo {
+    const eventInfo: EventInfo = {
+      type,
+      category,
+      name,
+      value,
+      eventStart: now(),
+    };
+
+    if (
+      typeof eventDetails === 'object' &&
+      Object.entries(eventDetails).length !== 0
+    ) {
+      eventInfo.eventDetails = JSON.stringify(eventDetails);
+    }
+
+    if (this._reportRepoName) {
+      eventInfo.repoName = this._reportRepoName;
+    }
+
+    const isInBackgroundTab = document.visibilityState === 'hidden';
+    if (isInBackgroundTab !== undefined) {
+      eventInfo.inBackgroundTab = isInBackgroundTab;
+    }
+
+    if (this._flagsService.enabledExperiments.length) {
+      eventInfo.enabledExperiments = JSON.stringify(
+        this._flagsService.enabledExperiments
+      );
+    }
+
+    return eventInfo;
+  }
+
+  /**
+   * User-perceived app start time, should be reported when the app is ready.
+   */
+  appStarted() {
+    this.timeEnd(TIMING.EVENT.APP_STARTED);
+    this._reportNavResTimes();
+  }
+
+  onVisibilityChange() {
+    this.hiddenDurationTimer.onVisibilityChange();
+    const eventName = `Visibility changed to ${document.visibilityState}`;
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.VISIBILITY,
+      eventName,
+      undefined,
+      {
+        hiddenDurationMs: this.hiddenDurationTimer.hiddenDurationMs,
+      },
+      true
+    );
+  }
+
+  /**
+   * Browser's navigation and resource timings
+   */
+  private _reportNavResTimes() {
+    const perfEvents = Object.keys(this.performanceTiming.toJSON());
+    perfEvents.forEach(eventName =>
+      this._reportPerformanceTiming(eventName as PeformanceTimingEventName)
+    );
+  }
+
+  private _reportPerformanceTiming(
+    eventName: PeformanceTimingEventName,
+    eventDetails?: EventDetails
+  ) {
+    const eventTiming = this.performanceTiming[eventName];
+    if (eventTiming > 0) {
+      const elapsedTime = eventTiming - this.performanceTiming.navigationStart;
+      // NavResTime - Navigation and resource timings.
+      this.reporter(
+        TIMING.TYPE,
+        TIMING.CATEGORY.UI_LATENCY,
+        `NavResTime - ${eventName}`,
+        elapsedTime,
+        eventDetails,
+        true
+      );
+    }
+  }
+
+  beforeLocationChanged() {
+    for (const prop of Object.keys(this._baselines)) {
+      delete this._baselines[prop];
+    }
+    this.time(TIMER.CHANGE_DISPLAYED);
+    this.time(TIMER.CHANGE_LOAD_FULL);
+    this.time(TIMER.DASHBOARD_DISPLAYED);
+    this.time(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
+    this.time(TIMER.DIFF_VIEW_DISPLAYED);
+    this.time(TIMER.DIFF_VIEW_LOAD_FULL);
+    this.time(TIMER.FILE_LIST_DISPLAYED);
+    this._reportRepoName = undefined;
+    // reset slow rpc list since here start page loads which report these rpcs
+    this._slowRpcList = [];
+    this.hiddenDurationTimer.reset();
+  }
+
+  locationChanged(page: string) {
+    this.reporter(
+      NAVIGATION.TYPE,
+      NAVIGATION.CATEGORY.LOCATION_CHANGED,
+      NAVIGATION.EVENT.PAGE,
+      page
+    );
+  }
+
+  dashboardDisplayed() {
+    if (hasOwnProperty(this._baselines, TIMER.STARTUP_DASHBOARD_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_DASHBOARD_DISPLAYED, this._pageLoadDetails());
+    } else {
+      this.timeEnd(TIMER.DASHBOARD_DISPLAYED, this._pageLoadDetails());
+    }
+  }
+
+  changeDisplayed() {
+    if (hasOwnProperty(this._baselines, TIMER.STARTUP_CHANGE_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_CHANGE_DISPLAYED, this._pageLoadDetails());
+    } else {
+      this.timeEnd(TIMER.CHANGE_DISPLAYED, this._pageLoadDetails());
+    }
+  }
+
+  changeFullyLoaded() {
+    if (hasOwnProperty(this._baselines, TIMER.STARTUP_CHANGE_LOAD_FULL)) {
+      this.timeEnd(TIMER.STARTUP_CHANGE_LOAD_FULL);
+    } else {
+      this.timeEnd(TIMER.CHANGE_LOAD_FULL);
+    }
+  }
+
+  diffViewDisplayed() {
+    if (hasOwnProperty(this._baselines, TIMER.STARTUP_DIFF_VIEW_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
+    } else {
+      this.timeEnd(TIMER.DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
+    }
+  }
+
+  diffViewFullyLoaded() {
+    if (hasOwnProperty(this._baselines, TIMER.STARTUP_DIFF_VIEW_LOAD_FULL)) {
+      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL);
+    } else {
+      this.timeEnd(TIMER.DIFF_VIEW_LOAD_FULL);
+    }
+  }
+
+  diffViewContentDisplayed() {
+    if (
+      hasOwnProperty(this._baselines, TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED)
+    ) {
+      this.timeEnd(TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED);
+    } else {
+      this.timeEnd(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
+    }
+  }
+
+  fileListDisplayed() {
+    if (hasOwnProperty(this._baselines, TIMER.STARTUP_FILE_LIST_DISPLAYED)) {
+      this.timeEnd(TIMER.STARTUP_FILE_LIST_DISPLAYED);
+    } else {
+      this.timeEnd(TIMER.FILE_LIST_DISPLAYED);
+    }
+  }
+
+  private _pageLoadDetails(): PageLoadDetails {
+    const details: PageLoadDetails = {
+      rpcList: this.slowRpcSnapshot,
+      hiddenDurationMs: this.hiddenDurationTimer.accHiddenDurationMs,
+    };
+
+    if (window.screen) {
+      details.screenSize = {
+        width: window.screen.width,
+        height: window.screen.height,
+      };
+    }
+
+    if (document && document.documentElement) {
+      details.viewport = {
+        width: document.documentElement.clientWidth,
+        height: document.documentElement.clientHeight,
+      };
+    }
+
+    if (window.performance && window.performance.memory) {
+      const toMb = (bytes: number) =>
+        Math.round((bytes / (1024 * 1024)) * 100) / 100;
+      details.usedJSHeapSizeMb = toMb(window.performance.memory.usedJSHeapSize);
+    }
+
+    details.hiddenDurationMs = this.hiddenDurationTimer.hiddenDurationMs;
+    return details;
+  }
+
+  reportExtension(name: string) {
+    this.reporter(LIFECYCLE.TYPE, LIFECYCLE.CATEGORY.EXTENSION_DETECTED, name);
+  }
+
+  pluginLoaded(name: string) {
+    if (name.startsWith('metrics-')) {
+      this.timeEnd(TIMER.METRICS_PLUGIN_LOADED);
+    }
+  }
+
+  pluginsLoaded(pluginsList?: string[]) {
+    this.timeEnd(TIMER.PLUGINS_LOADED);
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.PLUGINS_INSTALLED,
+      LIFECYCLE.CATEGORY.PLUGINS_INSTALLED,
+      undefined,
+      {pluginsList: pluginsList || []},
+      true
+    );
+  }
+
+  /**
+   * Reset named timer.
+   */
+  time(name: string) {
+    this._baselines[name] = now();
+    window.performance.mark(`${name}-start`);
+  }
+
+  /**
+   * Finish named timer and report it to server.
+   */
+  timeEnd(name: string, eventDetails?: EventDetails) {
+    if (!hasOwnProperty(this._baselines, name)) {
+      return;
+    }
+    const baseTime = this._baselines[name];
+    delete this._baselines[name];
+    this._reportTiming(name, now() - baseTime, eventDetails);
+
+    // Finalize the interval. Either from a registered start mark or
+    // the navigation start time (if baseTime is 0).
+    if (baseTime !== 0) {
+      window.performance.measure(name, `${name}-start`);
+    } else {
+      // Microsft Edge does not handle the 2nd param correctly
+      // (if undefined).
+      window.performance.measure(name);
+    }
+  }
+
+  /**
+   * Reports just line timeEnd, but additionally reports an average given a
+   * denominator and a separate reporiting name for the average.
+   *
+   * @param name Timing name.
+   * @param averageName Average timing name.
+   * @param denominator Number by which to divide the total to
+   *     compute the average.
+   */
+  timeEndWithAverage(name: string, averageName: string, denominator: number) {
+    if (!hasOwnProperty(this._baselines, name)) {
+      return;
+    }
+    const baseTime = this._baselines[name];
+    this.timeEnd(name);
+
+    // Guard against division by zero.
+    if (!denominator) {
+      return;
+    }
+    const time = now() - baseTime;
+    this._reportTiming(averageName, time / denominator);
+  }
+
+  /**
+   * Send a timing report with an arbitrary time value.
+   *
+   * @param name Timing name.
+   * @param time The time to report as an integer of milliseconds.
+   * @param eventDetails non sensitive details
+   */
+  private _reportTiming(
+    name: string,
+    time: number,
+    eventDetails?: EventDetails
+  ) {
+    this.reporter(
+      TIMING.TYPE,
+      TIMING.CATEGORY.UI_LATENCY,
+      name,
+      time,
+      eventDetails
+    );
+  }
+
+  /**
+   * Get a timer object to for reporing a user timing. The start time will be
+   * the time that the object has been created, and the end time will be the
+   * time that the "end" method is called on the object.
+   */
+  getTimer(name: string): Timer {
+    let called = false;
+    let start: number;
+    let max: number | null = null;
+
+    const timer: Timer = {
+      // Clear the timer and reset the start time.
+      reset: () => {
+        called = false;
+        start = now();
+        return timer;
+      },
+
+      // Stop the timer and report the intervening time.
+      end: () => {
+        if (called) {
+          throw new Error(`Timer for "${name}" already ended.`);
+        }
+        called = true;
+        const time = now() - start;
+
+        // If a maximum is specified and the time exceeds it, do not report.
+        if (max && time > max) {
+          return timer;
+        }
+
+        this._reportTiming(name, time);
+        return timer;
+      },
+
+      // Set a maximum reportable time. If a maximum is set and the timer is
+      // ended after the specified amount of time, the value is not reported.
+      withMaximum(maximum) {
+        max = maximum;
+        return timer;
+      },
+    };
+
+    // The timer is initialized to its creation time.
+    return timer.reset();
+  }
+
+  /**
+   * Log timing information for an RPC.
+   *
+   * @param anonymizedUrl The URL of the RPC with tokens obfuscated.
+   * @param elapsed The time elapsed of the RPC.
+   */
+  reportRpcTiming(anonymizedUrl: string, elapsed: number) {
+    this.reporter(
+      TIMING.TYPE,
+      TIMING.CATEGORY.RPC,
+      'RPC-' + anonymizedUrl,
+      elapsed,
+      {},
+      true
+    );
+    if (elapsed >= SLOW_RPC_THRESHOLD) {
+      this._slowRpcList.push({anonymizedUrl, elapsed});
+    }
+  }
+
+  reportLifeCycle(eventName: string, details: EventDetails) {
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.DEFAULT,
+      eventName,
+      undefined,
+      details,
+      true
+    );
+  }
+
+  reportInteraction(eventName: string, details: EventDetails) {
+    this.reporter(
+      INTERACTION.TYPE,
+      INTERACTION.CATEGORY.DEFAULT,
+      eventName,
+      undefined,
+      details,
+      true
+    );
+  }
+
+  /**
+   * A draft interaction was started. Update the time-betweeen-draft-actions
+   * timer.
+   */
+  recordDraftInteraction() {
+    // If there is no timer defined, then this is the first interaction.
+    // Set up the timer so that it's ready to record the intervening time when
+    // called again.
+    const timer = this._timers.timeBetweenDraftActions;
+    if (!timer) {
+      // Create a timer with a maximum length.
+      this._timers.timeBetweenDraftActions = this.getTimer(
+        DRAFT_ACTION_TIMER
+      ).withMaximum(DRAFT_ACTION_TIMER_MAX);
+      return;
+    }
+
+    // Mark the time and reinitialize the timer.
+    timer.end().reset();
+  }
+
+  reportErrorDialog(message: string) {
+    this.reporter(
+      ERROR.TYPE,
+      ERROR.CATEGORY.ERROR_DIALOG,
+      'ErrorDialog: ' + message,
+      {error: new Error(message)}
+    );
+  }
+
+  setRepoName(repoName: string) {
+    this._reportRepoName = repoName;
+  }
+}
+
+export const DEFAULT_STARTUP_TIMERS = {...STARTUP_TIMERS};
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
new file mode 100644
index 0000000..1ef2483
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export const grReportingMock = {
+  appStarted: () => {},
+  beforeLocationChanged: () => {},
+  changeDisplayed: () => {},
+  changeFullyLoaded: () => {},
+  dashboardDisplayed: () => {},
+  diffViewContentDisplayed: () => {},
+  diffViewDisplayed: () => {},
+  diffViewFullyLoaded: () => {},
+  fileListDisplayed: () => {},
+  getTimer: () => {
+    return {end: () => {}};
+  },
+  locationChanged: () => {},
+  onVisibilityChange: () => {},
+  pluginLoaded: () => {},
+  pluginsLoaded: () => {},
+  recordDraftInteraction: () => {},
+  reporter: () => {},
+  reportErrorDialog: () => {},
+  reportExtension: () => {},
+  reportInteraction: () => {},
+  reportLifeCycle: () => {},
+  reportRpcTiming: () => {},
+  setRepoName: () => {},
+  time: () => {},
+  timeEnd: () => {},
+  timeEndWithAverage: () => {},
+};
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js
new file mode 100644
index 0000000..73f8580
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {GrReporting} from './gr-reporting_impl.js';
+import {grReportingMock} from './gr-reporting_mock.js';
+suite('gr-reporting_mock tests', () => {
+  test('mocks all public methods', () => {
+    const methods = Object.getOwnPropertyNames(GrReporting.prototype)
+        .filter(name => typeof GrReporting.prototype[name] === 'function')
+        .filter(name => !name.startsWith('_') && name !== 'constructor')
+        .sort();
+    const mockMethods = Object.getOwnPropertyNames(grReportingMock)
+        .sort();
+    assert.deepEqual(methods, mockMethods);
+  });
+});
+
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
new file mode 100644
index 0000000..08e4a55
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
@@ -0,0 +1,499 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {GrReporting, DEFAULT_STARTUP_TIMERS, initErrorReporter} from './gr-reporting_impl.js';
+import {appContext} from '../app-context.js';
+suite('gr-reporting tests', () => {
+  let service;
+
+  let clock;
+  let fakePerformance;
+
+  const NOW_TIME = 100;
+
+  setup(() => {
+    clock = sinon.useFakeTimers(NOW_TIME);
+    service = new GrReporting(appContext.flagsService);
+    service._baselines = {...DEFAULT_STARTUP_TIMERS};
+    sinon.stub(service, 'reporter');
+  });
+
+  teardown(() => {
+    clock.restore();
+  });
+
+  test('appStarted', () => {
+    fakePerformance = {
+      navigationStart: 1,
+      loadEventEnd: 2,
+    };
+    fakePerformance.toJSON = () => fakePerformance;
+    sinon.stub(service, 'performanceTiming').get(() => fakePerformance);
+    sinon.stub(window.performance, 'now').returns(42);
+    service.appStarted();
+    assert.isTrue(
+        service.reporter.calledWithMatch(
+            'timing-report', 'UI Latency', 'App Started', 42
+        ));
+    assert.isTrue(
+        service.reporter.calledWithExactly(
+            'timing-report', 'UI Latency', 'NavResTime - loadEventEnd',
+            fakePerformance.loadEventEnd - fakePerformance.navigationStart,
+            undefined, true)
+    );
+  });
+
+  test('WebComponentsReady', () => {
+    sinon.stub(window.performance, 'now').returns(42);
+    service.timeEnd('WebComponentsReady');
+    assert.isTrue(service.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'WebComponentsReady', 42
+    ));
+  });
+
+  test('beforeLocationChanged', () => {
+    service._baselines['garbage'] = 'monster';
+    sinon.stub(service, 'time');
+    service.beforeLocationChanged();
+    assert.isTrue(service.time.calledWithExactly('DashboardDisplayed'));
+    assert.isTrue(service.time.calledWithExactly('ChangeDisplayed'));
+    assert.isTrue(service.time.calledWithExactly('ChangeFullyLoaded'));
+    assert.isTrue(service.time.calledWithExactly('DiffViewDisplayed'));
+    assert.isTrue(service.time.calledWithExactly('FileListDisplayed'));
+    assert.isFalse(service._baselines.hasOwnProperty('garbage'));
+  });
+
+  test('changeDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.changeDisplayed();
+    assert.isFalse(service.timeEnd.calledWith('ChangeDisplayed'));
+    assert.isTrue(service.timeEnd.calledWith('StartupChangeDisplayed'));
+    service.changeDisplayed();
+    assert.isTrue(service.timeEnd.calledWith('ChangeDisplayed'));
+  });
+
+  test('changeFullyLoaded', () => {
+    sinon.spy(service, 'timeEnd');
+    service.changeFullyLoaded();
+    assert.isFalse(
+        service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
+    assert.isTrue(
+        service.timeEnd.calledWithExactly('StartupChangeFullyLoaded'));
+    service.changeFullyLoaded();
+    assert.isTrue(service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
+  });
+
+  test('diffViewDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.diffViewDisplayed();
+    assert.isFalse(service.timeEnd.calledWith('DiffViewDisplayed'));
+    assert.isTrue(service.timeEnd.calledWith('StartupDiffViewDisplayed'));
+    service.diffViewDisplayed();
+    assert.isTrue(service.timeEnd.calledWith('DiffViewDisplayed'));
+  });
+
+  test('fileListDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.fileListDisplayed();
+    assert.isFalse(
+        service.timeEnd.calledWithExactly('FileListDisplayed'));
+    assert.isTrue(
+        service.timeEnd.calledWithExactly('StartupFileListDisplayed'));
+    service.fileListDisplayed();
+    assert.isTrue(service.timeEnd.calledWithExactly('FileListDisplayed'));
+  });
+
+  test('dashboardDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.dashboardDisplayed();
+    assert.isFalse(service.timeEnd.calledWith('DashboardDisplayed'));
+    assert.isTrue(service.timeEnd.calledWith('StartupDashboardDisplayed'));
+    service.dashboardDisplayed();
+    assert.isTrue(service.timeEnd.calledWith('DashboardDisplayed'));
+  });
+
+  test('dashboardDisplayed details', () => {
+    sinon.spy(service, 'timeEnd');
+    sinon.stub(window, 'performance').value( {
+      memory: {
+        usedJSHeapSize: 1024 * 1024,
+      },
+      measure: () => {},
+      now: () => { 42; },
+    });
+    service.reportRpcTiming('/changes/*~*/comments', 500);
+    service.dashboardDisplayed();
+    assert.isTrue(
+        service.timeEnd.calledWithExactly('StartupDashboardDisplayed',
+            {rpcList: [
+              {
+                anonymizedUrl: '/changes/*~*/comments',
+                elapsed: 500,
+              },
+            ],
+            screenSize: {
+              width: window.screen.width,
+              height: window.screen.height,
+            },
+            viewport: {
+              width: document.documentElement.clientWidth,
+              height: document.documentElement.clientHeight,
+            },
+            usedJSHeapSizeMb: 1,
+            hiddenDurationMs: 0,
+            }
+        ));
+  });
+
+  suite('hidden duration', () => {
+    let nowStub;
+    let visibilityStateStub;
+    const assertHiddenDurationsMs = hiddenDurationMs => {
+      service.dashboardDisplayed();
+      assert.isTrue(
+          service.timeEnd.calledWithMatch('StartupDashboardDisplayed',
+              {hiddenDurationMs}
+          ));
+    };
+
+    setup(() => {
+      sinon.spy(service, 'timeEnd');
+      nowStub = sinon.stub(window.performance, 'now');
+      visibilityStateStub = {
+        value: value => {
+          Object.defineProperty(document, 'visibilityState',
+              {value, configurable: true});
+        },
+      };
+    });
+
+    test('starts in hidden', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      service.onVisibilityChange();
+      nowStub.returns(15);
+      visibilityStateStub.value('visible');
+      service.onVisibilityChange();
+      assertHiddenDurationsMs(5);
+    });
+
+    test('full in hidden', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      assertHiddenDurationsMs(10);
+    });
+
+    test('full in visible', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('visible');
+      assertHiddenDurationsMs(0);
+    });
+
+    test('accumulated', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      service.onVisibilityChange();
+      nowStub.returns(15);
+      visibilityStateStub.value('visible');
+      service.onVisibilityChange();
+      nowStub.returns(20);
+      visibilityStateStub.value('hidden');
+      service.onVisibilityChange();
+      nowStub.returns(25);
+      assertHiddenDurationsMs(10);
+    });
+
+    test('reset after location change', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      assertHiddenDurationsMs(10);
+      visibilityStateStub.value('visible');
+      nowStub.returns(15);
+      service.beforeLocationChanged();
+      service.timeEnd.resetHistory();
+      service.dashboardDisplayed();
+      assert.isTrue(
+          service.timeEnd.calledWithMatch('DashboardDisplayed',
+              {hiddenDurationMs: 0}
+          ));
+    });
+  });
+
+  test('time and timeEnd', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(0);
+    service.time('foo');
+    nowStub.returns(1);
+    service.time('bar');
+    nowStub.returns(2);
+    service.timeEnd('bar');
+    nowStub.returns(3);
+    service.timeEnd('foo');
+    assert.isTrue(service.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'foo', 3
+    ));
+    assert.isTrue(service.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'bar', 1
+    ));
+  });
+
+  test('timer object', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(100);
+    const timer = service.getTimer('foo-bar');
+    nowStub.returns(150);
+    timer.end();
+    assert.isTrue(service.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'foo-bar', 50));
+  });
+
+  test('timer object double call', () => {
+    const timer = service.getTimer('foo-bar');
+    timer.end();
+    assert.isTrue(service.reporter.calledOnce);
+    assert.throws(() => {
+      timer.end();
+    }, 'Timer for "foo-bar" already ended.');
+  });
+
+  test('timer object maximum', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(100);
+    const timer = service.getTimer('foo-bar').withMaximum(100);
+    nowStub.returns(150);
+    timer.end();
+    assert.isTrue(service.reporter.calledOnce);
+
+    timer.reset();
+    nowStub.returns(260);
+    timer.end();
+    assert.isTrue(service.reporter.calledOnce);
+  });
+
+  test('recordDraftInteraction', () => {
+    const key = 'TimeBetweenDraftActions';
+    const nowStub = sinon.stub(window.performance, 'now').returns(100);
+    const timingStub = sinon.stub(service, '_reportTiming');
+    service.recordDraftInteraction();
+    assert.isFalse(timingStub.called);
+
+    nowStub.returns(200);
+    service.recordDraftInteraction();
+    assert.isTrue(timingStub.calledOnce);
+    assert.equal(timingStub.lastCall.args[0], key);
+    assert.equal(timingStub.lastCall.args[1], 100);
+
+    nowStub.returns(350);
+    service.recordDraftInteraction();
+    assert.isTrue(timingStub.calledTwice);
+    assert.equal(timingStub.lastCall.args[0], key);
+    assert.equal(timingStub.lastCall.args[1], 150);
+
+    nowStub.returns(370 + 2 * 60 * 1000);
+    service.recordDraftInteraction();
+    assert.isFalse(timingStub.calledThrice);
+  });
+
+  test('timeEndWithAverage', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(0);
+    nowStub.returns(1000);
+    service.time('foo');
+    nowStub.returns(1100);
+    service.timeEndWithAverage('foo', 'bar', 10);
+    assert.isTrue(service.reporter.calledTwice);
+    assert.isTrue(service.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'foo', 100));
+    assert.isTrue(service.reporter.calledWithMatch(
+        'timing-report', 'UI Latency', 'bar', 10));
+  });
+
+  test('reportExtension', () => {
+    service.reportExtension('foo');
+    assert.isTrue(service.reporter.calledWithExactly(
+        'lifecycle', 'Extension detected', 'foo'
+    ));
+  });
+
+  test('reportInteraction', () => {
+    service.reporter.restore();
+    sinon.spy(service, '_reportEvent');
+    service.pluginsLoaded(); // so we don't cache
+    service.reportInteraction('button-click', {name: 'sendReply'});
+    assert.isTrue(service._reportEvent.getCall(2).calledWithMatch(
+        {
+          type: 'interaction',
+          name: 'button-click',
+          eventDetails: JSON.stringify({name: 'sendReply'}),
+        }
+    ));
+  });
+
+  test('report start time', () => {
+    service.reporter.restore();
+    sinon.stub(window.performance, 'now').returns(42);
+    sinon.spy(service, '_reportEvent');
+    const dispatchStub = sinon.spy(document, 'dispatchEvent');
+    service.pluginsLoaded();
+    service.time('timeAction');
+    service.timeEnd('timeAction');
+    assert.isTrue(service._reportEvent.getCall(2).calledWithMatch(
+        {
+          type: 'timing-report',
+          category: 'UI Latency',
+          name: 'timeAction',
+          value: 0,
+          eventStart: 42,
+        }
+    ));
+    assert.equal(dispatchStub.getCall(2).args[0].detail.eventStart, 42);
+  });
+
+  suite('plugins', () => {
+    setup(() => {
+      service.reporter.restore();
+      sinon.stub(service, '_reportEvent');
+    });
+
+    test('pluginsLoaded reports time', () => {
+      sinon.stub(window.performance, 'now').returns(42);
+      service.pluginsLoaded();
+      assert.isTrue(service._reportEvent.calledWithMatch(
+          {
+            type: 'timing-report',
+            category: 'UI Latency',
+            name: 'PluginsLoaded',
+            value: 42,
+          }
+      ));
+    });
+
+    test('pluginsLoaded reports plugins', () => {
+      service.pluginsLoaded(['foo', 'bar']);
+      assert.isTrue(service._reportEvent.calledWithMatch(
+          {
+            type: 'lifecycle',
+            category: 'Plugins installed',
+            eventDetails: JSON.stringify({pluginsList: ['foo', 'bar']}),
+          }
+      ));
+    });
+
+    test('caches reports if plugins are not loaded', () => {
+      service.timeEnd('foo');
+      assert.isFalse(service._reportEvent.called);
+    });
+
+    test('reports if plugins are loaded', () => {
+      service.pluginsLoaded();
+      assert.isTrue(service._reportEvent.called);
+    });
+
+    test('reports if metrics plugin xyz is loaded', () => {
+      service.pluginLoaded('metrics-xyz');
+      assert.isTrue(service._reportEvent.called);
+    });
+
+    test('reports cached events preserving order', () => {
+      service.time('foo');
+      service.time('bar');
+      service.timeEnd('foo');
+      service.pluginsLoaded();
+      service.timeEnd('bar');
+      assert.isTrue(service._reportEvent.getCall(0).calledWithMatch(
+          {type: 'timing-report', category: 'UI Latency', name: 'foo'}
+      ));
+      assert.isTrue(service._reportEvent.getCall(1).calledWithMatch(
+          {type: 'timing-report', category: 'UI Latency',
+            name: 'PluginsLoaded'}
+      ));
+      assert.isTrue(service._reportEvent.getCall(2).calledWithMatch(
+          {type: 'lifecycle', category: 'Plugins installed'}
+      ));
+      assert.isTrue(service._reportEvent.getCall(3).calledWithMatch(
+          {type: 'timing-report', category: 'UI Latency', name: 'bar'}
+      ));
+    });
+  });
+
+  test('search', () => {
+    service.locationChanged('_handleSomeRoute');
+    assert.isTrue(service.reporter.calledWithExactly(
+        'nav-report', 'Location Changed', 'Page', '_handleSomeRoute'));
+  });
+
+  suite('exception logging', () => {
+    let fakeWindow;
+    let reporter;
+
+    const emulateThrow = function(msg, url, line, column, error) {
+      return fakeWindow.onerror(msg, url, line, column, error);
+    };
+
+    setup(() => {
+      reporter = service.reporter;
+      fakeWindow = {
+        handlers: {},
+        addEventListener(type, handler) {
+          this.handlers[type] = handler;
+        },
+      };
+      sinon.stub(console, 'error');
+      Object.defineProperty(appContext, 'reportingService', {
+        get() {
+          return service;
+        },
+      });
+      const errorReporter = initErrorReporter(appContext);
+      errorReporter.catchErrors(fakeWindow);
+    });
+
+    test('is reported', () => {
+      const error = new Error('bar');
+      error.stack = undefined;
+      emulateThrow('bar', 'http://url', 4, 2, error);
+      assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
+      const payload = reporter.lastCall.args[3];
+      assert.deepEqual(payload, {
+        url: 'http://url',
+        line: 4,
+        column: 2,
+        error,
+      });
+    });
+
+    test('is reported with 3 lines of stack', () => {
+      const error = new Error('bar');
+      emulateThrow('bar', 'http://url', 4, 2, error);
+      const expectedStack = error.stack.split('\n').slice(0, 3)
+          .join('\n');
+      assert.isTrue(reporter.calledWith('error', 'exception',
+          expectedStack));
+    });
+
+    test('prevent default event handler', () => {
+      assert.isTrue(emulateThrow());
+    });
+
+    test('unhandled rejection', () => {
+      fakeWindow.handlers['unhandledrejection']({
+        reason: {
+          message: 'bar',
+        },
+      });
+      assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
new file mode 100644
index 0000000..8038764
--- /dev/null
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -0,0 +1,80 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+  AccountInfo,
+  GroupBaseInfo,
+  NumericChangeId,
+  ServerInfo,
+} from '../../../types/common';
+
+export type ErrorCallback = (response?: Response, err?: Error) => void;
+
+/**
+ * Contains information about an account that can be added to a change
+ */
+export interface SuggestedReviewerAccountInfo {
+  account: AccountInfo;
+  /**
+   * The total number of accounts in the suggestion - always 1
+   */
+  count: 1;
+}
+
+/**
+ * Contains information about a group that can be added to a change
+ */
+export interface SuggestedReviewerGroupInfo {
+  group: GroupBaseInfo;
+  /**
+   * The total number of accounts that are members of the group is returned
+   * (this count includes members of nested groups)
+   */
+  count: number;
+  /**
+   * True if group is present and count is above the threshold where the
+   * confirmed flag must be passed to add the group as a reviewer
+   */
+  confirm?: boolean;
+}
+
+/**
+ * Contains information about a reviewer that can be added to a change
+ */
+export type SuggestedReviewerInfo =
+  | SuggestedReviewerAccountInfo
+  | SuggestedReviewerGroupInfo;
+
+export interface RestApiService {
+  getConfig(): Promise<ServerInfo>;
+  getLoggedIn(): Promise<boolean>;
+  getChangeSuggestedReviewers(
+    changeNum: NumericChangeId,
+    input: string,
+    errFn?: ErrorCallback
+  ): Promise<SuggestedReviewerInfo[]>;
+  getChangeSuggestedCCs(
+    changeNum: NumericChangeId,
+    input: string,
+    errFn?: ErrorCallback
+  ): Promise<SuggestedReviewerInfo[]>;
+  getSuggestedAccounts(
+    input: string,
+    n?: number,
+    errFn?: ErrorCallback
+  ): Promise<AccountInfo[]>;
+}
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.js b/polygerrit-ui/app/styles/dashboard-header-styles.js
deleted file mode 100644
index 683202e..0000000
--- a/polygerrit-ui/app/styles/dashboard-header-styles.js
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="dashboard-header-styles">
-  <template>
-    <style>
-      :host {
-        background-color: var(--view-background-color);
-        display: block;
-        min-height: 9em;
-        width: 100%;
-      }
-      gr-avatar {
-        display: inline-block;
-        height: 7em;
-        left: 1em;
-        margin: 1em;
-        top: 1em;
-        width: 7em;
-      }
-      .info {
-        display: inline-block;
-        padding: var(--spacing-l);
-        vertical-align: top;
-      }
-      .info > div > span {
-        display: inline-block;
-        font-weight: var(--font-weight-bold);
-        text-align: right;
-        width: 4em;
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.ts b/polygerrit-ui/app/styles/dashboard-header-styles.ts
new file mode 100644
index 0000000..2354f65
--- /dev/null
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="dashboard-header-styles">
+  <template>
+    <style>
+      :host {
+        background-color: var(--view-background-color);
+        display: block;
+        min-height: 9em;
+        width: 100%;
+      }
+      gr-avatar {
+        display: inline-block;
+        height: 7em;
+        left: 1em;
+        margin: 1em;
+        top: 1em;
+        width: 7em;
+      }
+      .info {
+        display: inline-block;
+        padding: var(--spacing-l);
+        vertical-align: top;
+      }
+      .info > div > span {
+        display: inline-block;
+        font-weight: var(--font-weight-bold);
+        text-align: right;
+        width: 4em;
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.js b/polygerrit-ui/app/styles/gr-change-list-styles.js
deleted file mode 100644
index 4f4d7e3..0000000
--- a/polygerrit-ui/app/styles/gr-change-list-styles.js
+++ /dev/null
@@ -1,194 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-change-list-styles">
-  <template>
-    <style>
-      gr-change-list-item {
-        border-top: 1px solid var(--border-color);
-      }
-      gr-change-list-item[selected],
-      gr-change-list-item:focus {
-        background-color: var(--selection-background-color);
-      }
-      .groupTitle td,
-      .cell {
-        vertical-align: middle;
-      }
-      .groupTitle td:not(.label):not(.endpoint),
-      .cell:not(.label):not(.endpoint) {
-        padding-right: 8px;
-      }
-      .groupTitle td {
-        color: var(--deemphasized-text-color);
-        text-align: left;
-      }
-      .groupHeader {
-        background-color: transparent;
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      .groupContent {
-        background-color: var(--background-color-primary);
-        box-shadow: var(--elevation-level-1);
-      }
-      .groupHeader a {
-        color: var(--primary-text-color);
-        text-decoration: none;
-      }
-      .groupHeader a:hover {
-        text-decoration: underline;
-      }
-      .groupTitle td,
-      .cell {
-        padding: var(--spacing-s) 0;
-      }
-      .groupHeader .cell {
-        padding-top: var(--spacing-l);
-      }
-      .star {
-        padding: 0;
-      }
-      gr-change-star {
-        vertical-align: middle;
-      }
-      .branch,
-      .star,
-      .label,
-      .number,
-      .owner,
-      .assignee,
-      .updated,
-      .size,
-      .status,
-      .repo {
-        white-space: nowrap;
-      }
-      .star {
-        vertical-align: middle;
-      }
-      .leftPadding {
-        width: var(--spacing-l);
-      }
-      .star {
-        width: 30px;
-      }
-      .reviewers div {
-        overflow: hidden;
-      }
-      .label, .endpoint {
-        border-left: 1px solid var(--border-color);
-      }
-      .groupTitle td.label,
-      .label {
-        text-align: center;
-        width: 3rem;
-      }
-      .truncatedRepo {
-        display: none;
-      }
-      @media only screen and (max-width: 150em) {
-        .assignee,
-        .branch,
-        .owner {
-          overflow: hidden;
-          max-width: 18rem;
-          text-overflow: ellipsis;
-        }
-        .truncatedRepo {
-          display: inline-block;
-        }
-        .fullRepo {
-          display: none;
-        }
-      }
-      @media only screen and (max-width: 100em) {
-        .assignee,
-        .branch,
-        .owner,
-        .reviewers {
-          max-width: 10rem;
-        }
-      }
-      @media only screen and (max-width: 50em) {
-        :host {
-          font-family: var(--header-font-family);
-          font-size: var(--font-size-h3);
-          font-weight: var(--font-weight-h3);
-          line-height: var(--line-height-h3);
-        }
-        gr-change-list-item {
-          flex-wrap: wrap;
-          justify-content: space-between;
-          padding: var(--spacing-xs) var(--spacing-m);
-        }
-        gr-change-list-item[selected],
-        gr-change-list-item:focus {
-          background-color: var(--view-background-color);
-          border: none;
-          border-top: 1px solid var(--border-color);
-        }
-        gr-change-list-item:hover {
-          background-color: var(--view-background-color);
-        }
-        .cell {
-          align-items: center;
-          display: flex;
-        }
-        .groupTitle,
-        .leftPadding,
-        .status,
-        .repo,
-        .branch,
-        .updated,
-        .label,
-        .assignee,
-        .groupHeader .star,
-        .noChanges .star {
-          display: none;
-        }
-        .groupHeader .cell,
-        .noChanges .cell {
-          padding-left: var(--spacing-m);
-        }
-        .subject {
-          margin-bottom: var(--spacing-xs);
-          width: calc(100% - 2em);
-        }
-        .owner,
-        .size {
-          max-width: none;
-        }
-        .noChanges .cell {
-          display: block;
-          height: auto;
-        }
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
new file mode 100644
index 0000000..25d7f52
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -0,0 +1,198 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-change-list-styles">
+  <template>
+    <style>
+      gr-change-list-item {
+        border-top: 1px solid var(--border-color);
+      }
+      gr-change-list-item[selected],
+      gr-change-list-item:focus {
+        background-color: var(--selection-background-color);
+      }
+      .groupTitle td,
+      .cell {
+        vertical-align: middle;
+      }
+      .groupTitle td:not(.label):not(.endpoint),
+      .cell:not(.label):not(.endpoint) {
+        padding-right: 8px;
+      }
+      .groupTitle td {
+        color: var(--deemphasized-text-color);
+        text-align: left;
+      }
+      .groupHeader {
+        background-color: transparent;
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+      }
+      .groupContent {
+        background-color: var(--background-color-primary);
+        box-shadow: var(--elevation-level-1);
+      }
+      .groupHeader a {
+        color: var(--primary-text-color);
+        text-decoration: none;
+      }
+      .groupHeader a:hover {
+        text-decoration: underline;
+      }
+      .groupTitle td,
+      .cell {
+        padding: var(--spacing-s) 0;
+      }
+      .groupHeader .cell {
+        padding-top: var(--spacing-l);
+      }
+      .star {
+        padding: 0;
+      }
+      gr-change-star {
+        vertical-align: middle;
+      }
+      .branch,
+      .star,
+      .label,
+      .number,
+      .owner,
+      .assignee,
+      .updated,
+      .size,
+      .status,
+      .repo {
+        white-space: nowrap;
+      }
+      .star {
+        vertical-align: middle;
+      }
+      .leftPadding {
+        width: var(--spacing-l);
+      }
+      .star {
+        width: 30px;
+      }
+      .reviewers div {
+        overflow: hidden;
+      }
+      .label, .endpoint {
+        border-left: 1px solid var(--border-color);
+      }
+      .groupTitle td.label,
+      .label {
+        text-align: center;
+        width: 3rem;
+      }
+      .truncatedRepo {
+        display: none;
+      }
+      @media only screen and (max-width: 150em) {
+        .assignee,
+        .branch,
+        .owner {
+          overflow: hidden;
+          max-width: 18rem;
+          text-overflow: ellipsis;
+        }
+        .truncatedRepo {
+          display: inline-block;
+        }
+        .fullRepo {
+          display: none;
+        }
+      }
+      @media only screen and (max-width: 100em) {
+        .assignee,
+        .branch,
+        .owner {
+          max-width: 10rem;
+        }
+      }
+      @media only screen and (max-width: 50em) {
+        :host {
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+        }
+        gr-change-list-item {
+          flex-wrap: wrap;
+          justify-content: space-between;
+          padding: var(--spacing-xs) var(--spacing-m);
+        }
+        gr-change-list-item[selected],
+        gr-change-list-item:focus {
+          background-color: var(--view-background-color);
+          border: none;
+          border-top: 1px solid var(--border-color);
+        }
+        gr-change-list-item:hover {
+          background-color: var(--view-background-color);
+        }
+        .cell {
+          align-items: center;
+          display: flex;
+        }
+        .groupTitle,
+        .leftPadding,
+        .status,
+        .repo,
+        .branch,
+        .updated,
+        .label,
+        .assignee,
+        .groupHeader .star,
+        .noChanges .star {
+          display: none;
+        }
+        .groupHeader .cell,
+        .noChanges .cell {
+          padding-left: var(--spacing-m);
+        }
+        .subject {
+          margin-bottom: var(--spacing-xs);
+          width: calc(100% - 2em);
+        }
+        .owner,
+        .size {
+          max-width: none;
+        }
+        .noChanges .cell {
+          display: block;
+          height: auto;
+        }
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
diff --git a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.js b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.js
deleted file mode 100644
index aabdde5..0000000
--- a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.js
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-change-metadata-shared-styles">
-  <template>
-    <style include="shared-styles">
-      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-    </style>
-    <style>
-      section {
-        display: table-row;
-      }
-
-      section:not(:first-of-type) .title,
-      section:not(:first-of-type) .value {
-        padding-top: var(--spacing-s);
-      }
-
-      .title,
-      .value {
-        display: table-cell;
-        vertical-align: top;
-      }
-
-      .title {
-        color: var(--deemphasized-text-color);
-        max-width: 20em;
-        padding-left: var(--metadata-horizontal-padding);
-        padding-right: var(--metadata-horizontal-padding);
-        word-break: break-word;
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
new file mode 100644
index 0000000..3d07d2e
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-change-metadata-shared-styles">
+  <template>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style>
+      section {
+        display: table-row;
+      }
+
+      section:not(:first-of-type) .title,
+      section:not(:first-of-type) .value {
+        padding-top: var(--spacing-s);
+      }
+
+      .title,
+      .value {
+        display: table-cell;
+        vertical-align: top;
+      }
+
+      .title {
+        color: var(--deemphasized-text-color);
+        max-width: 20em;
+        padding-left: var(--metadata-horizontal-padding);
+        padding-right: var(--metadata-horizontal-padding);
+        word-break: break-word;
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
diff --git a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.js b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.js
deleted file mode 100644
index 4bfb742..0000000
--- a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.js
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-change-view-integration-shared-styles">
-  <template>
-    <style include="shared-styles">
-      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-    </style>
-    <style>
-      :host {
-        border-top: 1px solid var(--border-color);
-        display: block;
-      }
-      .header {
-        color: var(--primary-text-color);
-        background-color: var(--table-header-background-color);
-        justify-content: space-between;
-        padding: var(--spacing-m) var(--spacing-l);
-        border-bottom: 1px solid var(--border-color);
-      }
-      .header .label {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-        margin: 0 var(--spacing-l) 0 0;
-      }
-      .header .note {
-        color: var(--deemphasized-text-color);
-      }
-      .content {
-        background-color: var(--view-background-color);
-      }
-      .header a,
-      .content a {
-        color: var(--link-color);
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  This is shared styles for change-view-integration endpoints.
-  All plugins that registered that endpoint should include this in
-  the component to have a consistent UX:
-
-  <style include="gr-change-view-integration-shared-styles"></style>
-
-  And use those defined class to apply these styles.
-*/
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
new file mode 100644
index 0000000..57c8d78
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-change-view-integration-shared-styles">
+  <template>
+    <style include="shared-styles">
+      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+    </style>
+    <style>
+      :host {
+        border-top: 1px solid var(--border-color);
+        display: block;
+      }
+      .header {
+        color: var(--primary-text-color);
+        background-color: var(--table-header-background-color);
+        justify-content: space-between;
+        padding: var(--spacing-m) var(--spacing-l);
+        border-bottom: 1px solid var(--border-color);
+      }
+      .header .label {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+        margin: 0 var(--spacing-l) 0 0;
+      }
+      .header .note {
+        color: var(--deemphasized-text-color);
+      }
+      .content {
+        background-color: var(--view-background-color);
+      }
+      .header a,
+      .content a {
+        color: var(--link-color);
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  This is shared styles for change-view-integration endpoints.
+  All plugins that registered that endpoint should include this in
+  the component to have a consistent UX:
+
+  <style include="gr-change-view-integration-shared-styles"></style>
+
+  And use those defined class to apply these styles.
+*/
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
diff --git a/polygerrit-ui/app/styles/gr-form-styles.js b/polygerrit-ui/app/styles/gr-form-styles.js
deleted file mode 100644
index 91763c5..0000000
--- a/polygerrit-ui/app/styles/gr-form-styles.js
+++ /dev/null
@@ -1,127 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-form-styles">
-  <template>
-    <style>
-      .gr-form-styles input {
-        background-color: var(--view-background-color);
-        color: var(--primary-text-color);
-      }
-      .gr-form-styles select {
-        background-color: var(--select-background-color);
-        color: var(--primary-text-color);
-      }
-      .gr-form-styles h1,
-      .gr-form-styles h2 {
-        margin-bottom: var(--spacing-s);
-      }
-      .gr-form-styles h4 {
-        font-weight: var(--font-weight-bold);
-      }
-      .gr-form-styles fieldset {
-        border: none;
-        margin-bottom: var(--spacing-xxl);
-      }
-      .gr-form-styles section {
-        display: flex;
-        margin: var(--spacing-s) 0;
-        min-height: 2em;
-      }
-      .gr-form-styles section * {
-        vertical-align: middle;
-      }
-      .gr-form-styles .title,
-      .gr-form-styles .value {
-        display: inline-block;
-      }
-      .gr-form-styles .title {
-        color: var(--deemphasized-text-color);
-        font-weight: var(--font-weight-bold);
-        padding-right: var(--spacing-m);
-        width: 15em;
-      }
-      .gr-form-styles th {
-        color: var(--deemphasized-text-color);
-        text-align: left;
-        vertical-align: bottom;
-      }
-      .gr-form-styles td,
-      .gr-form-styles tfoot th {
-        padding: var(--spacing-s) 0;
-        vertical-align: middle;
-      }
-      .gr-form-styles .emptyHeader {
-        text-align: right;
-      }
-      .gr-form-styles table {
-        width: 50em;
-      }
-      .gr-form-styles th:first-child,
-      .gr-form-styles td:first-child {
-        width: 15em;
-      }
-      .gr-form-styles th:first-child input,
-      .gr-form-styles td:first-child input {
-        width: 14em;
-      }
-      .gr-form-styles input:not([type="checkbox"]),
-      .gr-form-styles select,
-      .gr-form-styles textarea {
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        padding: var(--spacing-s);
-      }
-      .gr-form-styles td:last-child {
-        width: 5em;
-      }
-      .gr-form-styles th:last-child gr-button,
-      .gr-form-styles td:last-child gr-button {
-        width: 100%;
-      }
-      .gr-form-styles iron-autogrow-textarea {
-        height: auto;
-        min-height: 4em;
-      }
-      .gr-form-styles gr-autocomplete {
-        width: 14em;
-      }
-      @media only screen and (max-width: 40em) {
-        .gr-form-styles section {
-          margin-bottom: var(--spacing-l);
-        }
-        .gr-form-styles .title,
-        .gr-form-styles .value {
-          display: block;
-        }
-        .gr-form-styles table {
-          width: 100%;
-        }
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/styles/gr-form-styles.ts b/polygerrit-ui/app/styles/gr-form-styles.ts
new file mode 100644
index 0000000..3284ad5
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-form-styles.ts
@@ -0,0 +1,132 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-form-styles">
+  <template>
+    <style>
+      .gr-form-styles input {
+        background-color: var(--view-background-color);
+        color: var(--primary-text-color);
+      }
+      .gr-form-styles select {
+        background-color: var(--select-background-color);
+        color: var(--primary-text-color);
+      }
+      .gr-form-styles h1,
+      .gr-form-styles h2 {
+        margin-bottom: var(--spacing-s);
+      }
+      .gr-form-styles h4 {
+        font-weight: var(--font-weight-bold);
+      }
+      .gr-form-styles fieldset {
+        border: none;
+        margin-bottom: var(--spacing-xxl);
+      }
+      .gr-form-styles section {
+        display: flex;
+        margin: var(--spacing-s) 0;
+        min-height: 2em;
+      }
+      .gr-form-styles section * {
+        vertical-align: middle;
+      }
+      .gr-form-styles .title,
+      .gr-form-styles .value {
+        display: inline-block;
+      }
+      .gr-form-styles .title {
+        color: var(--deemphasized-text-color);
+        font-weight: var(--font-weight-bold);
+        padding-right: var(--spacing-m);
+        width: 15em;
+      }
+      .gr-form-styles th {
+        color: var(--deemphasized-text-color);
+        text-align: left;
+        vertical-align: bottom;
+      }
+      .gr-form-styles td,
+      .gr-form-styles tfoot th {
+        padding: var(--spacing-s) 0;
+        vertical-align: middle;
+      }
+      .gr-form-styles .emptyHeader {
+        text-align: right;
+      }
+      .gr-form-styles table {
+        width: 50em;
+      }
+      .gr-form-styles th:first-child,
+      .gr-form-styles td:first-child {
+        width: 15em;
+      }
+      .gr-form-styles th:first-child input,
+      .gr-form-styles td:first-child input {
+        width: 14em;
+      }
+      .gr-form-styles input:not([type="checkbox"]),
+      .gr-form-styles select,
+      .gr-form-styles textarea {
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        padding: var(--spacing-s);
+      }
+      .gr-form-styles td:last-child {
+        width: 5em;
+      }
+      .gr-form-styles th:last-child gr-button,
+      .gr-form-styles td:last-child gr-button {
+        width: 100%;
+      }
+      .gr-form-styles iron-autogrow-textarea {
+        height: auto;
+        min-height: 4em;
+      }
+      .gr-form-styles gr-autocomplete {
+        width: 14em;
+      }
+      @media only screen and (max-width: 40em) {
+        .gr-form-styles section {
+          margin-bottom: var(--spacing-l);
+        }
+        .gr-form-styles .title,
+        .gr-form-styles .value {
+          display: block;
+        }
+        .gr-form-styles table {
+          width: 100%;
+        }
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.js b/polygerrit-ui/app/styles/gr-menu-page-styles.js
deleted file mode 100644
index e52a895..0000000
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.js
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-menu-page-styles">
-  <template>
-    <style>
-      :host {
-        display: block;
-      }
-      main {
-        margin: var(--spacing-xxl) auto;
-        max-width: 50em;
-      }
-      .mainHeader {
-        margin-left: 14em;
-        padding: var(--spacing-l) 0 var(--spacing-l) var(--spacing-xxl);
-      }
-      main.table,
-      .mainHeader {
-        margin-top: 0;
-        margin-right: 0;
-        margin-left: 14em;
-        max-width: none;
-      }
-      h2.edited:after {
-        color: var(--deemphasized-text-color);
-        content: ' *';
-      }
-      .loading {
-        color: var(--deemphasized-text-color);
-        padding: var(--spacing-l);
-      }
-      @media only screen and (max-width: 67em) {
-        main {
-          margin: var(--spacing-xxl) 0 var(--spacing-xxl) 15em;
-        }
-        main.table {
-          margin-left: 14em;
-        }
-      }
-      @media only screen and (max-width: 53em) {
-        .loading {
-          padding: 0 var(--spacing-l);
-        }
-        main {
-          margin: var(--spacing-xxl) var(--spacing-l);
-        }
-        main.table {
-          margin: 0;
-        }
-        .mainHeader {
-          margin-left: 0;
-          padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-l);
-        }
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.ts b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
new file mode 100644
index 0000000..8e8b264
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-menu-page-styles">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      main {
+        margin: var(--spacing-xxl) auto;
+        max-width: 50em;
+      }
+      .mainHeader {
+        margin-left: 14em;
+        padding: var(--spacing-l) 0 var(--spacing-l) var(--spacing-xxl);
+      }
+      main.table,
+      .mainHeader {
+        margin-top: 0;
+        margin-right: 0;
+        margin-left: 14em;
+        max-width: none;
+      }
+      h2.edited:after {
+        color: var(--deemphasized-text-color);
+        content: ' *';
+      }
+      .loading {
+        color: var(--deemphasized-text-color);
+        padding: var(--spacing-l);
+      }
+      @media only screen and (max-width: 67em) {
+        main {
+          margin: var(--spacing-xxl) 0 var(--spacing-xxl) 15em;
+        }
+        main.table {
+          margin-left: 14em;
+        }
+      }
+      @media only screen and (max-width: 53em) {
+        .loading {
+          padding: 0 var(--spacing-l);
+        }
+        main {
+          margin: var(--spacing-xxl) var(--spacing-l);
+        }
+        main.table {
+          margin: 0;
+        }
+        .mainHeader {
+          margin-left: 0;
+          padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-l);
+        }
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.js b/polygerrit-ui/app/styles/gr-page-nav-styles.js
deleted file mode 100644
index 97f1a03..0000000
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.js
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-page-nav-styles">
-  <template>
-    <style>
-      .navStyles ul {
-        padding: var(--spacing-l) 0;
-      }
-      .navStyles li {
-        border-bottom: 1px solid transparent;
-        border-top: 1px solid transparent;
-        display: block;
-        padding: 0 var(--spacing-xl);
-      }
-      .navStyles li a {
-        display: block;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-      }
-      .navStyles .subsectionItem {
-        padding-left: var(--spacing-xxl);
-      }
-      .navStyles .hideSubsection {
-        display: none;
-      }
-      .navStyles li.sectionTitle {
-        padding: 0 var(--spacing-xxl) 0 var(--spacing-l);
-      }
-      .navStyles li.sectionTitle:not(:first-child) {
-        margin-top: var(--spacing-l);
-      }
-      .navStyles .title {
-        font-weight: var(--font-weight-bold);
-        margin: var(--spacing-s) 0;
-      }
-      .navStyles .selected {
-        background-color: var(--view-background-color);
-        border-bottom: 1px solid var(--border-color);
-        border-top: 1px solid var(--border-color);
-        font-weight: var(--font-weight-bold);
-      }
-      .navStyles a {
-        color: var(--primary-text-color);
-        display: inline-block;
-        margin: var(--spacing-s) 0;
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
new file mode 100644
index 0000000..9010b2d
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
@@ -0,0 +1,80 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-page-nav-styles">
+  <template>
+    <style>
+      .navStyles ul {
+        padding: var(--spacing-l) 0;
+      }
+      .navStyles li {
+        border-bottom: 1px solid transparent;
+        border-top: 1px solid transparent;
+        display: block;
+        padding: 0 var(--spacing-xl);
+      }
+      .navStyles li a {
+        display: block;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .navStyles .subsectionItem {
+        padding-left: var(--spacing-xxl);
+      }
+      .navStyles .hideSubsection {
+        display: none;
+      }
+      .navStyles li.sectionTitle {
+        padding: 0 var(--spacing-xxl) 0 var(--spacing-l);
+      }
+      .navStyles li.sectionTitle:not(:first-child) {
+        margin-top: var(--spacing-l);
+      }
+      .navStyles .title {
+        font-weight: var(--font-weight-bold);
+        margin: var(--spacing-s) 0;
+      }
+      .navStyles .selected {
+        background-color: var(--view-background-color);
+        border-bottom: 1px solid var(--border-color);
+        border-top: 1px solid var(--border-color);
+        font-weight: var(--font-weight-bold);
+      }
+      .navStyles a {
+        color: var(--primary-text-color);
+        display: inline-block;
+        margin: var(--spacing-s) 0;
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.js b/polygerrit-ui/app/styles/gr-subpage-styles.js
deleted file mode 100644
index f94cc9c..0000000
--- a/polygerrit-ui/app/styles/gr-subpage-styles.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-subpage-styles">
-  <template>
-    <style>
-      main {
-        margin: var(--spacing-l);
-      }
-      .loading {
-        display: none;
-      }
-      #loading.loading {
-        display: block;
-      }
-      #loading:not(.loading) {
-        display: none;
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.ts b/polygerrit-ui/app/styles/gr-subpage-styles.ts
new file mode 100644
index 0000000..640da66
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-subpage-styles.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-subpage-styles">
+  <template>
+    <style>
+      main {
+        margin: var(--spacing-l);
+      }
+      .loading {
+        display: none;
+      }
+      #loading.loading {
+        display: block;
+      }
+      #loading:not(.loading) {
+        display: none;
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
diff --git a/polygerrit-ui/app/styles/gr-table-styles.js b/polygerrit-ui/app/styles/gr-table-styles.js
deleted file mode 100644
index ceac675..0000000
--- a/polygerrit-ui/app/styles/gr-table-styles.js
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-table-styles">
-  <template>
-    <style>
-      .genericList {
-        background-color: var(--background-color-primary);
-        border-collapse: collapse;
-        width: 100%;
-      }
-      .genericList th,
-      .genericList td {
-        padding: var(--spacing-m) 0;
-        vertical-align: middle;
-      }
-      .genericList tr {
-        border-bottom: 1px solid var(--border-color);
-      }
-      .genericList tr:hover {
-        background-color: var(--hover-background-color);
-      }
-      .genericList th {
-        white-space: nowrap;
-      }
-      .genericList th,
-      .genericList td {
-        padding-right: var(--spacing-l);
-      }
-      .genericList tr th:first-of-type,
-      .genericList tr td:first-of-type {
-        padding-left: var(--spacing-l);
-      }
-      .genericList tr:first-of-type {
-        border-top: 1px solid var(--border-color);
-      }
-      .genericList tr th:last-of-type,
-      .genericList tr td:last-of-type {
-        border-left: 1px solid var(--border-color);
-        text-align: center;
-        padding-left: var(--spacing-l);
-      }
-      .genericList tr th.delete,
-      .genericList tr td.delete {
-        padding-top: 0;
-        padding-bottom: 0;
-      }
-      .genericList tr th.delete,
-      .genericList tr td.delete,
-      .genericList tr.loadingMsg td,
-      .genericList tr.groupHeader td {
-        border-left: none;
-      }
-      .genericList .loading {
-        border: none;
-        display: none;
-      }
-      .genericList td {
-        flex-shrink: 0;
-      }
-      .genericList .topHeader,
-      .genericList .groupHeader {
-        color: var(--primary-text-color);
-        font-weight: var(--font-weight-bold);
-        text-align: left;
-        vertical-align: middle
-      }
-      .genericList .groupHeader {
-        background-color: var(--background-color-secondary);
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      .genericList a {
-        color: var(--primary-text-color);
-        text-decoration: none;
-      }
-      .genericList a:hover {
-        text-decoration: underline;
-      }
-      .genericList .description {
-        width: 99%;
-      }
-      .genericList .loadingMsg {
-        color: var(--deemphasized-text-color);
-        display: block;
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-      .genericList .loadingMsg:not(.loading) {
-        display: none;
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/styles/gr-table-styles.ts b/polygerrit-ui/app/styles/gr-table-styles.ts
new file mode 100644
index 0000000..52fdc67
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-table-styles.ts
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-table-styles">
+  <template>
+    <style>
+      .genericList {
+        background-color: var(--background-color-primary);
+        border-collapse: collapse;
+        width: 100%;
+      }
+      .genericList th,
+      .genericList td {
+        padding: var(--spacing-m) 0;
+        vertical-align: middle;
+      }
+      .genericList tr {
+        border-bottom: 1px solid var(--border-color);
+      }
+      .genericList tr:hover {
+        background-color: var(--hover-background-color);
+      }
+      .genericList th {
+        white-space: nowrap;
+      }
+      .genericList th,
+      .genericList td {
+        padding-right: var(--spacing-l);
+      }
+      .genericList tr th:first-of-type,
+      .genericList tr td:first-of-type {
+        padding-left: var(--spacing-l);
+      }
+      .genericList tr:first-of-type {
+        border-top: 1px solid var(--border-color);
+      }
+      .genericList tr th:last-of-type,
+      .genericList tr td:last-of-type {
+        border-left: 1px solid var(--border-color);
+        text-align: center;
+        padding-left: var(--spacing-l);
+      }
+      .genericList tr th.delete,
+      .genericList tr td.delete {
+        padding-top: 0;
+        padding-bottom: 0;
+      }
+      .genericList tr th.delete,
+      .genericList tr td.delete,
+      .genericList tr.loadingMsg td,
+      .genericList tr.groupHeader td {
+        border-left: none;
+      }
+      .genericList .loading {
+        border: none;
+        display: none;
+      }
+      .genericList td {
+        flex-shrink: 0;
+      }
+      .genericList .topHeader,
+      .genericList .groupHeader {
+        color: var(--primary-text-color);
+        font-weight: var(--font-weight-bold);
+        text-align: left;
+        vertical-align: middle
+      }
+      .genericList .groupHeader {
+        background-color: var(--background-color-secondary);
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+      }
+      .genericList a {
+        color: var(--primary-text-color);
+        text-decoration: none;
+      }
+      .genericList a:hover {
+        text-decoration: underline;
+      }
+      .genericList .description {
+        width: 99%;
+      }
+      .genericList .loadingMsg {
+        color: var(--deemphasized-text-color);
+        display: block;
+        padding: var(--spacing-s) var(--spacing-l);
+      }
+      .genericList .loadingMsg:not(.loading) {
+        display: none;
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.js b/polygerrit-ui/app/styles/gr-voting-styles.js
deleted file mode 100644
index 4860428..0000000
--- a/polygerrit-ui/app/styles/gr-voting-styles.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-voting-styles">
-  <template>
-    <style>
-      :host {
-        --vote-chip-styles: {
-          border: 1px solid rgba(0,0,0,.12);
-          border-radius: 1em;
-          box-shadow: none;
-          box-sizing: border-box;
-          min-width: 3em;
-        }
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.ts b/polygerrit-ui/app/styles/gr-voting-styles.ts
new file mode 100644
index 0000000..d4e6d52
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-voting-styles.ts
@@ -0,0 +1,48 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="gr-voting-styles">
+  <template>
+    <style>
+      :host {
+        --vote-chip-styles: {
+          border: 1px solid rgba(0,0,0,.12);
+          border-radius: 1em;
+          box-shadow: none;
+          box-sizing: border-box;
+          min-width: 3em;
+          color: var(--vote-text-color);
+        }
+      }
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
diff --git a/polygerrit-ui/app/styles/shared-styles.js b/polygerrit-ui/app/styles/shared-styles.js
deleted file mode 100644
index f5e048f..0000000
--- a/polygerrit-ui/app/styles/shared-styles.js
+++ /dev/null
@@ -1,199 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="shared-styles">
-  <template>
-    <style>
-
-      /* CSS reset */
-
-      html, body, button, div, span, applet, object, iframe, h1, h2, h3,
-      h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite,
-      code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub,
-      sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form,
-      label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article,
-      aside, canvas, details, embed, figure, figcaption, footer, header, hgroup,
-      main, menu, nav, output, ruby, section, summary, time, mark, audio, video {
-        border: 0;
-        box-sizing: border-box;
-        font-size: 100%;
-        font: inherit;
-        margin: 0;
-        padding: 0;
-        vertical-align: baseline;
-      }
-      *::after,
-      *::before {
-        box-sizing: border-box;
-      }
-      input {
-        background-color: var(--background-color-primary);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        box-sizing: border-box;
-        color: var(--primary-text-color);
-        margin: 0;
-        padding: var(--spacing-s);
-      }
-      iron-autogrow-textarea {
-        background-color: inherit;
-        color: var(--primary-text-color);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        padding: 0;
-        box-sizing: border-box;
-        /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
-           css rule, which prevents overriding the border color. Clear that. */
-        -webkit-appearance: none;
-
-        --iron-autogrow-textarea: {
-          box-sizing: border-box;
-          padding: var(--spacing-s);
-        };
-      }
-      a {
-        color: var(--link-color);
-      }
-      input,
-      textarea,
-      select,
-      button {
-        font: inherit;
-      }
-      ol, ul {
-        list-style: none;
-      }
-      blockquote, q {
-        quotes: none;
-      }
-      blockquote:before, blockquote:after,
-      q:before, q:after {
-        content: '';
-        content: none;
-      }
-      table {
-        border-collapse: collapse;
-        border-spacing: 0;
-      }
-
-      /* Fonts */
-
-      .font-normal {
-        font-size: var(--font-size-normal);
-        font-weight: var(--font-weight-normal);
-        line-height: var(--line-height-normal);
-      }
-      .font-small {
-        font-size: var(--font-size-small);
-        font-weight: var(--font-weight-normal);
-        line-height: var(--line-height-small);
-      }
-      h1, .font-h1 {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h1);
-        font-weight: var(--font-weight-h1);
-        line-height: var(--line-height-h1);
-      }
-      h2, .font-h2 {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h2);
-        font-weight: var(--font-weight-h2);
-        line-height: var(--line-height-h2);
-      }
-      h3, .font-h3 {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      iron-icon {
-        color: var(--deemphasized-text-color);
-        --iron-icon-height: 20px;
-        --iron-icon-width: 20px;
-      }
-
-      /* Stopgap solution until we remove hidden\$ attributes. */
-
-      [hidden] {
-        display: none !important;
-      }
-      .separator {
-        border-left: 1px solid var(--border-color);
-        height: 20px;
-        margin: 0 8px;
-      }
-      .separator.transparent {
-        border-color: transparent;
-      }
-      paper-toggle-button {
-        --paper-toggle-button-checked-bar-color: var(--link-color);
-        --paper-toggle-button-checked-button-color: var(--link-color);
-      }
-      paper-tabs {
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-        --paper-font-common-base: {
-          font-family: var(--header-font-family);
-          -webkit-font-smoothing: initial;
-        };
-        --paper-tab-content-focused: {
-          /* paper-tabs uses 700 here, which can look awkward */
-          font-weight: var(--font-weight-h3);
-        };
-        --paper-tab-content-unselected: {
-          /* paper-tabs uses 0.8 here, but we want to control the color directly */
-          opacity: 1;
-          color: var(--deemphasized-text-color);
-        };
-      }
-      iron-autogrow-textarea {
-        /** This is needed for firefox */
-        --iron-autogrow-textarea_-_white-space: pre-wrap;
-      }
-      strong {
-        font-weight: var(--font-weight-bold);
-      }
-
-      /** BEGIN: loading spiner */
-      .loadingSpin {
-        border: 2px solid var(--disabled-button-background-color);
-        border-top: 2px solid var(--primary-button-background-color);
-        border-radius: 50%;
-        width: 10px;
-        height: 10px;
-        animation: spin 2s linear infinite;
-        margin-right: var(--spacing-s);
-      }
-      @keyframes spin {
-        0% { transform: rotate(0deg); }
-        100% { transform: rotate(360deg); }
-      }
-      /** END: loading spiner */
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
new file mode 100644
index 0000000..04dca9c
--- /dev/null
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -0,0 +1,204 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `<dom-module id="shared-styles">
+  <template>
+    <style>
+
+      /* CSS reset */
+
+      html, body, button, div, span, applet, object, iframe, h1, h2, h3,
+      h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite,
+      code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub,
+      sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form,
+      label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article,
+      aside, canvas, details, embed, figure, figcaption, footer, header, hgroup,
+      main, menu, nav, output, ruby, section, summary, time, mark, audio, video {
+        border: 0;
+        box-sizing: border-box;
+        font-size: 100%;
+        font: inherit;
+        margin: 0;
+        padding: 0;
+        vertical-align: baseline;
+      }
+      *::after,
+      *::before {
+        box-sizing: border-box;
+      }
+      input {
+        background-color: var(--background-color-primary);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        box-sizing: border-box;
+        color: var(--primary-text-color);
+        margin: 0;
+        padding: var(--spacing-s);
+      }
+      iron-autogrow-textarea {
+        background-color: inherit;
+        color: var(--primary-text-color);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        padding: 0;
+        box-sizing: border-box;
+        /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
+           css rule, which prevents overriding the border color. Clear that. */
+        -webkit-appearance: none;
+
+        --iron-autogrow-textarea: {
+          box-sizing: border-box;
+          padding: var(--spacing-s);
+        };
+      }
+      a {
+        color: var(--link-color);
+      }
+      input,
+      textarea,
+      select,
+      button {
+        font: inherit;
+      }
+      ol, ul {
+        list-style: none;
+      }
+      blockquote, q {
+        quotes: none;
+      }
+      blockquote:before, blockquote:after,
+      q:before, q:after {
+        content: '';
+        content: none;
+      }
+      table {
+        border-collapse: collapse;
+        border-spacing: 0;
+      }
+
+      /* Fonts */
+
+      .font-normal {
+        font-size: var(--font-size-normal);
+        font-weight: var(--font-weight-normal);
+        line-height: var(--line-height-normal);
+      }
+      .font-small {
+        font-size: var(--font-size-small);
+        font-weight: var(--font-weight-normal);
+        line-height: var(--line-height-small);
+      }
+      .heading-1 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h1);
+        font-weight: var(--font-weight-h1);
+        line-height: var(--line-height-h1);
+      }
+      .heading-2 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h2);
+        font-weight: var(--font-weight-h2);
+        line-height: var(--line-height-h2);
+      }
+      .heading-3 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+      }
+      iron-icon {
+        color: var(--deemphasized-text-color);
+        --iron-icon-height: 20px;
+        --iron-icon-width: 20px;
+      }
+
+      /* Stopgap solution until we remove hidden$ attributes. */
+
+      [hidden] {
+        display: none !important;
+      }
+      .separator {
+        border-left: 1px solid var(--border-color);
+        height: 20px;
+        margin: 0 8px;
+      }
+      .separator.transparent {
+        border-color: transparent;
+      }
+      paper-toggle-button {
+        --paper-toggle-button-checked-bar-color: var(--link-color);
+        --paper-toggle-button-checked-button-color: var(--link-color);
+      }
+      paper-tabs {
+        font-size: var(--font-size-h3);
+        font-weight: var(--font-weight-h3);
+        line-height: var(--line-height-h3);
+        --paper-font-common-base: {
+          font-family: var(--header-font-family);
+          -webkit-font-smoothing: initial;
+        };
+        --paper-tab-content-focused: {
+          /* paper-tabs uses 700 here, which can look awkward */
+          font-weight: var(--font-weight-h3);
+        };
+        --paper-tab-content-unselected: {
+          /* paper-tabs uses 0.8 here, but we want to control the color directly */
+          opacity: 1;
+          color: var(--deemphasized-text-color);
+        };
+      }
+      iron-autogrow-textarea {
+        /** This is needed for firefox */
+        --iron-autogrow-textarea_-_white-space: pre-wrap;
+      }
+      strong {
+        font-weight: var(--font-weight-bold);
+      }
+
+      /** BEGIN: loading spiner */
+      .loadingSpin {
+        border: 2px solid var(--disabled-button-background-color);
+        border-top: 2px solid var(--primary-button-background-color);
+        border-radius: 50%;
+        width: 10px;
+        height: 10px;
+        animation: spin 2s linear infinite;
+        margin-right: var(--spacing-s);
+      }
+      @keyframes spin {
+        0% { transform: rotate(0deg); }
+        100% { transform: rotate(360deg); }
+      }
+      /** END: loading spiner */
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
+
+/*
+  FIXME(polymer-modulizer): the above comments were extracted
+  from HTML and may be out of place here. Review them and
+  then delete this comment!
+*/
diff --git a/polygerrit-ui/app/styles/themes/app-theme.js b/polygerrit-ui/app/styles/themes/app-theme.js
deleted file mode 100644
index 5d5d9e3..0000000
--- a/polygerrit-ui/app/styles/themes/app-theme.js
+++ /dev/null
@@ -1,220 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<custom-style><style is="custom-style">
-html {
-  /**
-   * When adding a new color variable make sure to also add it to the other
-   * theme files in the same directory.
-   *
-   * For colors prefer lower case hex colors.
-   *
-   * Note that plugins might be using these variables, so removing a variable
-   * can be a breaking change that should go into the release notes.
-   */
-
-  /* text colors */
-  --primary-text-color: black;
-  --link-color: #2a66d9;
-  --comment-text-color: black;
-  --deemphasized-text-color: #5F6368;
-  --default-button-text-color: #2a66d9;
-  --error-text-color: red;
-  --primary-button-text-color: white;
-    /* Used on text color for change list that doesn't need user's attention. */
-  --reviewed-text-color: black;
-  --tooltip-text-color: white;
-  --vote-text-color-recommended: #388e3c;
-  --vote-text-color-disliked: #d32f2f;
-
-  /* background colors */
-  /* primary background colors */
-  --background-color-primary: #ffffff;
-  --background-color-secondary: #f8f9fa;
-  --background-color-tertiary: #f1f3f4;
-  /* directly derived from primary background colors */
-  --chip-background-color: var(--background-color-tertiary);
-  --default-button-background-color: var(--background-color-primary);
-  --dialog-background-color: var(--background-color-primary);
-  --dropdown-background-color: var(--background-color-primary);
-  --expanded-background-color: var(--background-color-tertiary);
-  --select-background-color: var(--background-color-secondary);
-  --shell-command-background-color: var(--background-color-secondary);
-  --shell-command-decoration-background-color: var(--background-color-tertiary);
-  --table-header-background-color: var(--background-color-secondary);
-  --table-subheader-background-color: var(--background-color-tertiary);
-  --view-background-color: var(--background-color-primary);
-  /* unique background colors */
-  --assignee-highlight-color: #fcfad6;
-  --edit-mode-background-color: #ebf5fb;
-  --emphasis-color: #fff9c4;
-  --hover-background-color: rgba(161, 194, 250, 0.2);
-  --disabled-button-background-color: #e8eaed;
-  --primary-button-background-color: #2a66d9;
-  --selection-background-color: rgba(161, 194, 250, 0.1);
-  --tooltip-background-color: #333;
-  /* comment background colors */
-  --comment-background-color: #e8eaed;
-  --robot-comment-background-color: #e8f0fe;
-  --unresolved-comment-background-color: #fef7e0;
-  /* vote background colors */
-  --vote-color-approved: #9fcc6b;
-  --vote-color-disliked: #f7c4cb;
-  --vote-color-neutral: #ebf5fb;
-  --vote-color-recommended: #c9dfaf;
-  --vote-color-rejected: #f7a1ad;
-
-  /* misc colors */
-  --border-color: #e8e8e8;
-  --comment-separator-color: #dadce0;
-
-  /* fonts */
-  --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
-  --header-font-family: 'Open Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
-  --monospace-font-family: 'Roboto Mono', 'SF Mono', 'Lucida Console', Monaco, monospace;
-  --font-size-code: 12px;     /* 12px mono */
-  --font-size-mono: .929rem;  /* 13px mono */
-  --font-size-small: .857rem; /* 12px */
-  --font-size-normal: 1rem;   /* 14px */
-  --font-size-h3: 1.143rem;   /* 16px */
-  --font-size-h2: 1.429rem;   /* 20px */
-  --font-size-h1: 1.714rem;   /* 24px */
-  --line-height-code: 1.334;      /* 16px */
-  --line-height-mono: 1.286rem;   /* 18px */
-  --line-height-small: 1.143rem;  /* 16px */
-  --line-height-normal: 1.429rem; /* 20px */
-  --line-height-h3: 1.714rem;     /* 24px */
-  --line-height-h2: 2rem;         /* 28px */
-  --line-height-h1: 2.286rem;     /* 32px */
-  --font-weight-normal: 400; /* 400 is the same as 'normal' */
-  --font-weight-bold: 500;
-  --font-weight-h1: 400;
-  --font-weight-h2: 400;
-  --font-weight-h3: 400;
-
-  /* spacing */
-  --spacing-xxs: 1px;
-  --spacing-xs: 2px;
-  --spacing-s: 4px;
-  --spacing-m: 8px;
-  --spacing-l: 12px;
-  --spacing-xl: 16px;
-  --spacing-xxl: 24px;
-
-  /* header and footer */
-  --footer-background-color: transparent;
-  --footer-border-top: none;
-  --header-background-color: var(--background-color-tertiary);
-  --header-border-bottom: 1px solid var(--border-color);
-  --header-border-image: '';
-  --header-box-shadow: none;
-  --header-padding: 0 var(--spacing-l);
-  --header-icon-size: 0em;
-  --header-icon: none;
-  --header-text-color: black;
-  --header-title-content: 'Gerrit';
-  --header-title-font-size: 1.75rem;
-
-  /* diff colors */
-  --dark-add-highlight-color: #aaf2aa;
-  --dark-rebased-add-highlight-color: #d7d7f9;
-  --dark-rebased-remove-highlight-color: #f7e8b7;
-  --dark-remove-highlight-color: #ffcdd2;
-  --diff-blank-background-color: var(--background-color-secondary);
-  --diff-context-control-background-color: #fff7d4;
-  --diff-context-control-border-color: #f6e6a5;
-  --diff-context-control-color: var(--deemphasized-text-color);
-  --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
-  --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
-  --diff-selection-background-color: #c7dbf9;
-  --diff-tab-indicator-color: var(--deemphasized-text-color);
-  --diff-trailing-whitespace-indicator: #ff9ad2;
-  --light-add-highlight-color: #d8fed8;
-  --light-rebased-add-highlight-color: #eef;
-  --light-remove-add-highlight-color: #fff8dc;
-  --light-remove-highlight-color: #ffebee;
-  --coverage-covered: #e0f2f1;
-  --coverage-not-covered: #ffd1a4;
-
-  /* syntax colors */
-  --syntax-attr-color: #219;
-  --syntax-attribute-color: var(--primary-text-color);
-  --syntax-built_in-color: #30a;
-  --syntax-comment-color: #3f7f5f;
-  --syntax-default-color: var(--primary-text-color);
-  --syntax-doctag-weight: bold;
-  --syntax-function-color: var(--primary-text-color);
-  --syntax-keyword-color: #9e0069;
-  --syntax-link-color: #219;
-  --syntax-literal-color: #219;
-  --syntax-meta-color: #ff1717;
-  --syntax-meta-keyword-color: #219;
-  --syntax-number-color: #164;
-  --syntax-params-color: var(--primary-text-color);
-  --syntax-regexp-color: #fa8602;
-  --syntax-selector-attr-color: #fa8602;
-  --syntax-selector-class-color: #164;
-  --syntax-selector-id-color: #2a00ff;
-  --syntax-selector-pseudo-color: #fa8602;
-  --syntax-string-color: #2a00ff;
-  --syntax-tag-color: #170;
-  --syntax-template-tag-color: #fa8602;
-  --syntax-template-variable-color: #0000c0;
-  --syntax-title-color: #0000c0;
-  --syntax-type-color: #2a66d9;
-  --syntax-variable-color: var(--primary-text-color);
-
-  /* elevation */
-  --elevation-level-1: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 1px 3px 1px rgba(60, 64, 67, .15);
-  --elevation-level-2: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 2px 6px 2px rgba(60, 64, 67, .15);
-  --elevation-level-3: 0px 1px 3px 0px rgba(60, 64, 67, .30), 0px 4px 8px 3px rgba(60, 64, 67, .15);
-  --elevation-level-4: 0px 2px 3px 0px rgba(60, 64, 67, .30), 0px 6px 10px 4px rgba(60, 64, 67, .15);
-  --elevation-level-5: 0px 4px 4px 0px rgba(60, 64, 67, .30), 0px 8px 12px 6px rgba(60, 64, 67, .15);
-
-  /* misc */
-  --border-radius: 4px;
-  --reply-overlay-z-index: 1000;
-
-  /* paper and iron component overrides */
-  --iron-overlay-backdrop-background-color: black;
-  --iron-overlay-backdrop-opacity: 0.32;
-  --iron-overlay-backdrop: {
-    transition: none;
-  };
-}
-@media screen and (max-width: 50em) {
-  html {
-    --spacing-xxs: 1px;
-    --spacing-xs: 1px;
-    --spacing-s: 2px;
-    --spacing-m: 4px;
-    --spacing-l: 8px;
-    --spacing-xl: 12px;
-    --spacing-xxl: 16px;
-  }
-}
-</style></custom-style>`;
-
-document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
new file mode 100644
index 0000000..2706b22
--- /dev/null
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -0,0 +1,234 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and $_documentContainer is a global variable.
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+const $_documentContainer = document.createElement('template');
+
+$_documentContainer.innerHTML = `
+<custom-style id="light-theme"><style is="custom-style">
+  html {
+    /**
+     * When adding a new color variable make sure to also add it to the other
+     * theme files in the same directory.
+     *
+     * For colors prefer lower case hex colors.
+     *
+     * Note that plugins might be using these variables, so removing a variable
+     * can be a breaking change that should go into the release notes.
+     */
+
+    /* text colors */
+    --primary-text-color: black;
+    --link-color: #2a66d9;
+    --comment-text-color: black;
+    --deemphasized-text-color: #5F6368;
+    --default-button-text-color: #2a66d9;
+    --chip-selected-text-color: var(--default-button-text-color);
+    --error-text-color: red;
+    --primary-button-text-color: white;
+      /* Used on text color for change list that doesn't need user's attention. */
+    --reviewed-text-color: black;
+    --vote-text-color: black;
+    --status-text-color: white;
+    --tooltip-text-color: white;
+    --negative-red-text-color: #d93025;
+    --positive-green-text-color: #188038;
+
+    /* background colors */
+    /* primary background colors */
+    --background-color-primary: #ffffff;
+    --background-color-secondary: #f8f9fa;
+    --background-color-tertiary: #f1f3f4;
+    /* directly derived from primary background colors */
+    --chip-background-color: var(--background-color-tertiary);
+    --default-button-background-color: var(--background-color-primary);
+    --dialog-background-color: var(--background-color-primary);
+    --dropdown-background-color: var(--background-color-primary);
+    --expanded-background-color: var(--background-color-tertiary);
+    --select-background-color: var(--background-color-secondary);
+    --shell-command-background-color: var(--background-color-secondary);
+    --shell-command-decoration-background-color: var(--background-color-tertiary);
+    --table-header-background-color: var(--background-color-secondary);
+    --table-subheader-background-color: var(--background-color-tertiary);
+    --view-background-color: var(--background-color-primary);
+    /* unique background colors */
+    --assignee-highlight-color: #fcfad6;
+    --chip-selected-background-color: #e8f0fe;
+    --edit-mode-background-color: #ebf5fb;
+    --emphasis-color: #fff9c4;
+    --hover-background-color: rgba(161, 194, 250, 0.2);
+    --disabled-button-background-color: #e8eaed;
+    --primary-button-background-color: #2a66d9;
+    --selection-background-color: rgba(161, 194, 250, 0.1);
+    --tooltip-background-color: #333;
+    /* comment background colors */
+    --comment-background-color: #e8eaed;
+    --robot-comment-background-color: #e8f0fe;
+    --unresolved-comment-background-color: #fef7e0;
+    /* vote background colors */
+    --vote-color-approved: #9fcc6b;
+    --vote-color-disliked: #f7c4cb;
+    --vote-color-neutral: #ebf5fb;
+    --vote-color-recommended: #c9dfaf;
+    --vote-color-rejected: #f7a1ad;
+
+    /* misc colors */
+    --border-color: #e8e8e8;
+    --comment-separator-color: #dadce0;
+
+    /* status colors */
+    --status-merged: #188038;
+    --status-abandoned: #5f6368;
+    --status-wip: #795548;
+    --status-private: #a142f4;
+    --status-conflict: #d93025;
+    --status-active: #1976d2;
+    --status-ready: #b80672;
+    --status-custom: #681da8;
+
+    /* fonts */
+    --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+    --header-font-family: 'Open Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+    --monospace-font-family: 'Roboto Mono', 'SF Mono', 'Lucida Console', Monaco, monospace;
+    --font-size-code: 12px;     /* 12px mono */
+    --font-size-mono: .929rem;  /* 13px mono */
+    --font-size-small: .857rem; /* 12px */
+    --font-size-normal: 1rem;   /* 14px */
+    --font-size-h3: 1.143rem;   /* 16px */
+    --font-size-h2: 1.429rem;   /* 20px */
+    --font-size-h1: 1.714rem;   /* 24px */
+    --line-height-code: 1.334;      /* 16px */
+    --line-height-mono: 1.286rem;   /* 18px */
+    --line-height-small: 1.143rem;  /* 16px */
+    --line-height-normal: 1.429rem; /* 20px */
+    --line-height-h3: 1.714rem;     /* 24px */
+    --line-height-h2: 2rem;         /* 28px */
+    --line-height-h1: 2.286rem;     /* 32px */
+    --font-weight-normal: 400; /* 400 is the same as 'normal' */
+    --font-weight-bold: 500;
+    --font-weight-h1: 400;
+    --font-weight-h2: 400;
+    --font-weight-h3: 400;
+
+    /* spacing */
+    --spacing-xxs: 1px;
+    --spacing-xs: 2px;
+    --spacing-s: 4px;
+    --spacing-m: 8px;
+    --spacing-l: 12px;
+    --spacing-xl: 16px;
+    --spacing-xxl: 24px;
+
+    /* header and footer */
+    --footer-background-color: transparent;
+    --footer-border-top: none;
+    --header-background-color: var(--background-color-tertiary);
+    --header-border-bottom: 1px solid var(--border-color);
+    --header-border-image: '';
+    --header-box-shadow: none;
+    --header-padding: 0 var(--spacing-l);
+    --header-icon-size: 0em;
+    --header-icon: none;
+    --header-text-color: black;
+    --header-title-content: 'Gerrit';
+    --header-title-font-size: 1.75rem;
+
+    /* diff colors */
+    --dark-add-highlight-color: #aaf2aa;
+    --dark-rebased-add-highlight-color: #d7d7f9;
+    --dark-rebased-remove-highlight-color: #f7e8b7;
+    --dark-remove-highlight-color: #ffcdd2;
+    --diff-blank-background-color: var(--background-color-secondary);
+    --diff-context-control-background-color: #fff7d4;
+    --diff-context-control-border-color: #f6e6a5;
+    --diff-context-control-color: var(--deemphasized-text-color);
+    --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
+    --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
+    --diff-selection-background-color: #c7dbf9;
+    --diff-tab-indicator-color: var(--deemphasized-text-color);
+    --diff-trailing-whitespace-indicator: #ff9ad2;
+    --light-add-highlight-color: #d8fed8;
+    --light-rebased-add-highlight-color: #eef;
+    --light-remove-add-highlight-color: #fff8dc;
+    --light-remove-highlight-color: #ffebee;
+    --coverage-covered: #e0f2f1;
+    --coverage-not-covered: #ffd1a4;
+
+    /* syntax colors */
+    --syntax-attr-color: #219;
+    --syntax-attribute-color: var(--primary-text-color);
+    --syntax-built_in-color: #30a;
+    --syntax-comment-color: #3f7f5f;
+    --syntax-default-color: var(--primary-text-color);
+    --syntax-doctag-weight: bold;
+    --syntax-function-color: var(--primary-text-color);
+    --syntax-keyword-color: #9e0069;
+    --syntax-link-color: #219;
+    --syntax-literal-color: #219;
+    --syntax-meta-color: #ff1717;
+    --syntax-meta-keyword-color: #219;
+    --syntax-number-color: #164;
+    --syntax-params-color: var(--primary-text-color);
+    --syntax-regexp-color: #fa8602;
+    --syntax-selector-attr-color: #fa8602;
+    --syntax-selector-class-color: #164;
+    --syntax-selector-id-color: #2a00ff;
+    --syntax-selector-pseudo-color: #fa8602;
+    --syntax-string-color: #2a00ff;
+    --syntax-tag-color: #170;
+    --syntax-template-tag-color: #fa8602;
+    --syntax-template-variable-color: #0000c0;
+    --syntax-title-color: #0000c0;
+    --syntax-type-color: #2a66d9;
+    --syntax-variable-color: var(--primary-text-color);
+
+    /* elevation */
+    --elevation-level-1: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 1px 3px 1px rgba(60, 64, 67, .15);
+    --elevation-level-2: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 2px 6px 2px rgba(60, 64, 67, .15);
+    --elevation-level-3: 0px 1px 3px 0px rgba(60, 64, 67, .30), 0px 4px 8px 3px rgba(60, 64, 67, .15);
+    --elevation-level-4: 0px 2px 3px 0px rgba(60, 64, 67, .30), 0px 6px 10px 4px rgba(60, 64, 67, .15);
+    --elevation-level-5: 0px 4px 4px 0px rgba(60, 64, 67, .30), 0px 8px 12px 6px rgba(60, 64, 67, .15);
+
+    /* misc */
+    --border-radius: 4px;
+    --reply-overlay-z-index: 1000;
+
+    /* paper and iron component overrides */
+    --iron-overlay-backdrop-background-color: black;
+    --iron-overlay-backdrop-opacity: 0.32;
+    --iron-overlay-backdrop: {
+      transition: none;
+    };
+  }
+  @media screen and (max-width: 50em) {
+    html {
+      --spacing-xxs: 1px;
+      --spacing-xs: 1px;
+      --spacing-s: 2px;
+      --spacing-m: 4px;
+      --spacing-l: 8px;
+      --spacing-xl: 12px;
+      --spacing-xxl: 16px;
+    }
+  }
+</style></custom-style>`;
+
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.html b/polygerrit-ui/app/styles/themes/dark-theme.html
deleted file mode 100644
index 4248878..0000000
--- a/polygerrit-ui/app/styles/themes/dark-theme.html
+++ /dev/null
@@ -1,145 +0,0 @@
-<!--
-@license
-Copyright (C) 2019 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<dom-module id="dark-theme">
-  <custom-style><style is="custom-style">
-    html {
-      /**
-       * Sections and variables must stay consistent with app-theme.html.
-       *
-       * Only modify color variables in this theme file. dark-theme extends
-       * app-theme, so there is no need to repeat all variables, but for colors
-       * it does make sense to list them all: If you override one color, then
-       * you probably want to override all.
-       */
-
-      /* text colors */
-      --primary-text-color: #e8eaed;
-      --link-color: #8ab4f8;
-      --comment-text-color: var(--primary-text-color);
-      --deemphasized-text-color: #9aa0a6;
-      --default-button-text-color: #8ab4f8;
-      --error-text-color: red;
-      --primary-button-text-color: var(--primary-text-color);
-        /* Used on text color for change list doesn't need user's attention. */
-      --reviewed-text-color: #dadce0;
-      --tooltip-text-color: white;
-      --vote-text-color-recommended: #388e3c;
-      --vote-text-color-disliked: #d32f2f;
-
-      /* background colors */
-      /* primary background colors */
-      --background-color-primary: #202124;
-      --background-color-secondary: #2f3034;
-      --background-color-tertiary: #3b3d3f;
-      /* directly derived from primary background colors */
-      /*   empty, because inheriting from app-theme is just fine
-      /* unique background colors */
-      --assignee-highlight-color: #3a361c;
-      --edit-mode-background-color: #5c0a36;
-      --emphasis-color: #383f4a;
-      --hover-background-color: rgba(161, 194, 250, 0.2);
-      --disabled-button-background-color: #484a4d;
-      --primary-button-background-color: var(--link-color);
-      --selection-background-color: rgba(161, 194, 250, 0.1);
-      --tooltip-background-color: #111;
-      /* comment background colors */
-      --comment-background-color: #3c3f43;
-      --robot-comment-background-color: #1e3a5f;
-      --unresolved-comment-background-color: #614a19;
-      /* vote background colors */
-      --vote-color-approved: #7fb66b;
-      --vote-color-disliked: #bf6874;
-      --vote-color-neutral: #597280;
-      --vote-color-recommended: #3f6732;
-      --vote-color-rejected: #ac2d3e;
-
-      /* misc colors */
-      --border-color: #5f6368;
-      --comment-separator-color: var(--border-color);
-
-      /* fonts */
-      --font-weight-bold: 700; /* 700 is the same as 'bold' */
-
-      /* spacing */
-
-      /* header and footer */
-      --footer-background-color: var(--background-color-tertiary);
-      --footer-border-top: 1px solid var(--border-color);
-      --header-background-color: var(--background-color-tertiary);
-      --header-border-bottom: 1px solid var(--border-color);
-      --header-padding: 0 var(--spacing-l);
-      --header-text-color: var(--primary-text-color);
-
-      /* diff colors */
-      --dark-add-highlight-color: #133820;
-      --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
-      --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
-      --dark-remove-highlight-color: #62110f;
-      --diff-blank-background-color: var(--background-color-secondary);
-      --diff-context-control-background-color: #333311;
-      --diff-context-control-border-color: var(--border-color);
-      --diff-context-control-color: var(--deemphasized-text-color);
-      --diff-highlight-range-color: rgba(0, 100, 200, 0.5);
-      --diff-highlight-range-hover-color: rgba(0, 150, 255, 0.5);
-      --diff-selection-background-color: #3a71d8;
-      --diff-tab-indicator-color: var(--deemphasized-text-color);
-      --diff-trailing-whitespace-indicator: #ff9ad2;
-      --light-add-highlight-color: #0f401f;
-      --light-rebased-add-highlight-color: #487165;
-      --light-remove-add-highlight-color: #2f3f2f;
-      --light-remove-highlight-color: #320404;
-      --coverage-covered: #112826;
-      --coverage-not-covered: #6b3600;
-
-      /* syntax colors */
-      --syntax-attr-color: #80cbbf;
-      --syntax-attribute-color: var(--primary-text-color);
-      --syntax-built_in-color: #f7c369;
-      --syntax-comment-color: var(--deemphasized-text-color);
-      --syntax-default-color: var(--primary-text-color);
-      --syntax-doctag-weight: bold;
-      --syntax-function-color: var(--primary-text-color);
-      --syntax-keyword-color: #cd4cf0;
-      --syntax-link-color: #c792ea;
-      --syntax-literal-color: #eefff7;
-      --syntax-meta-color: #6d7eee;
-      --syntax-meta-keyword-color: #eefff7;
-      --syntax-number-color: #00998a;
-      --syntax-params-color: var(--primary-text-color);
-      --syntax-regexp-color: #f77669;
-      --syntax-selector-attr-color: #80cbbf;
-      --syntax-selector-class-color: #ffcb68;
-      --syntax-selector-id-color: #f77669;
-      --syntax-selector-pseudo-color: #c792ea;
-      --syntax-string-color: #c3e88d;
-      --syntax-tag-color: #f77669;
-      --syntax-template-tag-color: #c792ea;
-      --syntax-template-variable-color: #f77669;
-      --syntax-title-color: #75a5ff;
-      --syntax-type-color: #dd5f5f;
-      --syntax-variable-color: #f77669;
-
-      /* misc */
-
-      /* paper and iron component overrides */
-      --iron-overlay-backdrop-background-color: white;
-
-      /* rules applied to <html> */
-      background-color: var(--view-background-color);
-    }
-  </style></custom-style>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
new file mode 100644
index 0000000..2fa66f0
--- /dev/null
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -0,0 +1,175 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+function getStyleEl() {
+  const $_documentContainer = document.createElement('template');
+  $_documentContainer.innerHTML = `
+  <custom-style id="dark-theme"><style is="custom-style">
+    html {
+      /**
+       * Sections and variables must stay consistent with app-theme.js.
+       *
+       * Only modify color variables in this theme file. dark-theme extends
+       * app-theme, so there is no need to repeat all variables, but for colors
+       * it does make sense to list them all: If you override one color, then
+       * you probably want to override all.
+       */
+
+      /* text colors */
+      --primary-text-color: #e8eaed;
+      --link-color: #8ab4f8;
+      --comment-text-color: var(--primary-text-color);
+      --deemphasized-text-color: #9aa0a6;
+      --default-button-text-color: #8ab4f8;
+      --chip-selected-text-color: #d2e3fc;
+      --error-text-color: red;
+      --primary-button-text-color: black;
+        /* Used on text color for change list doesn't need user's attention. */
+      --reviewed-text-color: #dadce0;
+      --vote-text-color: black;
+      --status-text-color: black;
+      --tooltip-text-color: white;
+      --negative-red-text-color: #f28b82;
+      --positive-green-text-color: #81c995;
+
+      /* background colors */
+      /* primary background colors */
+      --background-color-primary: #202124;
+      --background-color-secondary: #2f3034;
+      --background-color-tertiary: #3b3d3f;
+      /* directly derived from primary background colors */
+      /*   empty, because inheriting from app-theme is just fine
+      /* unique background colors */
+      --assignee-highlight-color: #3a361c;
+      --chip-selected-background-color: #3c4455;
+      --edit-mode-background-color: #5c0a36;
+      --emphasis-color: #383f4a;
+      --hover-background-color: rgba(161, 194, 250, 0.2);
+      --disabled-button-background-color: #484a4d;
+      --primary-button-background-color: var(--link-color);
+      --selection-background-color: rgba(161, 194, 250, 0.1);
+      --tooltip-background-color: #111;
+      /* comment background colors */
+      --comment-background-color: #3c3f43;
+      --robot-comment-background-color: #1e3a5f;
+      --unresolved-comment-background-color: #614a19;
+      /* vote background colors */
+      --vote-color-approved: #7fb66b;
+      --vote-color-disliked: #bf6874;
+      --vote-color-neutral: #597280;
+      --vote-color-recommended: #3f6732;
+      --vote-color-rejected: #ac2d3e;
+
+      /* misc colors */
+      --border-color: #5f6368;
+      --comment-separator-color: var(--border-color);
+
+      /* status colors */
+      --status-merged: #5bb974;
+      --status-abandoned: #dadce0;
+      --status-wip: #bcaaa4;
+      --status-private: #d7aefb;
+      --status-conflict: #f28b82;
+      --status-active: #669df6;
+      --status-ready: #f439a0;
+      --status-custom: #af5cf7;
+
+      /* fonts */
+      --font-weight-bold: 700; /* 700 is the same as 'bold' */
+
+      /* spacing */
+
+      /* header and footer */
+      --footer-background-color: var(--background-color-tertiary);
+      --footer-border-top: 1px solid var(--border-color);
+      --header-background-color: var(--background-color-tertiary);
+      --header-border-bottom: 1px solid var(--border-color);
+      --header-padding: 0 var(--spacing-l);
+      --header-text-color: var(--primary-text-color);
+
+      /* diff colors */
+      --dark-add-highlight-color: #133820;
+      --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
+      --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
+      --dark-remove-highlight-color: #62110f;
+      --diff-blank-background-color: var(--background-color-secondary);
+      --diff-context-control-background-color: #333311;
+      --diff-context-control-border-color: var(--border-color);
+      --diff-context-control-color: var(--deemphasized-text-color);
+      --diff-highlight-range-color: rgba(0, 100, 200, 0.5);
+      --diff-highlight-range-hover-color: rgba(0, 150, 255, 0.5);
+      --diff-selection-background-color: #3a71d8;
+      --diff-tab-indicator-color: var(--deemphasized-text-color);
+      --diff-trailing-whitespace-indicator: #ff9ad2;
+      --light-add-highlight-color: #0f401f;
+      --light-rebased-add-highlight-color: #487165;
+      --light-remove-add-highlight-color: #2f3f2f;
+      --light-remove-highlight-color: #320404;
+      --coverage-covered: #112826;
+      --coverage-not-covered: #6b3600;
+
+      /* syntax colors */
+      --syntax-attr-color: #80cbbf;
+      --syntax-attribute-color: var(--primary-text-color);
+      --syntax-built_in-color: #f7c369;
+      --syntax-comment-color: var(--deemphasized-text-color);
+      --syntax-default-color: var(--primary-text-color);
+      --syntax-doctag-weight: bold;
+      --syntax-function-color: var(--primary-text-color);
+      --syntax-keyword-color: #cd4cf0;
+      --syntax-link-color: #c792ea;
+      --syntax-literal-color: #eefff7;
+      --syntax-meta-color: #6d7eee;
+      --syntax-meta-keyword-color: #eefff7;
+      --syntax-number-color: #00998a;
+      --syntax-params-color: var(--primary-text-color);
+      --syntax-regexp-color: #f77669;
+      --syntax-selector-attr-color: #80cbbf;
+      --syntax-selector-class-color: #ffcb68;
+      --syntax-selector-id-color: #f77669;
+      --syntax-selector-pseudo-color: #c792ea;
+      --syntax-string-color: #c3e88d;
+      --syntax-tag-color: #f77669;
+      --syntax-template-tag-color: #c792ea;
+      --syntax-template-variable-color: #f77669;
+      --syntax-title-color: #75a5ff;
+      --syntax-type-color: #dd5f5f;
+      --syntax-variable-color: #f77669;
+
+      /* misc */
+
+      /* paper and iron component overrides */
+      --iron-overlay-backdrop-background-color: white;
+
+      /* rules applied to <html> */
+      background-color: var(--view-background-color);
+    }
+  </style></custom-style>`;
+
+  return $_documentContainer;
+}
+
+export function applyTheme() {
+  document.head.appendChild(getStyleEl().content);
+}
+
+export function removeTheme() {
+  const darkThemeEls = document.head.querySelectorAll('#dark-theme');
+  if (darkThemeEls.length) {
+    darkThemeEls.forEach(darkThemeEl => darkThemeEl.remove());
+  }
+}
diff --git a/polygerrit-ui/app/test/a11y-test-utils.js b/polygerrit-ui/app/test/a11y-test-utils.js
new file mode 100644
index 0000000..a687e07
--- /dev/null
+++ b/polygerrit-ui/app/test/a11y-test-utils.js
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import './common-test-setup-karma.js';
+
+// Run a11y audit on test fixture
+// The code is inspired by the
+// https://github.com/Polymer/web-component-tester/blob/master/data/a11ySuite.js
+export async function runA11yAudit(fixture, ignoredRules) {
+  fixture.instantiate();
+  await flush();
+  const axsConfig = new axs.AuditConfiguration();
+  axsConfig.scope = document.body;
+  axsConfig.showUnsupportedRulesWarning = false;
+  axsConfig.auditRulesToIgnore = ignoredRules;
+
+  const auditResults = axs.Audit.run(axsConfig);
+  const errors = [];
+  auditResults.forEach((result, index) => {
+    // only show applicable tests
+    if (result.result === 'FAIL') {
+      const title = result.rule.heading;
+      // fail test if audit result is FAIL
+      const error = axs.Audit.accessibilityErrorMessage(result);
+      errors.push(`${title}: ${error}`);
+    }
+  });
+  if (errors.length > 0) {
+    assert.fail(errors.join('\n') + '\n');
+  }
+}
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.js b/polygerrit-ui/app/test/common-test-setup-karma.js
new file mode 100644
index 0000000..cc934fc
--- /dev/null
+++ b/polygerrit-ui/app/test/common-test-setup-karma.js
@@ -0,0 +1,188 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import './common-test-setup.js';
+import '@polymer/test-fixture/test-fixture.js';
+import 'chai/chai.js';
+self.assert = window.chai.assert;
+self.expect = window.chai.expect;
+
+window.addEventListener('error', e => {
+  // For uncaught error mochajs doesn't print the full stack trace.
+  // We should print it ourselves.
+  console.error(e.error.stack.toString());
+});
+
+let originalOnBeforeUnload;
+
+suiteSetup(() => {
+  // This suiteSetup() method is called only once before all tests
+
+  // Can't use window.addEventListener("beforeunload",...) here,
+  // the handler is raised too late.
+  originalOnBeforeUnload = window.onbeforeunload;
+  window.onbeforeunload = e => {
+    // If a test reloads a page, we can't prevent it.
+    // However we can print earror and the stack trace with assert.fail
+    try {
+      throw new Error();
+    } catch (e) {
+      console.error('Page reloading attempt detected.');
+      console.error(e.stack.toString());
+    }
+    originalOnBeforeUnload(e);
+  };
+});
+
+suiteTeardown(() => {
+  // This suiteTeardown() method is called only once after all tests
+  window.onbeforeunload = originalOnBeforeUnload;
+});
+
+// Tests can use fake timers (sandbox.useFakeTimers)
+// Keep the original one for use in test utils methods.
+const nativeSetTimeout = window.setTimeout;
+
+/**
+ * Triggers a flush of any pending events, observations, etc and calls you back
+ * after they have been processed if callback is passed; otherwise returns
+ * promise.
+ *
+ * @param {function()} callback
+ */
+function flush(callback) {
+  // Ideally, this function would be a call to Polymer.dom.flush, but that
+  // doesn't support a callback yet
+  // (https://github.com/Polymer/polymer-dev/issues/851)
+  window.Polymer.dom.flush();
+  if (callback) {
+    nativeSetTimeout(callback, 0);
+  } else {
+    return new Promise(resolve => {
+      nativeSetTimeout(resolve, 0);
+    });
+  }
+}
+
+self.flush = flush;
+
+class TestFixtureIdProvider {
+  static get instance() {
+    if (!TestFixtureIdProvider._instance) {
+      TestFixtureIdProvider._instance = new TestFixtureIdProvider();
+    }
+    return TestFixtureIdProvider._instance;
+  }
+
+  constructor() {
+    this.fixturesCount = 1;
+  }
+
+  generateNewFixtureId() {
+    this.fixturesCount++;
+    return `fixture-${this.fixturesCount}`;
+  }
+}
+
+class TestFixture {
+  constructor(fixtureId) {
+    this.fixtureId = fixtureId;
+  }
+
+  /**
+   * Create an instance of a fixture's template.
+   *
+   * @param {Object} model - see Data-bound sections at
+   *   https://www.webcomponents.org/element/@polymer/test-fixture
+   * @return {HTMLElement | HTMLElement[]} - if the fixture's template contains
+   *   a single element, returns the appropriated instantiated element.
+   *   Otherwise, it return an array of all instantiated elements from the
+   *   template.
+   */
+  instantiate(model) {
+    // The window.fixture method is defined in common-test-setup.js
+    return window.fixture(this.fixtureId, model);
+  }
+}
+
+/**
+ * Wraps provided template to a test-fixture tag and adds test-fixture to
+ * the document. You can use the html function to create a template.
+ *
+ * Example:
+ * import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+ *
+ * // Create fixture at the root level of a test file
+ * const basicTestFixture = fixtureFromTemplate(html`
+ *   <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
+ *   <ul>
+ *    <li>A</li>
+ *    <li>B</li>
+ *    <li>C</li>
+ *    <li>D</li>
+ *   </ul>
+ * `);
+ * ...
+ * // Instantiate fixture when needed:
+ *
+ * suite('example') {
+ *   let elements;
+ *   setup(() => {
+ *     elements = basicTestFixture.instantiate();
+ *   });
+ * }
+ *
+ * @param {HTMLTemplateElement} template - a template for a fixture
+ * @return {TestFixture} - the instance of TestFixture class
+ */
+function fixtureFromTemplate(template) {
+  const fixtureId = TestFixtureIdProvider.instance.generateNewFixtureId();
+  const testFixture = document.createElement('test-fixture');
+  testFixture.setAttribute('id', fixtureId);
+  testFixture.appendChild(template);
+  document.body.appendChild(testFixture);
+  return new TestFixture(fixtureId);
+}
+
+/**
+ * Wraps provided tag to a test-fixture/template tags and adds test-fixture
+ * to the document.
+ *
+ * Example:
+ *
+ * // Create fixture at the root level of a test file
+ * const basicTestFixture = fixtureFromElement('gr-diff-view');
+ * ...
+ * // Instantiate fixture when needed:
+ *
+ * suite('example') {
+ *   let element;
+ *   setup(() => {
+ *     element = basicTestFixture.instantiate();
+ *   });
+ * }
+ *
+ * @param {HTMLTemplateElement} template - a template for a fixture
+ * @return {TestFixture} - the instance of TestFixture class
+ */
+function fixtureFromElement(tagName) {
+  const template = document.createElement('template');
+  template.innerHTML = `<${tagName}></${tagName}>`;
+  return fixtureFromTemplate(template);
+}
+
+window.fixtureFromTemplate = fixtureFromTemplate;
+window.fixtureFromElement = fixtureFromElement;
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
index aea24da..19465a3 100644
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -14,14 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../scripts/bundled-polymer.js';
 
+// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
+// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
+import '../scripts/bundled-polymer.js';
+import './test-app-context-init.js';
 import 'polymer-resin/standalone/polymer-resin.js';
 import '@polymer/iron-test-helpers/iron-test-helpers.js';
 import './test-router.js';
-import {SafeTypes} from '../behaviors/safe-types-behavior/safe-types-behavior.js';
-import {initAppContext} from '../services/app-context-init.js';
 import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnlyResetRestApi} from '../elements/shared/gr-js-api-interface/gr-plugin-rest-api.js';
+import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
+import {cleanupTestUtils, TestKeyboardShortcutBinder} from './test-utils.js';
+import {flushDebouncers} from '@polymer/polymer/lib/utils/debounce';
+import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import sinon from 'sinon/pkg/sinon-esm.js';
+import {safeTypesBridge} from '../utils/safe-types-util.js';
+window.sinon = sinon;
 
 security.polymer_resin.install({
   allowedIdentifierPrefixes: [''],
@@ -36,70 +45,102 @@
         JSON.stringify(args));
     }
   },
-  safeTypesBridge: SafeTypes.safeTypesBridge,
+  safeTypesBridge,
 });
 
-// Default implementations of 'fixture' and 'stub' methods in
-// web-component-tester are incorrect. Default methods calls mocha teardown
-// method to register cleanup actions. Each call to the teardown method adds
-// additional 'afterEach' hook to a suite.
-// As a result, if a suite's setup(..) method calls fixture(..) or stub(..)
-// method, then additional afterEach hook is registered before each test.
-// In overall, afterEach hook is called testCount^2 instead of testCount.
-// When tests runs with the wct test runner, the runner adds listener for
-// the 'afterEach' and tries to make some UI and log udpates. These updates
-// are quite heavy, and after about 40-50 tests each test waste 0.5-1seconds.
-//
-// Our implementation uses global teardown to clean up everything. mocha calls
-// global teardown after each test. The cleanups array stores all functions
-// which must be called after a test ends.
-//
-// Note, that fixture(...) and stub(..) methods are registered different by
-// WCT. This is why these methods implemented slightly different here.
 const cleanups = [];
-if (!window.fixture) {
-  window.fixture = function(fixtureId, model) {
-    // This method is inspired by WCT method
-    cleanups.push(() => document.getElementById(fixtureId).restore());
-    return document.getElementById(fixtureId).create(model);
-  };
-} else {
-  throw new Error('window.fixture must be set before wct sets it');
-}
 
-// On the first call to the setup, WCT installs window.fixture
-// and widnow.stub methods
+// For karma always set our implementation
+// (karma doesn't provide the fixture method)
+window.fixture = function(fixtureId, model) {
+  // This method is inspired by web-component-tester method
+  cleanups.push(() => document.getElementById(fixtureId).restore());
+  return document.getElementById(fixtureId).create(model);
+};
+
 setup(() => {
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
   assert.equal(cleanups.length, 0);
-
-  _testOnly_resetPluginLoader();
-  initAppContext();
+  // The following calls is nessecary to avoid influence of previously executed
+  // tests.
+  TestKeyboardShortcutBinder.push();
+  const mgr = _testOnly_getShortcutManagerInstance();
+  assert.equal(mgr.activeHosts.size, 0);
+  assert.equal(mgr.listeners.size, 0);
+  document.getSelection().removeAllRanges();
+  const pl = _testOnly_resetPluginLoader();
+  // For testing, always init with empty plugin list
+  // Since when serve in gr-app, we always retrieve the list
+  // from project config and init loading after that, all
+  // `awaitPluginsLoaded` will rely on that to kick off,
+  // in testing, we want to kick start this earlier.
+  // You still can manually call _testOnly_resetPluginLoader
+  // to reset this behavior if you need to test something specific.
+  pl.loadPlugins([]);
+  _testOnlyResetGrRestApiSharedObjects();
+  _testOnlyResetRestApi();
 });
 
-if (window.stub) {
-  window.stub = function(tagName, implementation) {
-    // This method is inspired by WCT method
-    const proto = document.createElement(tagName).constructor.prototype;
-    const stubs = Object.keys(implementation)
-        .map(key => sinon.stub(proto, key, implementation[key]));
-    cleanups.push(() => {
-      stubs.forEach(stub => {
-        stub.restore();
-      });
+// For karma always set our implementation
+// (karma doesn't provide the stub method)
+window.stub = function(tagName, implementation) {
+  // This method is inspired by web-component-tester method
+  const proto = document.createElement(tagName).constructor.prototype;
+  const stubs = Object.keys(implementation)
+      .map(key => sinon.stub(proto, key).callsFake(implementation[key]));
+  cleanups.push(() => {
+    stubs.forEach(stub => {
+      stub.restore();
     });
-  };
-} else {
-  throw new Error('window.stub must be set after wct sets it');
+  });
+};
+
+// Very simple function to catch unexpected elements in documents body.
+// It can't catch everything, but in most cases it is enough.
+function checkChildAllowed(element) {
+  const allowedTags = ['SCRIPT', 'IRON-A11Y-ANNOUNCER'];
+  if (allowedTags.includes(element.tagName)) {
+    return;
+  }
+  if (element.tagName === 'TEST-FIXTURE') {
+    if (element.children.length == 0 ||
+        (element.children.length == 1 &&
+        element.children[0].tagName === 'TEMPLATE')) {
+      return;
+    }
+    assert.fail(`Test fixture
+        ${element.outerHTML}` +
+        `isn't resotred after the test is finished. Please ensure that ` +
+        `restore() method is called for this test-fixture. Usually the call` +
+        `happens automatically.`);
+    return;
+  }
+  if (element.tagName === 'DIV' && element.id === 'gr-hovercard-container' &&
+      element.childNodes.length === 0) {
+    return;
+  }
+  assert.fail(
+      `The following node remains in document after the test:
+      ${element.tagName}
+      Outer HTML:
+      ${element.outerHTML},
+      Stack trace:
+      ${element.stackTrace}`);
+}
+function checkGlobalSpace() {
+  for (const child of document.body.children) {
+    checkChildAllowed(child);
+  }
 }
 
 teardown(() => {
-  // WCT incorrectly uses teardown method in the 'fixture' and 'stub'
-  // implementations. This leads to slowdown WCT tests after each tests.
-  // I.e. more tests in a file - longer it takes.
-  // For example, gr-file-list_test.html takes approx 40 second without
-  // a fix and 10 seconds with our implementation of fixture and stub.
+  sinon.restore();
+  cleanupTestUtils();
   cleanups.forEach(cleanup => cleanup());
   cleanups.splice(0);
+  TestKeyboardShortcutBinder.pop();
+  checkGlobalSpace();
+  // Clean Polymer debouncer queue, so next tests will not be affected.
+  flushDebouncers();
 });
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
deleted file mode 100644
index 63df0be..0000000
--- a/polygerrit-ui/app/test/index.html
+++ /dev/null
@@ -1,34 +0,0 @@
-<!-- Copyright (C) 2020 The Android Open Source Project -->
-<!-- -->
-<!-- Licensed under the Apache License, Version 2.0 (the "License"); -->
-<!-- you may not use this file except in compliance with the License. -->
-<!-- You may obtain a copy of the License at -->
-<!-- -->
-<!-- http://www.apache.org/licenses/LICENSE-2.0 -->
-<!-- -->
-<!-- Unless required by applicable law or agreed to in writing, software -->
-<!-- distributed under the License is distributed on an "AS IS" BASIS, -->
-<!-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -->
-<!-- See the License for the specific language governing permissions and -->
-<!-- limitations under the License. -->
-
-<!DOCTYPE html>
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>Elements Test Runner</title>
-<script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-<script src="/node_modules/web-component-tester/browser.js"></script>
-<style>
-  /* Prevent horizontal scrolling on page.
-   New version of web-component-tester creates very narrow iframe */
-  #subsuites {
-    width: 1500px !important;
-  }
-</style>
-<script type="module">
-    import {config, testsPerFileString} from './suite_conf.js';
-    import {getSuiteTests} from './tests.js';
-    WCT.loadSuites(
-        getSuiteTests(testsPerFileString, config.splitIndex, config.splitCount));
-</script>
diff --git a/polygerrit-ui/app/test/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
new file mode 100644
index 0000000..f2ca48c
--- /dev/null
+++ b/polygerrit-ui/app/test/mocks/comment-api.js
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
+
+/**
+ * This is an "abstract" class for tests. The descendant must define a template
+ * for this element and a tagName - see createCommentApiMockWithTemplateElement below
+ */
+class CommentApiMock extends GestureEventListeners(
+    LegacyElementMixin(
+        PolymerElement)) {
+  static get properties() {
+    return {
+      _changeComments: Object,
+    };
+  }
+
+  loadComments() {
+    return this._reloadComments();
+  }
+
+  /**
+   * For the purposes of the mock, _reloadDrafts is not included because its
+   * response is the same type as reloadComments, just makes less API
+   * requests. Since this is for test purposes/mocked data anyway, keep this
+   * file simpler by just using _reloadComments here instead.
+   */
+  _reloadDraftsWithCallback(e) {
+    return this._reloadComments().then(() => e.detail.resolve());
+  }
+
+  _reloadComments() {
+    return this.$.commentAPI.loadAll(this._changeNum)
+        .then(comments => {
+          this._changeComments = this.$.commentAPI._changeComments;
+        });
+  }
+}
+
+/**
+ * Creates a new element which is descendant of CommentApiMock with specified
+ * template. Additionally, the method registers a tagName for this element.
+ *
+ * Each tagName must be a unique accross all tests.
+ */
+export function createCommentApiMockWithTemplateElement(tagName, template) {
+  const elementClass = class extends CommentApiMock {
+    static get is() { return tagName; }
+
+    static get template() { return template; }
+  };
+  customElements.define(tagName, elementClass);
+  return elementClass;
+}
diff --git a/polygerrit-ui/app/test/mock-diff-response.js b/polygerrit-ui/app/test/mocks/diff-response.js
similarity index 100%
rename from polygerrit-ui/app/test/mock-diff-response.js
rename to polygerrit-ui/app/test/mocks/diff-response.js
diff --git a/polygerrit-ui/app/test/suite_conf.js b/polygerrit-ui/app/test/suite_conf.js
deleted file mode 100644
index 82870fe..0000000
--- a/polygerrit-ui/app/test/suite_conf.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * This file is an example of the content.
- * The real file is generated by the wct_test.sh script.
- * Content of this file doesn't affect wct tests.
- * Generated files contains all information to split test files between different tests
- */
-
-export const config = {
-  splitIndex: 0, // Index for split (wct_suite creates several sh_test, each split has its own index)
-  splitCount: 1, // Defines the number of splits
-};
-
-/**
- * testsPerFileString contains information about number of tests in each file
- * This information is not precise. It is used to split test files between WCT suites more evenly.
- */
-export const testsPerFileString = `
-./elements/change-list/gr-repo-header/gr-repo-header_test.html:1
-./elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html:25
-./elements/gr-app_test.html:4
-./behaviors/gr-admin-nav-behavior/gr-admin-nav-behavior_test.html:13
-./behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html:19
-`;
diff --git a/polygerrit-ui/app/test/test-app-context-init.js b/polygerrit-ui/app/test/test-app-context-init.js
new file mode 100644
index 0000000..88232cd3
--- /dev/null
+++ b/polygerrit-ui/app/test/test-app-context-init.js
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Init app context before any other imports
+import {initAppContext} from '../services/app-context-init.js';
+import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock.js';
+import {appContext} from '../services/app-context.js';
+
+initAppContext();
+
+function setMock(serviceName, setupMock) {
+  Object.defineProperty(appContext, serviceName, {
+    get() {
+      return setupMock;
+    },
+  });
+}
+setMock('reportingService', grReportingMock);
diff --git a/polygerrit-ui/app/test/test-utils.js b/polygerrit-ui/app/test/test-utils.js
index 77d8e22..e1eadef 100644
--- a/polygerrit-ui/app/test/test-utils.js
+++ b/polygerrit-ui/app/test/test-utils.js
@@ -18,6 +18,7 @@
 import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader.js';
 import {testOnly_resetInternalState} from '../elements/shared/gr-js-api-interface/gr-api-utils.js';
 import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 
 export const mockPromise = () => {
   let res;
@@ -29,10 +30,96 @@
 };
 export const isHidden = el => getComputedStyle(el).display === 'none';
 
+// Some tests/elements can define its own binding. We want to restore bindings
+// at the end of the test. The TestKeyboardShortcutBinder store bindings in
+// stack, so it is possible to override bindings in nested suites.
+export class TestKeyboardShortcutBinder {
+  static push() {
+    if (!this.stack) {
+      this.stack = [];
+    }
+    const testBinder = new TestKeyboardShortcutBinder();
+    this.stack.push(testBinder);
+    return _testOnly_getShortcutManagerInstance();
+  }
+
+  static pop() {
+    this.stack.pop()._restoreShortcuts();
+  }
+
+  constructor() {
+    this._originalBinding = new Map(
+        _testOnly_getShortcutManagerInstance().bindings);
+  }
+
+  _restoreShortcuts() {
+    const bindings = _testOnly_getShortcutManagerInstance().bindings;
+    bindings.clear();
+    this._originalBinding.forEach((value, key) => {
+      bindings.set(key, value);
+    });
+  }
+}
+
 // Provide reset plugins function to clear installed plugins between tests.
 // No gr-app found (running tests)
 export const resetPlugins = () => {
   testOnly_resetInternalState();
   _testOnly_resetEndpoints();
-  _testOnly_resetPluginLoader();
+  const pl = _testOnly_resetPluginLoader();
+  pl.loadPlugins([]);
 };
+
+const cleanups = [];
+
+function registerTestCleanup(cleanupCallback) {
+  cleanups.push(cleanupCallback);
+}
+
+export function cleanupTestUtils() {
+  cleanups.forEach(cleanup => cleanup());
+  cleanups.splice(0);
+}
+
+export function stubBaseUrl(newUrl) {
+  const originalCanonicalPath = window.CANONICAL_PATH;
+  window.CANONICAL_PATH = newUrl;
+  registerTestCleanup(() => window.CANONICAL_PATH = originalCanonicalPath);
+}
+
+export function generateChange(options) {
+  const change = {
+    _number: 42,
+  };
+  const revisionIdStart = 1;
+  const messageIdStart = 1000;
+  // We want to distinguish between empty arrays/objects and undefined
+  // If an option is not set - the appropriate property is not set
+  // If an options is set - the property always set
+  if (typeof options.revisionsCount !== 'undefined') {
+    const revisions = {};
+    for (let i = 0; i < options.revisionsCount; i++) {
+      const revisionId = (i + revisionIdStart).toString(16);
+      revisions[revisionId] = {
+        _number: i+1,
+        commit: {parents: []},
+      };
+    }
+    change.revisions = revisions;
+  }
+  if (typeof options.messagesCount !== 'undefined') {
+    const messages = [];
+    for (let i = 0; i < options.messagesCount; i++) {
+      messages.push({
+        id: (i + messageIdStart).toString(16),
+        date: new Date(2020, 1, 1),
+        message: `This is a message N${i + 1}`,
+      });
+    }
+    change.messages = messages;
+  }
+  if (options.status) {
+    change.status = options.status;
+  }
+  return change;
+}
diff --git a/polygerrit-ui/app/test/tests.js b/polygerrit-ui/app/test/tests.js
deleted file mode 100644
index 795949d..0000000
--- a/polygerrit-ui/app/test/tests.js
+++ /dev/null
@@ -1,339 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-const testFiles = [];
-const scriptsPath = '../scripts/';
-const elementsPath = '../elements/';
-const behaviorsPath = '../behaviors/';
-const servicesPath = '../services/';
-
-// Elements tests.
-/* eslint-disable max-len */
-const elements = [
-  // This seemed to be flakey when it was farther down the list. Keep at the
-  // beginning.
-  'gr-app_test.html',
-  'admin/gr-access-section/gr-access-section_test.html',
-  'admin/gr-admin-group-list/gr-admin-group-list_test.html',
-  'admin/gr-admin-view/gr-admin-view_test.html',
-  'admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html',
-  'admin/gr-create-change-dialog/gr-create-change-dialog_test.html',
-  'admin/gr-create-group-dialog/gr-create-group-dialog_test.html',
-  'admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html',
-  'admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html',
-  'admin/gr-group-audit-log/gr-group-audit-log_test.html',
-  'admin/gr-group-members/gr-group-members_test.html',
-  'admin/gr-group/gr-group_test.html',
-  'admin/gr-permission/gr-permission_test.html',
-  'admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.html',
-  'admin/gr-plugin-list/gr-plugin-list_test.html',
-  'admin/gr-repo-access/gr-repo-access_test.html',
-  'admin/gr-repo-command/gr-repo-command_test.html',
-  'admin/gr-repo-commands/gr-repo-commands_test.html',
-  'admin/gr-repo-dashboards/gr-repo-dashboards_test.html',
-  'admin/gr-repo-detail-list/gr-repo-detail-list_test.html',
-  'admin/gr-repo-list/gr-repo-list_test.html',
-  'admin/gr-repo-plugin-config/gr-repo-plugin-config_test.html',
-  'admin/gr-repo/gr-repo_test.html',
-  'admin/gr-rule-editor/gr-rule-editor_test.html',
-  'change-list/gr-change-list-item/gr-change-list-item_test.html',
-  'change-list/gr-change-list-view/gr-change-list-view_test.html',
-  'change-list/gr-change-list/gr-change-list_test.html',
-  'change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.html',
-  'change-list/gr-create-change-help/gr-create-change-help_test.html',
-  'change-list/gr-dashboard-view/gr-dashboard-view_test.html',
-  'change-list/gr-repo-header/gr-repo-header_test.html',
-  'change-list/gr-user-header/gr-user-header_test.html',
-  'change/gr-change-actions/gr-change-actions_test.html',
-  'change/gr-change-metadata/gr-change-metadata-it_test.html',
-  'change/gr-change-metadata/gr-change-metadata_test.html',
-  'change/gr-change-requirements/gr-change-requirements_test.html',
-  'change/gr-change-view/gr-change-view_test.html',
-  'change/gr-comment-list/gr-comment-list_test.html',
-  'change/gr-commit-info/gr-commit-info_test.html',
-  'change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.html',
-  'change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html',
-  'change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.html',
-  'change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html',
-  'change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html',
-  'change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html',
-  'change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.html',
-  'change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.html',
-  'change/gr-download-dialog/gr-download-dialog_test.html',
-  'change/gr-file-list-header/gr-file-list-header_test.html',
-  'change/gr-file-list/gr-file-list_test.html',
-  'change/gr-included-in-dialog/gr-included-in-dialog_test.html',
-  'change/gr-label-score-row/gr-label-score-row_test.html',
-  'change/gr-label-scores/gr-label-scores_test.html',
-  'change/gr-message/gr-message_test.html',
-  'change/gr-messages-list/gr-messages-list_test.html',
-  'change/gr-messages-list/gr-messages-list-experimental_test.html',
-  'change/gr-related-changes-list/gr-related-changes-list_test.html',
-  'change/gr-reply-dialog/gr-reply-dialog-it_test.html',
-  'change/gr-reply-dialog/gr-reply-dialog_test.html',
-  'change/gr-reviewer-list/gr-reviewer-list_test.html',
-  'change/gr-thread-list/gr-thread-list_test.html',
-  'change/gr-upload-help-dialog/gr-upload-help-dialog_test.html',
-  'core/gr-account-dropdown/gr-account-dropdown_test.html',
-  'core/gr-error-dialog/gr-error-dialog_test.html',
-  'core/gr-error-manager/gr-error-manager_test.html',
-  'core/gr-key-binding-display/gr-key-binding-display_test.html',
-  'core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.html',
-  'core/gr-main-header/gr-main-header_test.html',
-  'core/gr-navigation/gr-navigation_test.html',
-  'core/gr-reporting/gr-reporting_test.html',
-  'core/gr-router/gr-router_test.html',
-  'core/gr-search-bar/gr-search-bar_test.html',
-  'core/gr-smart-search/gr-smart-search_test.html',
-  'diff/gr-comment-api/gr-comment-api_test.html',
-  'diff/gr-coverage-layer/gr-coverage-layer_test.html',
-  'diff/gr-diff-builder/gr-diff-builder-element_test.html',
-  'diff/gr-diff-builder/gr-diff-builder-unified_test.html',
-  'diff/gr-diff-cursor/gr-diff-cursor_test.html',
-  'diff/gr-diff-highlight/gr-annotation_test.html',
-  'diff/gr-diff-highlight/gr-diff-highlight_test.html',
-  'diff/gr-diff-host/gr-diff-host_test.html',
-  'diff/gr-diff-mode-selector/gr-diff-mode-selector_test.html',
-  'diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.html',
-  'diff/gr-diff-processor/gr-diff-processor_test.html',
-  'diff/gr-diff-selection/gr-diff-selection_test.html',
-  'diff/gr-diff-view/gr-diff-view_test.html',
-  'diff/gr-diff/gr-diff-group_test.html',
-  'diff/gr-diff/gr-diff_test.html',
-  'diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.html',
-  'diff/gr-patch-range-select/gr-patch-range-select_test.html',
-  'diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html',
-  'diff/gr-selection-action-box/gr-selection-action-box_test.html',
-  'diff/gr-syntax-layer/gr-syntax-layer_test.html',
-  'documentation/gr-documentation-search/gr-documentation-search_test.html',
-  'edit/gr-default-editor/gr-default-editor_test.html',
-  'edit/gr-edit-controls/gr-edit-controls_test.html',
-  'edit/gr-edit-file-controls/gr-edit-file-controls_test.html',
-  'edit/gr-editor-view/gr-editor-view_test.html',
-  'plugins/gr-admin-api/gr-admin-api_test.html',
-  'plugins/gr-styles-api/gr-styles-api_test.html',
-  'plugins/gr-attribute-helper/gr-attribute-helper_test.html',
-  'plugins/gr-dom-hooks/gr-dom-hooks_test.html',
-  'plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html',
-  'plugins/gr-event-helper/gr-event-helper_test.html',
-  'plugins/gr-external-style/gr-external-style_test.html',
-  'plugins/gr-plugin-host/gr-plugin-host_test.html',
-  'plugins/gr-popup-interface/gr-plugin-popup_test.html',
-  'plugins/gr-popup-interface/gr-popup-interface_test.html',
-  'plugins/gr-repo-api/gr-repo-api_test.html',
-  'plugins/gr-settings-api/gr-settings-api_test.html',
-  'plugins/gr-theme-api/gr-theme-api_test.html',
-  'settings/gr-account-info/gr-account-info_test.html',
-  'settings/gr-agreements-list/gr-agreements-list_test.html',
-  'settings/gr-change-table-editor/gr-change-table-editor_test.html',
-  'settings/gr-cla-view/gr-cla-view_test.html',
-  'settings/gr-edit-preferences/gr-edit-preferences_test.html',
-  'settings/gr-email-editor/gr-email-editor_test.html',
-  'settings/gr-gpg-editor/gr-gpg-editor_test.html',
-  'settings/gr-group-list/gr-group-list_test.html',
-  'settings/gr-http-password/gr-http-password_test.html',
-  'settings/gr-identities/gr-identities_test.html',
-  'settings/gr-menu-editor/gr-menu-editor_test.html',
-  'settings/gr-registration-dialog/gr-registration-dialog_test.html',
-  'settings/gr-settings-view/gr-settings-view_test.html',
-  'settings/gr-ssh-editor/gr-ssh-editor_test.html',
-  'settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html',
-  'shared/gr-event-interface/gr-event-interface_test.html',
-  'shared/gr-account-entry/gr-account-entry_test.html',
-  'shared/gr-account-label/gr-account-label_test.html',
-  'shared/gr-account-list/gr-account-list_test.html',
-  'shared/gr-account-link/gr-account-link_test.html',
-  'shared/gr-alert/gr-alert_test.html',
-  'shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html',
-  'shared/gr-autocomplete/gr-autocomplete_test.html',
-  'shared/gr-avatar/gr-avatar_test.html',
-  'shared/gr-button/gr-button_test.html',
-  'shared/gr-change-star/gr-change-star_test.html',
-  'shared/gr-change-status/gr-change-status_test.html',
-  'shared/gr-comment-thread/gr-comment-thread_test.html',
-  'shared/gr-comment/gr-comment_test.html',
-  'shared/gr-copy-clipboard/gr-copy-clipboard_test.html',
-  'shared/gr-count-string-formatter/gr-count-string-formatter_test.html',
-  'shared/gr-cursor-manager/gr-cursor-manager_test.html',
-  'shared/gr-date-formatter/gr-date-formatter_test.html',
-  'shared/gr-dialog/gr-dialog_test.html',
-  'shared/gr-diff-preferences/gr-diff-preferences_test.html',
-  'shared/gr-download-commands/gr-download-commands_test.html',
-  'shared/gr-dropdown/gr-dropdown_test.html',
-  'shared/gr-dropdown-list/gr-dropdown-list_test.html',
-  'shared/gr-editable-content/gr-editable-content_test.html',
-  'shared/gr-editable-label/gr-editable-label_test.html',
-  'shared/gr-formatted-text/gr-formatted-text_test.html',
-  'shared/gr-hovercard/gr-hovercard_test.html',
-  'shared/gr-hovercard-account/gr-hovercard-account_test.html',
-  'shared/gr-js-api-interface/gr-annotation-actions-context_test.html',
-  'shared/gr-js-api-interface/gr-annotation-actions-js-api_test.html',
-  'shared/gr-js-api-interface/gr-change-actions-js-api_test.html',
-  'shared/gr-js-api-interface/gr-change-reply-js-api_test.html',
-  'shared/gr-js-api-interface/gr-api-utils_test.html',
-  'shared/gr-js-api-interface/gr-js-api-interface_test.html',
-  'shared/gr-js-api-interface/gr-gerrit_test.html',
-  'shared/gr-js-api-interface/gr-plugin-action-context_test.html',
-  'shared/gr-js-api-interface/gr-plugin-loader_test.html',
-  'shared/gr-js-api-interface/gr-plugin-endpoints_test.html',
-  'shared/gr-js-api-interface/gr-plugin-rest-api_test.html',
-  'shared/gr-fixed-panel/gr-fixed-panel_test.html',
-  'shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.html',
-  'shared/gr-label-info/gr-label-info_test.html',
-  'shared/gr-lib-loader/gr-lib-loader_test.html',
-  'shared/gr-limited-text/gr-limited-text_test.html',
-  'shared/gr-linked-chip/gr-linked-chip_test.html',
-  'shared/gr-linked-text/gr-linked-text_test.html',
-  'shared/gr-list-view/gr-list-view_test.html',
-  'shared/gr-overlay/gr-overlay_test.html',
-  'shared/gr-page-nav/gr-page-nav_test.html',
-  'shared/gr-repo-branch-picker/gr-repo-branch-picker_test.html',
-  'shared/gr-rest-api-interface/gr-auth_test.html',
-  'shared/gr-rest-api-interface/gr-etag-decorator_test.html',
-  'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
-  'shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html',
-  'shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.html',
-  'shared/gr-select/gr-select_test.html',
-  'shared/gr-shell-command/gr-shell-command_test.html',
-  'shared/gr-storage/gr-storage_test.html',
-  'shared/gr-textarea/gr-textarea_test.html',
-  'shared/gr-tooltip-content/gr-tooltip-content_test.html',
-  'shared/gr-tooltip/gr-tooltip_test.html',
-  'shared/revision-info/revision-info_test.html',
-];
-/* eslint-enable max-len */
-for (let file of elements) {
-  file = elementsPath + file;
-  testFiles.push(file);
-}
-
-// Behaviors tests.
-/* eslint-disable max-len */
-const behaviors = [
-  'async-foreach-behavior/async-foreach-behavior_test.html',
-  'base-url-behavior/base-url-behavior_test.html',
-  'docs-url-behavior/docs-url-behavior_test.html',
-  'dom-util-behavior/dom-util-behavior_test.html',
-  'keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html',
-  'rest-client-behavior/rest-client-behavior_test.html',
-  'gr-access-behavior/gr-access-behavior_test.html',
-  'gr-admin-nav-behavior/gr-admin-nav-behavior_test.html',
-  'gr-change-table-behavior/gr-change-table-behavior_test.html',
-  'gr-list-view-behavior/gr-list-view-behavior_test.html',
-  'gr-display-name-behavior/gr-display-name-behavior_test.html',
-  'gr-patch-set-behavior/gr-patch-set-behavior_test.html',
-  'gr-path-list-behavior/gr-path-list-behavior_test.html',
-  'gr-tooltip-behavior/gr-tooltip-behavior_test.html',
-  'gr-url-encoding-behavior/gr-url-encoding-behavior_test.html',
-  'safe-types-behavior/safe-types-behavior_test.html',
-];
-/* eslint-enable max-len */
-for (let file of behaviors) {
-  // Behaviors do not utilize the DOM, so no shadow DOM test is necessary.
-  file = behaviorsPath + file;
-  testFiles.push(file);
-}
-
-const scripts = [
-  'gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.html',
-  'gr-group-suggestions-provider/gr-group-suggestions-provider_test.html',
-  'gr-display-name-utils/gr-display-name-utils_test.html',
-  'gr-email-suggestions-provider/gr-email-suggestions-provider_test.html',
-  'util_test.html',
-];
-/* eslint-enable max-len */
-for (let file of scripts) {
-  file = scriptsPath + file;
-  testFiles.push(file);
-}
-
-const services = [
-  'flags_test.html',
-];
-for (let file of services) {
-  file = servicesPath + file;
-  testFiles.push(file);
-}
-
-/**
- * Converts multiline string to a map<file_name, test_count>.
- *
- * @param {number} testsPerFileString - multiline input string in the following format:
- *   fileName1:test_count1
- *   fileName2:test_count2
- *   ...
- *   fileName3:test_count3
- * @return Object<string, number> - key is the test file name, value is the number of tests
- */
-function parseTestsPerFileString(testsPerFileString) {
-  return testsPerFileString.split('\n').map(s => s.trim().replace('./', '../'))
-      .reduce((acc, fileAndCount) => {
-        const [file, countStr] = fileAndCount.split(':');
-        acc[file] = parseInt(countStr);
-        return acc;
-      }, {});
-}
-
-const defaultTestsPerFile = [];
-
-function getBucketWithMinTests(buckets) {
-  let minBucket = buckets[0];
-  for (let i = 1; i < buckets.length; i++) {
-    if (buckets[i].count < minBucket.count) {
-      minBucket = buckets[i];
-    }
-  }
-  return minBucket;
-}
-
-/**
- * Split testFiles among all buckets. The greedy algorithm is used,
- * because we don't need accurate splitting
- */
-function splitTestsByBuckets(buckets, testsPerFile) {
-  for (const testFile of testFiles) {
-    const testsInFile = testsPerFile[testFile] ?
-      testsPerFile[testFile] : defaultTestsPerFile;
-    const minBucket = getBucketWithMinTests(buckets);
-    minBucket.count += testsInFile;
-    minBucket.items.push(testFile);
-  }
-}
-
-/**
- * Returns list of test files for specified splitIndex
- *
- * @param {string} testsPerFileString - information about number of tests in each file
- *  (see suite_conf.js for exact format)
- * @param {number} splitIndex - index of split to return (0<=splitIndex<splitCount)
- * @param {number} splitCount - total number of splits
- * @return Array<string> - list of test files
- */
-export function getSuiteTests(testsPerFileString, splitIndex, splitCount) {
-  const testsPerFile = parseTestsPerFileString(testsPerFileString);
-  const buckets = [];
-  for (let i = 0; i < splitCount; i++) {
-    buckets.push({count: 0, items: []});
-  }
-  // TODO(dmfilippov): split tests by buckets only once
-  // This doesn't affect overall performance, so we can keep it
-  // while we have only small amounts of test files.
-  splitTestsByBuckets(buckets, testsPerFile);
-  console.log(buckets);
-  return buckets[splitIndex].items;
-}
-
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
new file mode 100644
index 0000000..bc6c2df
--- /dev/null
+++ b/polygerrit-ui/app/tsconfig.json
@@ -0,0 +1,62 @@
+{
+  "compilerOptions": {
+    /* Basic Options */
+    "target": "es2018", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
+    "module": "es2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
+    "allowJs": true, /* Allow javascript files to be compiled. */
+    "checkJs": false, /* Report errors in .js files. */
+    "declaration": false, /* Temporary disabled - generates corresponding '.d.ts' file. */
+    "declarationMap": false, /* Generates a sourcemap for each corresponding '.d.ts' file. */
+    "inlineSourceMap": true, /* Generates corresponding '.map' file. */
+    "outDir": "../../.ts-out/polygerrit-ui/app", /* Not used in bazel. Redirect output structure to the directory. */
+    "rootDir": ".", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
+    "removeComments": false, /* Emit comments to output*/
+
+    /* Strict Type-Checking Options */
+    "strict": true, /* Enable all strict type-checking options. */
+    "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
+    "strictNullChecks": true, /* Enable strict null checks. */
+    "strictFunctionTypes": true, /* Enable strict checking of function types. */
+    "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
+    "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
+    "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
+
+    /* Additional Checks */
+    "noUnusedLocals": true, /* Report errors on unused locals. */
+    "noUnusedParameters": true, /* Report errors on unused parameters. */
+    "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+    "noFallthroughCasesInSwitch": true,/* Report errors for fallthrough cases in switch statement. */
+
+    "skipLibCheck": true, /* Do not check node_modules */
+
+    /* Module Resolution Options */
+    "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
+    "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
+    "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+
+    /* Advanced Options */
+    "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
+    "incremental": true,
+    "experimentalDecorators": true
+  },
+  // With the * pattern (without an extension), only supported files
+  // are included. The supported files are .ts, .tsx, .d.ts.
+  // If allowJs is set to true, .js and .jsx files are included as well.
+  // Note: gerrit doesn't have .tsx and .jsx files
+  "include": [
+    // This items below must be in sync with the src_dirs list in the BUILD file
+    "constants/**/*",
+    "elements/**/*",
+    "embed/**/*",
+    "gr-diff/**/*",
+    "mixins/**/*",
+    "samples/**/*",
+    "scripts/**/*",
+    "services/**/*",
+    "styles/**/*",
+    "types/**/*",
+    "utils/**/*",
+    // Directory for test utils (not included in src_dirs in the BUILD file)
+    "test/**/*"
+  ]
+}
diff --git a/polygerrit-ui/app/tsconfig_eslint.json b/polygerrit-ui/app/tsconfig_eslint.json
new file mode 100644
index 0000000..7cc99c7
--- /dev/null
+++ b/polygerrit-ui/app/tsconfig_eslint.json
@@ -0,0 +1,11 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "skipLibCheck": false, /* This is required for report-ts-error.js.
+    See details in the //tools/js/eslint-rules/report-ts-error.js file.*/
+    "baseUrl": "../../external/ui_npm/node_modules" /* Only for bazel.
+    Compiler will try to use it to resolve module name and if it fail - will
+    fallback to a default behavior
+    (https://github.com/microsoft/TypeScript/issues/5039)*/
+  }
+}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
new file mode 100644
index 0000000..ed89a42
--- /dev/null
+++ b/polygerrit-ui/app/types/common.ts
@@ -0,0 +1,948 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+  ChangeStatus,
+  DefaultDisplayNameConfig,
+  FileInfoStatus,
+  GpgKeyInfoStatus,
+  ProblemInfoStatus,
+  RequirementStatus,
+  ReviewerState,
+  RevisionKind,
+} from '../constants/constants';
+
+export type BrandType<T, BrandName extends string> = T &
+  {[__brand in BrandName]: never};
+
+export type PatchSetNum = BrandType<'edit' | number, '_patchSet'>;
+export type ChangeId = BrandType<string, '_changeId'>;
+export type ChangeMessageId = BrandType<string, '_changeMessageId'>;
+export type LegacyChangeId = BrandType<number, '_legacyChangeId'>;
+export type NumericChangeId = BrandType<number, '_numericChangeId'>;
+export type ProjectName = BrandType<string, '_projectName'>;
+export type TopicName = BrandType<string, '_topicName'>;
+export type AccountId = BrandType<number, '_accountId'>;
+export type HttpMethod = BrandType<string, '_httpMethod'>;
+export type GitRef = BrandType<string, '_gitRef'>;
+export type RequirementType = BrandType<string, '_requirementType'>;
+export type TrackingId = BrandType<string, '_trackingId'>;
+export type ReviewInputTag = BrandType<string, '_reviewInputTag'>;
+
+// The 8-char hex GPG key ID.
+export type GpgKeyId = BrandType<string, '_gpgKeyId'>;
+
+// The 40-char (plus spaces) hex GPG key fingerprint
+export type GpgKeyFingerprint = BrandType<string, '_gpgKeyFingerprint'>;
+
+// OpenPGP User IDs (https://tools.ietf.org/html/rfc4880#section-5.11).
+export type OpenPgpUserIds = BrandType<string, '_openPgpUserIds'>;
+
+// This ID is equal to the numeric ID of the change that triggered the
+// submission. If the change that triggered the submission also has a topic, it
+// will be "<id>-<topic>" of the change that triggered the submission
+// The callers must not rely on the format of the submission ID.
+export type ChangeSubmissionId = BrandType<
+  string | number,
+  '_changeSubmissionId'
+>;
+
+// The refs/heads/ prefix is omitted in Branch name
+export type BranchName = BrandType<string, '_branchName'>;
+
+// The ID of the change in the format "'<project>~<branch>~<Change-Id>'"
+export type ChangeInfoId = BrandType<string, '_changeInfoId'>;
+export type Hashtag = BrandType<string, '_hashtag'>;
+export type StarLabel = BrandType<string, '_startLabel'>;
+export type SubmitType = BrandType<string, '_submitType'>;
+export type CommitId = BrandType<string, '_commitId'>;
+
+// The UUID of the group
+export type GroupId = BrandType<string, '_groupId'>;
+
+// The timezone offset from UTC in minutes
+export type TimezoneOffset = BrandType<number, '_timezoneOffset'>;
+
+// Timestamps are given in UTC and have the format
+// "'yyyy-mm-dd hh:mm:ss.fffffffff'"
+// where "'ffffffffff'" represents nanoseconds.
+export type Timestamp = BrandType<string, '_timestamp'>;
+
+export type IdToAttentionSetMap = {[accountId: string]: AttentionSetInfo};
+export type LabelNameToInfoMap = {[labelName: string]: LabelInfo};
+
+// The map maps the values (“-2”, “-1”, " `0`", “+1”, “+2”) to the value descriptions.
+export type LabelValueToDescriptionMap = {[labelValue: string]: string};
+
+/**
+ * The LabelInfo entity contains information about a label on a change, always corresponding to the current patch set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#label-info
+ */
+type LabelInfo = QuickLabelInfo | DetailedLabelInfo;
+
+interface LabelCommonInfo {
+  optional?: boolean; // not set if false
+}
+
+export interface QuickLabelInfo extends LabelCommonInfo {
+  approved?: AccountInfo;
+  rejected?: AccountInfo;
+  recommended?: AccountInfo;
+  disliked?: AccountInfo;
+  blocking?: boolean; // not set if false
+  value?: number; // The voting value of the user who recommended/disliked this label on the change if it is not “+1”/“-1”.
+  default_value?: number;
+}
+
+export interface DetailedLabelInfo extends LabelCommonInfo {
+  all?: ApprovalInfo[];
+  values?: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
+}
+
+/**
+ * The ChangeInfo entity contains information about a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
+ */
+export interface ChangeInfo {
+  id: ChangeInfoId;
+  project: ProjectName;
+  branch: BranchName;
+  topic?: TopicName;
+  attention_set?: IdToAttentionSetMap;
+  assignee?: AccountInfo;
+  hashtags?: Hashtag[];
+  change_id: ChangeId;
+  subject: string;
+  status: ChangeStatus;
+  created: Timestamp;
+  updated: Timestamp;
+  submitted?: Timestamp;
+  submitter: AccountInfo;
+  starred?: boolean; // not set if false
+  stars?: StarLabel[];
+  reviewed?: boolean; // not set if false
+  submit_type?: SubmitType;
+  mergeable?: boolean;
+  submittable?: boolean;
+  insertions: number; // Number of inserted lines
+  deletions: number; // Number of deleted lines
+  total_comment_count?: number;
+  unresolved_comment_count?: number;
+  _number: LegacyChangeId;
+  owner: AccountInfo;
+  actions?: ActionInfo[];
+  requirements?: Requirement[];
+  labels?: LabelInfo[];
+  permitted_labels?: LabelNameToInfoMap;
+  removable_reviewers?: AccountInfo[];
+  reviewers?: AccountInfo[];
+  pending_reviewers?: AccountInfo[];
+  reviewer_updates?: ReviewerUpdateInfo[];
+  messages?: ChangeMessageInfo[];
+  current_revision?: CommitId;
+  revisions?: {[revisionId: string]: RevisionInfo};
+  tracking_ids?: TrackingIdInfo[];
+  _more_changes?: boolean; // not set if false
+  problems?: ProblemInfo[];
+  is_private?: boolean; // not set if false
+  work_in_progress?: boolean; // not set if false
+  has_review_started?: boolean; // not set if false
+  revert_of?: NumericChangeId;
+  submission_id?: ChangeSubmissionId;
+  cherry_pick_of_change?: NumericChangeId;
+  cherry_pick_of_patch_set?: PatchSetNum;
+  contains_git_conflicts?: boolean;
+}
+
+/**
+ * The AccountInfo entity contains information about an account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-info
+ */
+export interface AccountInfo {
+  _account_id: AccountId;
+  name?: string;
+  display_name?: string;
+  email?: string;
+  secondary_emails?: string[];
+  username?: string;
+  avatars?: AvatarInfo[];
+  _more_accounts?: boolean; // not set if false
+  status?: string; // status message of the account
+  inactive?: boolean; // not set if false
+}
+
+/**
+ * The GroupAuditEventInfo entity contains information about an auditevent of a group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupAuditEventInfo {
+  member: string;
+  type: string;
+  user: string;
+  date: string;
+}
+
+/**
+ * The GroupBaseInfo entity contains base information about the group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#group-base-info
+ */
+export interface GroupBaseInfo {
+  id: GroupId;
+  name: string;
+}
+
+/**
+ * The GroupInfo entity contains information about a group. This can be a
+ * Gerrit internal group, or an external group that is known to Gerrit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupInfo {
+  id: string;
+  name: string;
+  url?: string;
+  options?: GroupOptionsInfo;
+  description?: string;
+  group_id?: string;
+  owner?: string;
+  owner_id?: string;
+  created_on?: string;
+  _more_groups?: boolean;
+  members?: AccountInfo[];
+  includes?: GroupInfo[];
+}
+
+/**
+ * The 'GroupInput' entity contains information for the creation of a new
+ * internal group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupInput {
+  name?: string;
+  uuid?: string;
+  description?: string;
+  visible_to_all?: string;
+  owner_id?: string;
+  members?: string[];
+}
+
+/**
+ * Options of the group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupOptionsInfo {
+  visible_to_all: boolean;
+}
+
+/**
+ * New options for a group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupOptionsInput {
+  visible_to_all: boolean;
+}
+
+/**
+ * The GroupsInput entity contains information about groups that should be
+ * included into a group or that should be deleted from a group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupsInput {
+  _one_group?: string;
+  groups?: string[];
+}
+
+/**
+ * The MembersInput entity contains information about accounts that should be
+ * added as members to a group or that should be deleted from the group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface MembersInput {
+  _one_member?: string;
+  members?: string[];
+}
+
+/**
+ * The ActionInfo entity describes a REST API call the client canmake to
+ * manipulate a resource. These are frequently implemented by plugins and may
+ * be discovered at runtime.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#action-info
+ */
+export interface ActionInfo {
+  method?: HttpMethod; // Most actions use POST, PUT or DELETE to cause state changes.
+  label?: string; // Short title to display to a user describing the action
+  title?: string; // Longer text to display describing the action
+  enabled?: boolean; // not set if false
+}
+
+/**
+ * The Requirement entity contains information about a requirement relative to
+ * a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#requirement
+ */
+export interface Requirement {
+  status: RequirementStatus;
+  fallbackText: string; // A human readable reason
+  type: RequirementType;
+}
+
+/**
+ * The ReviewerUpdateInfo entity contains information about updates tochange’s
+ * reviewers set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-update-info
+ */
+export interface ReviewerUpdateInfo {
+  updated: Timestamp;
+  updated_by: AccountInfo;
+  reviewer: AccountInfo;
+  state: ReviewerState;
+}
+
+/**
+ * The ChangeMessageInfo entity contains information about a messageattached
+ * to a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-message-info
+ */
+export interface ChangeMessageInfo {
+  id: ChangeMessageId;
+  author?: AccountInfo;
+  real_author?: AccountInfo;
+  date: Timestamp;
+  message: string;
+  tag?: ReviewInputTag;
+  _revision_number?: PatchSetNum;
+}
+
+/**
+ * The RevisionInfo entity contains information about a patch set.Not all
+ * fields are returned by default.  Additional fields can be obtained by
+ * adding o parameters as described in Query Changes.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revision-info
+ */
+export interface RevisionInfo {
+  kind: RevisionKind;
+  _number: PatchSetNum;
+  created: Timestamp;
+  uploader: AccountInfo;
+  ref: GitRef;
+  fetch?: {[protocol: string]: FetchInfo};
+  commit?: CommitInfo;
+  files?: {[filename: string]: FileInfo};
+  actions?: ActionInfo[];
+  reviewed?: boolean;
+  commit_with_footers?: boolean;
+  push_certificate?: PushCertificateInfo;
+  description?: string;
+}
+
+/**
+ * The TrackingIdInfo entity describes a reference to an external tracking
+ * system.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#tracking-id-info
+ */
+export interface TrackingIdInfo {
+  system: string;
+  id: TrackingId;
+}
+
+/**
+ * The ProblemInfo entity contains a description of a potential consistency
+ * problem with a change. These are not related to the code review process,
+ * but rather indicate some inconsistency in Gerrit’s database or repository
+ * metadata related to the enclosing change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#problem-info
+ */
+export interface ProblemInfo {
+  message: string;
+  status?: ProblemInfoStatus; // Only set if a fix was attempted
+  outcome?: string;
+}
+
+/**
+ * The AttentionSetInfo entity contains details of users that are in the attention set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#attention-set-info
+ */
+export interface AttentionSetInfo {
+  account: AccountInfo;
+  last_update: Timestamp;
+}
+
+/**
+ * The ApprovalInfo entity contains information about an approval from auser
+ * for a label on a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#approval-info
+ */
+export interface ApprovalInfo extends AccountInfo {
+  value?: string;
+  permitted_voting_range?: VotingRangeInfo;
+  date?: Timestamp;
+  tag?: ReviewInputTag;
+  post_submit?: boolean; // not set if false
+}
+
+/**
+ * The AvartarInfo entity contains information about an avatar image ofan
+ * account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#avatar-info
+ */
+export interface AvatarInfo {
+  url: string;
+  height: number;
+  width: number;
+}
+
+/**
+ * The FetchInfo entity contains information about how to fetch a patchset via
+ * a certain protocol.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fetch-info
+ */
+export interface FetchInfo {
+  url: string;
+  ref: string;
+  commands?: {[commandName: string]: string};
+}
+
+/**
+ * The CommitInfo entity contains information about a commit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
+ */
+export interface CommitInfo {
+  commit?: CommitId;
+  parents: ParentCommitInfo[];
+  author: GitPersonInfo;
+  committer: GitPersonInfo;
+  subject: string;
+  message: string;
+  web_links?: WebLinkInfo[];
+}
+
+/**
+ * The parent commits of this commit as a list of CommitInfo entities.
+ * In each parent only the commit and subject fields are populated.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
+ */
+export interface ParentCommitInfo {
+  commit: CommitId;
+  subject: string;
+}
+
+/**
+ * The FileInfo entity contains information about a file in a patch set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#file-info
+ */
+export interface FileInfo {
+  status?: FileInfoStatus;
+  binary?: boolean; // not set if false
+  old_path?: string;
+  lines_inserted?: number;
+  lines_deleted?: number;
+  size_delta: number; // in bytes
+  size: number; // in bytes
+}
+
+/**
+ * The PushCertificateInfo entity contains information about a pushcertificate
+ * provided when the user pushed for review with git push
+ * --signed HEAD:refs/for/<branch>. Only used when signed push is
+ * enabled on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#push-certificate-info
+ */
+export interface PushCertificateInfo {
+  certificate: string;
+  key: GpgKeyInfo;
+}
+
+/**
+ * The GpgKeyInfo entity contains information about a GPG public key.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#gpg-key-info
+ */
+export interface GpgKeyInfo {
+  id?: GpgKeyId;
+  fingerprint?: GpgKeyFingerprint;
+  user_ids?: OpenPgpUserIds[];
+  key?: string; // ASCII armored public key material
+  status?: GpgKeyInfoStatus;
+  problems?: string[];
+}
+
+/**
+ * The GitPersonInfo entity contains information about theauthor/committer of
+ * a commit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#git-person-info
+ */
+export interface GitPersonInfo {
+  name: string;
+  email: string;
+  date: Timestamp;
+  tz: TimezoneOffset;
+}
+
+/**
+ * The WebLinkInfo entity describes a link to an external site.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
+ */
+export interface WebLinkInfo {
+  name: string;
+  url: string;
+  image_url: string;
+}
+
+/**
+ * The VotingRangeInfo entity describes the continuous voting range from minto
+ * max values.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#voting-range-info
+ */
+export interface VotingRangeInfo {
+  min: number;
+  max: number;
+}
+
+/**
+ * The AccountsConfigInfo entity contains information about Gerrit configuration
+ * from the accounts section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface AccountsConfigInfo {
+  visibility: string;
+  default_display_name: DefaultDisplayNameConfig;
+}
+
+/**
+ * The AuthInfo entity contains information about the authentication
+ * configuration of the Gerrit server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface AuthInfo {
+  type: string;
+  use_contributor_agreements: boolean;
+  contributor_agreements: boolean;
+  editable_account_fields: string;
+  login_url?: string;
+  login_text?: string;
+  switch_account_url?: string;
+  register_url?: string;
+  register_text?: string;
+  edit_full_name_url?: string;
+  http_password_url?: string;
+  git_basic_auth_policy?: string;
+}
+
+/**
+ * The CacheInfo entity contains information about a cache.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CacheInfo {
+  name: string;
+  type: string;
+  entries: EntriesInfo;
+  average_get?: string;
+  hit_ratio: HitRatioInfo;
+}
+
+/**
+ * The CacheOperationInput entity contains information about an operation that
+ * should be executed on caches.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CacheOperationInput {
+  operation: string;
+  caches?: string[];
+}
+
+/**
+ * The CapabilityInfo entity contains information about a capability.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CapabilityInfo {
+  id: string;
+  name: string;
+}
+
+/**
+ * The ChangeConfigInfo entity contains information about Gerrit configuration
+ * from the change section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ChangeConfigInfo {
+  allow_blame: boolean;
+  large_change: string;
+  reply_label: string;
+  reply_tooltip: string;
+  update_delay: string;
+  submit_whole_topic: boolean;
+  disable_private_changes: boolean;
+  mergeability_computation_behavior: ChangeInfo;
+  enable_attention_set: boolean;
+  enable_assignee: boolean;
+}
+
+/**
+ * The ChangeIndexConfigInfo entity contains information about Gerrit
+ * configuration from the index.change section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ChangeIndexConfigInfo {
+  index_mergeable: boolean;
+}
+
+/**
+ * The CheckAccountExternalIdsResultInfo entity contains the result of running
+ * the account external ID consistency check.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CheckAccountExternalIdsResultInfo {
+  problems: string;
+}
+
+/**
+ * The CheckAccountsResultInfo entity contains the result of running the account
+ * consistency check.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CheckAccountsResultInfo {
+  problems: string;
+}
+
+/**
+ * The CheckGroupsResultInfo entity contains the result of running the group
+ * consistency check.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CheckGroupsResultInfo {
+  problems: string;
+}
+
+/**
+ * The ConsistencyCheckInfo entity contains the results of running consistency
+ * checks.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConsistencyCheckInfo {
+  check_accounts_result?: CheckAccountsResultInfo;
+  check_account_external_ids_result?: CheckAccountExternalIdsResultInfo;
+  check_groups_result?: CheckGroupsResultInfo;
+}
+
+/**
+ * The ConsistencyCheckInput entity contains information about which consistency
+ * checks should be run.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConsistencyCheckInput {
+  check_accounts?: string;
+  check_account_external_ids?: string;
+  check_groups?: string;
+}
+
+/**
+ * The ConsistencyProblemInfo entity contains information about a consistency
+ * problem.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConsistencyProblemInfo {
+  status: string;
+  message: string;
+}
+
+/**
+ * The entity describes the result of a reload of gerrit.config.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConfigUpdateInfo {
+  applied: string;
+  rejected: string;
+}
+
+/**
+ * The entity describes an updated config value.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConfigUpdateEntryInfo {
+  config_key: string;
+  old_value: string;
+  new_value: string;
+}
+
+/**
+ * The DownloadInfo entity contains information about supported download
+ * options.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface DownloadInfo {
+  schemes: string;
+  archives: string;
+}
+
+/**
+ * The DownloadSchemeInfo entity contains information about a supported download
+ * scheme and its commands.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface DownloadSchemeInfo {
+  url: string;
+  is_auth_required: boolean;
+  is_auth_supported: boolean;
+  commands: string;
+  clone_commands: string;
+}
+
+/**
+ * The EmailConfirmationInput entity contains information for confirming an
+ * email address.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface EmailConfirmationInput {
+  token: string;
+}
+
+/**
+ * The EntriesInfo entity contains information about the entries in acache.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface EntriesInfo {
+  mem?: string;
+  disk?: string;
+  space?: string;
+}
+
+/**
+ * The GerritInfo entity contains information about Gerrit configuration from
+ * the gerrit section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface GerritInfo {
+  all_projects_name: string;
+  all_users_name: string;
+  doc_search: string;
+  doc_url?: string;
+  edit_gpg_keys: boolean;
+  report_bug_url?: string;
+}
+
+/**
+ * The IndexConfigInfo entity contains information about Gerrit configuration
+ * from the index section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface IndexConfigInfo {
+  change: ChangeIndexConfigInfo;
+}
+
+/**
+ * The HitRatioInfo entity contains information about the hit ratio of a cache.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface HitRatioInfo {
+  mem: string;
+  disk?: string;
+}
+
+/**
+ * The IndexChangesInput contains a list of numerical changes IDs to index.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface IndexChangesInput {
+  changes: string;
+}
+
+/**
+ * The JvmSummaryInfo entity contains information about the JVM.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface JvmSummaryInfo {
+  vm_vendor: string;
+  vm_name: string;
+  vm_version: string;
+  os_name: string;
+  os_version: string;
+  os_arch: string;
+  user: string;
+  host?: string;
+  current_working_directory: string;
+  site: string;
+}
+
+/**
+ * The MemSummaryInfo entity contains information about the current memory
+ * usage.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface MemSummaryInfo {
+  total: string;
+  used: string;
+  free: string;
+  buffers: string;
+  max: string;
+  open_files?: string;
+}
+
+/**
+ * The PluginConfigInfo entity contains information about Gerrit extensions by
+ * plugins.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface PluginConfigInfo {
+  has_avatars: boolean;
+}
+
+/**
+ * The ReceiveInfo entity contains information about the configuration of
+ * git-receive-pack behavior on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ReceiveInfo {
+  enableSignedPush?: string;
+}
+
+/**
+ * The ServerInfo entity contains information about the configuration of the
+ * Gerrit server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ServerInfo {
+  accounts: AccountsConfigInfo;
+  auth: AuthInfo;
+  change: ChangeConfigInfo;
+  download: DownloadInfo;
+  gerrit: GerritInfo;
+  index: IndexConfigInfo;
+  note_db_enabled: boolean;
+  plugin: PluginConfigInfo;
+  receive?: ReceiveInfo;
+  suggest: SuggestInfo;
+  user: UserConfigInfo;
+  default_theme?: string;
+}
+
+/**
+ * The SuggestInfo entity contains information about Gerritconfiguration from
+ * the suggest section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface SuggestInfo {
+  from: string;
+}
+
+/**
+ * The SummaryInfo entity contains information about the current state of the
+ * server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface SummaryInfo {
+  task_summary: TaskSummaryInfo;
+  mem_summary: MemSummaryInfo;
+  thread_summary: ThreadSummaryInfo;
+  jvm_summary?: JvmSummaryInfo;
+}
+
+/**
+ * The TaskInfo entity contains information about a task in a background work
+ * queue.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface TaskInfo {
+  id: string;
+  state: string;
+  start_time: string;
+  delay: string;
+  command: string;
+  remote_name?: string;
+  project?: string;
+}
+
+/**
+ * The TaskSummaryInfo entity contains information about the current tasks.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface TaskSummaryInfo {
+  total?: string;
+  running?: string;
+  ready?: string;
+  sleeping?: string;
+}
+
+/**
+ * The ThreadSummaryInfo entity contains information about the current threads.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ThreadSummaryInfo {
+  cpus: string;
+  threads: string;
+  counts: string;
+}
+
+/**
+ * The TopMenuEntryInfo entity contains information about a top menu entry.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface TopMenuEntryInfo {
+  name: string;
+  items: string;
+}
+
+/**
+ * The TopMenuItemInfo entity contains information about a menu item ina top
+ * menu entry.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface TopMenuItemInfo {
+  url: string;
+  name: string;
+  target: string;
+  id?: string;
+}
+
+/**
+ * The UserConfigInfo entity contains information about Gerrit configuration
+ * from the user section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface UserConfigInfo {
+  anonymous_coward_name: string;
+}
+
+/*
+ * The CommentInfo entity contains information about an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
+ */
+export interface CommentInfo {
+  patch_set?: PatchSetNum;
+  id: string;
+  path?: string;
+  side?: string;
+  parent?: string;
+  line?: string;
+  range?: CommentRange;
+  in_reply_to?: string;
+  message?: string;
+  updated: string;
+  author?: AccountInfo;
+  tag?: string;
+  unresolved?: boolean;
+  change_message_id?: string;
+  commit_id?: string;
+}
+
+/**
+ * The CommentRange entity describes the range of an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
+ */
+export interface CommentRange {
+  start_line: string;
+  start_character: string;
+  end_line: string;
+  end_character: string;
+}
diff --git a/polygerrit-ui/app/types/custom-externs.js b/polygerrit-ui/app/types/custom-externs.js
deleted file mode 100644
index afa094c..0000000
--- a/polygerrit-ui/app/types/custom-externs.js
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * For the purposes of template type checking, externs should be added for
- * anything set on the window object. Note that sub-properties of these
- * declared properties are considered something separate.
- *
- * This file is only for template type checking, not used in Gerrit code.
- */
-
-/* eslint-disable no-var */
-/* eslint-disable no-unused-vars */
-/** @externs */
-// @unused
-
-var Gerrit;
-var GrAnnotation;
-var GrAttributeHelper;
-var GrChangeActionsInterface;
-var GrChangeReplyInterface;
-var GrDiffBuilder;
-var GrDiffBuilderImage;
-var GrDiffBuilderSideBySide;
-var GrDiffBuilderUnified;
-var GrDiffGroup;
-var GrDiffLine;
-var GrDomHooks;
-var GrEditConstants;
-var GrEtagDecorator;
-var GrFileListConstants;
-var GrGapiAuth;
-var GrGerritAuth;
-var GrLinkTextParser;
-var GrPluginEndpoints;
-var GrPopupInterface;
-var GrRangeNormalizer;
-var GrReporting;
-var GrReviewerUpdatesParser;
-var GrCountStringFormatter;
-var GrThemeApi;
-var SiteBasedCache;
-var FetchPromisesCache;
-var GrRestApiHelper;
-var GrDisplayNameUtils;
-var GrReviewerSuggestionsProvider;
-var moment;
-var page;
-var util;
\ No newline at end of file
diff --git a/polygerrit-ui/app/types/custom-externs.ts b/polygerrit-ui/app/types/custom-externs.ts
new file mode 100644
index 0000000..216900a
--- /dev/null
+++ b/polygerrit-ui/app/types/custom-externs.ts
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * For the purposes of template type checking, externs should be added for
+ * anything set on the window object. Note that sub-properties of these
+ * declared properties are considered something separate.
+ *
+ * This file is only for template type checking, not used in Gerrit code.
+ */
+
+/* eslint-disable no-var */
+/* eslint-disable no-unused-vars */
+/** @externs */
+// @unused
+
+var Gerrit: any;
+var GrAnnotation;
+var GrAttributeHelper;
+var GrChangeActionsInterface;
+var GrChangeReplyInterface;
+var GrDiffBuilder;
+var GrDiffBuilderImage;
+var GrDiffBuilderSideBySide;
+var GrDiffBuilderUnified;
+var GrDiffGroup;
+var GrDiffLine;
+var GrDomHooks;
+var GrEditConstants;
+var GrEtagDecorator;
+var GrFileListConstants;
+var GrGapiAuth;
+var GrGerritAuth;
+var GrLinkTextParser;
+var GrPluginEndpoints;
+var GrPopupInterface;
+var GrRangeNormalizer;
+var GrReporting;
+var GrReviewerUpdatesParser;
+var GrCountStringFormatter;
+var GrThemeApi;
+var SiteBasedCache;
+var FetchPromisesCache;
+var GrRestApiHelper;
+var GrDisplayNameUtils;
+var GrReviewerSuggestionsProvider;
+var page;
+var util;
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
new file mode 100644
index 0000000..81db291
--- /dev/null
+++ b/polygerrit-ui/app/types/globals.ts
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export {};
+
+declare global {
+  interface Window {
+    CANONICAL_PATH?: string;
+    ShadyCSS?: {
+      getComputedStyleValue(el: Element, name: string): string;
+    };
+    HTMLImports?: {whenReady: (cb: () => void) => void};
+    linkify(
+      text: string,
+      options: {callback: (text: string, href?: string) => void}
+    ): void;
+  }
+
+  interface Performance {
+    // typescript doesn't know about the memory property.
+    // Define it here, so it can be used everywhere
+    memory?: {
+      jsHeapSizeLimit: number;
+      totalJSHeapSize: number;
+      usedJSHeapSize: number;
+    };
+  }
+}
diff --git a/polygerrit-ui/app/types/types.js b/polygerrit-ui/app/types/types.js
deleted file mode 100644
index 5408eea..0000000
--- a/polygerrit-ui/app/types/types.js
+++ /dev/null
@@ -1,311 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// Type definitions used across multiple files in Gerrit
-
-/** @enum {string} */
-export const CoverageType = {
-  /**
-   * start_character and end_character of the range will be ignored for this
-   * type.
-   */
-  COVERED: 'COVERED',
-  /**
-   * start_character and end_character of the range will be ignored for this
-   * type.
-   */
-  NOT_COVERED: 'NOT_COVERED',
-  PARTIALLY_COVERED: 'PARTIALLY_COVERED',
-  /**
-   * You don't have to use this. If there is no coverage information for a
-   * range, then it implicitly means NOT_INSTRUMENTED. start_character and
-   * end_character of the range will be ignored for this type.
-   */
-  NOT_INSTRUMENTED: 'NOT_INSTRUMENTED',
-};
-
-const Gerrit = window.Gerrit || {};
-
-/**
- * @typedef {{
- *   start_line: number,
- *   start_character: number,
- *   end_line: number,
- *   end_character: number,
- * }}
- */
-Gerrit.Range;
-
-/**
- * @typedef {{side: string, range: Gerrit.Range, hovering: boolean}}
- */
-Gerrit.HoveredRange;
-
-/**
- * @typedef {{
- *   side: string,
- *   type: Gerrit.CoverageType,
- *   code_range: Gerrit.Range,
- * }}
- */
-Gerrit.CoverageRange;
-
-/**
- * @typedef {{
- *    basePatchNum: (string|number),
- *    patchNum: (number),
- * }}
- */
-Gerrit.PatchRange;
-
-/**
- * @typedef {{
- *   changeNum: (string|number),
- *   endpoint: string,
- *   patchNum: (string|number|null|undefined),
- *   errFn: (function(?Response, string=)|null|undefined),
- *   params: (Object|null|undefined),
- *   fetchOptions: (Object|null|undefined),
- *   anonymizedEndpoint: (string|undefined),
- *   reportEndpointAsIs: (boolean|undefined),
- * }}
- */
-Gerrit.ChangeFetchRequest;
-
-/**
- * @typedef {{
- *   is_private: boolean,
- *   subject: string,
- *   unresolved_comment_count: number,
- * }}
- */
-Gerrit.Change;
-
-/**
- * Object to describe a request for passing into _send.
- * - method is the HTTP method to use in the request.
- * - url is the URL for the request
- * - body is a request payload.
- *     TODO (beckysiegel) remove need for number at least.
- * - errFn is a function to invoke when the request fails.
- * - cancelCondition is a function that, if provided and returns true, will
- *   cancel the response after it resolves.
- * - contentType is the content type of the body.
- * - headers is a key-value hash to describe HTTP headers for the request.
- * - parseResponse states whether the result should be parsed as a JSON
- *     object using getResponseObject.
- *
- * @typedef {{
- *   method: string,
- *   url: string,
- *   body: (string|number|Object|null|undefined),
- *   errFn: (function(?Response, string=)|null|undefined),
- *   contentType: (string|null|undefined),
- *   headers: (Object|undefined),
- *   parseResponse: (boolean|undefined),
- *   anonymizedUrl: (string|undefined),
- *   reportUrlAsIs: (boolean|undefined),
- * }}
- */
-Gerrit.SendRequest;
-
-/**
- * @typedef {{
- *   changeNum: (string|number),
- *   method: string,
- *   patchNum: (string|number|undefined),
- *   endpoint: string,
- *   body: (string|number|Object|null|undefined),
- *   errFn: (function(?Response, string=)|null|undefined),
- *   contentType: (string|null|undefined),
- *   headers: (Object|undefined),
- *   parseResponse: (boolean|undefined),
- *   anonymizedEndpoint: (string|undefined),
- *   reportEndpointAsIs: (boolean|undefined),
- * }}
- */
-Gerrit.ChangeSendRequest;
-
-/**
- * @typedef {{
- *    url: string,
- *    fetchOptions: (Object|null|undefined),
- *    anonymizedUrl: (string|undefined),
- * }}
- */
-Gerrit.FetchRequest;
-
-/**
- * Object to describe a request for passing into fetchJSON or fetchRawJSON.
- * - url is the URL for the request (excluding get params)
- * - errFn is a function to invoke when the request fails.
- * - cancelCondition is a function that, if provided and returns true, will
- *     cancel the response after it resolves.
- * - params is a key-value hash to specify get params for the request URL.
- *
- * @typedef {{
- *    url: string,
- *    errFn: (function(?Response, string=)|null|undefined),
- *    cancelCondition: (function()|null|undefined),
- *    params: (Object|null|undefined),
- *    fetchOptions: (Object|null|undefined),
- *    anonymizedUrl: (string|undefined),
- *    reportUrlAsIs: (boolean|undefined),
- * }}
- */
-Gerrit.FetchJSONRequest;
-
-/**
- * @typedef {{
- *    message: string,
- *    icon: string,
- *    class: string,
- *  }}
- */
-Gerrit.PushCertificateValidation;
-
-/**
- * Object containing layout values to be used in rendering size-bars.
- * `max{Inserted,Deleted}` represent the largest values of the
- * `lines_inserted` and `lines_deleted` fields of the files respectively. The
- * `max{Addition,Deletion}Width` represent the width of the graphic allocated
- * to the insertion or deletion side respectively. Finally, the
- * `deletionOffset` value represents the x-position for the deletion bar.
- *
- * @typedef {{
- *    maxInserted: number,
- *    maxDeleted: number,
- *    maxAdditionWidth: number,
- *    maxDeletionWidth: number,
- *    deletionOffset: number,
- * }}
- */
-Gerrit.LayoutStats;
-
-/**
- * @typedef {{
- *    changeNum: number,
- *    path: string,
- *    patchRange: !Gerrit.PatchRange,
- *    projectConfig: (Object|undefined),
- * }}
- */
-Gerrit.CommentMeta;
-
-/**
- * @typedef {{
- *    meta: !Gerrit.CommentMeta,
- *    left: !Array,
- *    right: !Array,
- * }}
- */
-Gerrit.CommentsBySide;
-
-/**
- * The DiffIntralineInfo entity contains information about intraline edits in a
- * file.
- *
- * The information consists of a list of <skip length, mark length> pairs, where
- * the skip length is the number of characters between the end of the previous
- * edit and the start of this edit, and the mark length is the number of edited
- * characters following the skip. The start of the edits is from the beginning
- * of the related diff content lines.
- *
- * Note that the implied newline character at the end of each line is included
- * in the length calculation, and thus it is possible for the edits to span
- * newlines.
- *
- * @typedef {!Array<number>}
- */
-Gerrit.IntralineInfo;
-
-/**
- * A portion of the diff that is treated the same.
- *
- * Called `DiffContent` in the API, see
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-content
- *
- * @typedef {{
- *  ab: ?Array<!string>,
- *  a: ?Array<!string>,
- *  b: ?Array<!string>,
- *  skip: ?number,
- *  edit_a: ?Array<!Gerrit.IntralineInfo>,
- *  edit_b: ?Array<!Gerrit.IntralineInfo>,
- *  due_to_rebase: ?boolean,
- *  common: ?boolean
- * }}
- */
-Gerrit.DiffChunk;
-
-/**
- * Special line number which should not be collapsed into a shared region.
- *
- * @typedef {{
- *  number: number,
- *  leftSide: boolean
- * }}
- */
-Gerrit.LineOfInterest;
-
-/**
- * @typedef {{
- *    html: Node,
- *    position: number,
- *    length: number,
- * }}
- */
-Gerrit.CommentLinkItem;
-
-/**
- * @typedef {{
- *   name: string,
- *   value: Object,
- * }}
- */
-Gerrit.GrSuggestionItem;
-
-/**
- * @typedef {{
- *    getSuggestions: function(string): Promise<Array<Object>>,
- *    makeSuggestionItem: function(Object): Gerrit.GrSuggestionItem,
- * }}
- */
-Gerrit.GrSuggestionsProvider;
-
-/**
- * @typedef {{
- *  patch_set: ?number,
- *  id: ?string,
- *  path: ?Object,
- *  side: ?string,
- *  parent: ?number,
- *  line: ?Object,
- *  in_reply_to: ?string,
- *  message: ?Object,
- *  updated: ?string,
- *  author: ?Object,
- *  tag: ?Object,
- *  unresolved: ?boolean,
- *  robot_id: ?string,
- *  robot_run_id: ?string,
- *  url: ?string,
- *  properties: ?Object,
- *  fix_suggestions: ?Object,
- *  }}
- */
-Gerrit.Comment;
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
new file mode 100644
index 0000000..909f719
--- /dev/null
+++ b/polygerrit-ui/app/types/types.ts
@@ -0,0 +1,36 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export enum CoverageType {
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  COVERED = 'COVERED',
+  /**
+   * start_character and end_character of the range will be ignored for this
+   * type.
+   */
+  NOT_COVERED = 'NOT_COVERED',
+  PARTIALLY_COVERED = 'PARTIALLY_COVERED',
+  /**
+   * You don't have to use this. If there is no coverage information for a
+   * range, then it implicitly means NOT_INSTRUMENTED. start_character and
+   * end_character of the range will be ignored for this type.
+   */
+  NOT_INSTRUMENTED = 'NOT_INSTRUMENTED',
+}
diff --git a/polygerrit-ui/app/utils/access-util.ts b/polygerrit-ui/app/utils/access-util.ts
new file mode 100644
index 0000000..179fe74
--- /dev/null
+++ b/polygerrit-ui/app/utils/access-util.ts
@@ -0,0 +1,151 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export const AccessPermissions = {
+  abandon: {
+    id: 'abandon',
+    name: 'Abandon',
+  },
+  addPatchSet: {
+    id: 'addPatchSet',
+    name: 'Add Patch Set',
+  },
+  create: {
+    id: 'create',
+    name: 'Create Reference',
+  },
+  createTag: {
+    id: 'createTag',
+    name: 'Create Annotated Tag',
+  },
+  createSignedTag: {
+    id: 'createSignedTag',
+    name: 'Create Signed Tag',
+  },
+  delete: {
+    id: 'delete',
+    name: 'Delete Reference',
+  },
+  deleteChanges: {
+    id: 'deleteChanges',
+    name: 'Delete Changes',
+  },
+  deleteOwnChanges: {
+    id: 'deleteOwnChanges',
+    name: 'Delete Own Changes',
+  },
+  editAssignee: {
+    id: 'editAssignee',
+    name: 'Edit Assignee',
+  },
+  editHashtags: {
+    id: 'editHashtags',
+    name: 'Edit Hashtags',
+  },
+  editTopicName: {
+    id: 'editTopicName',
+    name: 'Edit Topic Name',
+  },
+  forgeAuthor: {
+    id: 'forgeAuthor',
+    name: 'Forge Author Identity',
+  },
+  forgeCommitter: {
+    id: 'forgeCommitter',
+    name: 'Forge Committer Identity',
+  },
+  forgeServerAsCommitter: {
+    id: 'forgeServerAsCommitter',
+    name: 'Forge Server Identity',
+  },
+  owner: {
+    id: 'owner',
+    name: 'Owner',
+  },
+  publishDrafts: {
+    id: 'publishDrafts',
+    name: 'Publish Drafts',
+  },
+  push: {
+    id: 'push',
+    name: 'Push',
+  },
+  pushMerge: {
+    id: 'pushMerge',
+    name: 'Push Merge Commit',
+  },
+  read: {
+    id: 'read',
+    name: 'Read',
+  },
+  rebase: {
+    id: 'rebase',
+    name: 'Rebase',
+  },
+  revert: {
+    id: 'revert',
+    name: 'Revert',
+  },
+  removeReviewer: {
+    id: 'removeReviewer',
+    name: 'Remove Reviewer',
+  },
+  submit: {
+    id: 'submit',
+    name: 'Submit',
+  },
+  submitAs: {
+    id: 'submitAs',
+    name: 'Submit (On Behalf Of)',
+  },
+  toggleWipState: {
+    id: 'toggleWipState',
+    name: 'Toggle Work In Progress State',
+  },
+  viewPrivateChanges: {
+    id: 'viewPrivateChanges',
+    name: 'View Private Changes',
+  },
+};
+
+interface AccessPermission {
+  id: string;
+  name: string;
+}
+
+/**
+ * @return {!Array} returns a sorted array sorted by the id of the original
+ *    object.
+ */
+export function toSortedPermissionsArray(
+  obj: Record<string, AccessPermission>
+) {
+  if (!obj) {
+    return [];
+  }
+  return Object.keys(obj)
+    .map(key => {
+      return {
+        id: key,
+        value: obj[key],
+      };
+    })
+    .sort((a, b) =>
+      // Since IDs are strings, use localeCompare.
+      a.id.localeCompare(b.id)
+    );
+}
diff --git a/polygerrit-ui/app/utils/access-util_test.js b/polygerrit-ui/app/utils/access-util_test.js
new file mode 100644
index 0000000..d4f5669
--- /dev/null
+++ b/polygerrit-ui/app/utils/access-util_test.js
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {toSortedPermissionsArray} from './access-util.js';
+
+suite('gr-access-behavior tests', () => {
+  test('toSortedPermissionsArray', () => {
+    const rules = {
+      'global:Project-Owners': {
+        action: 'ALLOW', force: false,
+      },
+      '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+        action: 'ALLOW', force: false,
+      },
+    };
+    const expectedResult = [
+      {id: '4c97682e6ce6b7247f3381b6f1789356666de7f', value: {
+        action: 'ALLOW', force: false,
+      }},
+      {id: 'global:Project-Owners', value: {
+        action: 'ALLOW', force: false,
+      }},
+    ];
+    assert.deepEqual(toSortedPermissionsArray(rules), expectedResult);
+  });
+});
+
diff --git a/polygerrit-ui/app/utils/admin-nav-util.js b/polygerrit-ui/app/utils/admin-nav-util.js
new file mode 100644
index 0000000..8356db1
--- /dev/null
+++ b/polygerrit-ui/app/utils/admin-nav-util.js
@@ -0,0 +1,197 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {GerritNav} from '../elements/core/gr-navigation/gr-navigation.js';
+
+const ADMIN_LINKS = [{
+  name: 'Repositories',
+  noBaseUrl: true,
+  url: '/admin/repos',
+  view: 'gr-repo-list',
+  viewableToAll: true,
+}, {
+  name: 'Groups',
+  section: 'Groups',
+  noBaseUrl: true,
+  url: '/admin/groups',
+  view: 'gr-admin-group-list',
+}, {
+  name: 'Plugins',
+  capability: 'viewPlugins',
+  section: 'Plugins',
+  noBaseUrl: true,
+  url: '/admin/plugins',
+  view: 'gr-plugin-list',
+}];
+
+/**
+ * @param {!Object} account
+ * @param {!Function} getAccountCapabilities
+ * @param {!Function} getAdminMenuLinks
+ *  Possible aguments in options:
+ *    repoName?: string
+ *    groupId?: string,
+ *    groupName?: string,
+ *    groupIsInternal?: boolean,
+ *    isAdmin?: boolean,
+ *    groupOwner?: boolean,
+ * @param {!Object=} opt_options
+ * @return {Promise<!Object>}
+ */
+export function getAdminLinks(account, getAccountCapabilities,
+    getAdminMenuLinks, opt_options) {
+  if (!account) {
+    return Promise.resolve(_filterLinks(link => link.viewableToAll,
+        getAdminMenuLinks, opt_options));
+  }
+  return getAccountCapabilities()
+      .then(capabilities => _filterLinks(
+          link => !link.capability
+          || capabilities.hasOwnProperty(link.capability),
+          getAdminMenuLinks,
+          opt_options));
+}
+
+/**
+ * @param {!Function} filterFn
+ * @param {!Function} getAdminMenuLinks
+ *  Possible aguments in options:
+ *    repoName?: string
+ *    groupId?: string,
+ *    groupName?: string,
+ *    groupIsInternal?: boolean,
+ *    isAdmin?: boolean,
+ *    groupOwner?: boolean,
+ * @param {!Object|undefined} opt_options
+ * @return {Promise<!Object>}
+ */
+function _filterLinks(filterFn, getAdminMenuLinks, opt_options) {
+  let links = ADMIN_LINKS.slice(0);
+  let expandedSection;
+
+  const isExternalLink = link => link.url[0] !== '/';
+
+  // Append top-level links that are defined by plugins.
+  links.push(...getAdminMenuLinks().map(link => {
+    return {
+      url: link.url,
+      name: link.text,
+      capability: link.capability || null,
+      noBaseUrl: !isExternalLink(link),
+      view: null,
+      viewableToAll: !link.capability,
+      target: isExternalLink(link) ? '_blank' : null,
+    };
+  }));
+
+  links = links.filter(filterFn);
+
+  const filteredLinks = [];
+  const repoName = opt_options && opt_options.repoName;
+  const groupId = opt_options && opt_options.groupId;
+  const groupName = opt_options && opt_options.groupName;
+  const groupIsInternal = opt_options && opt_options.groupIsInternal;
+  const isAdmin = opt_options && opt_options.isAdmin;
+  const groupOwner = opt_options && opt_options.groupOwner;
+
+  // Don't bother to get sub-navigation items if only the top level links
+  // are needed. This is used by the main header dropdown.
+  if (!repoName && !groupId) { return {links, expandedSection}; }
+
+  // Otherwise determine the full set of links and return both the full
+  // set in addition to the subsection that should be displayed if it
+  // exists.
+  for (const link of links) {
+    const linkCopy = {...link};
+    if (linkCopy.name === 'Repositories' && repoName) {
+      linkCopy.subsection = getRepoSubsections(repoName);
+      expandedSection = linkCopy.subsection;
+    } else if (linkCopy.name === 'Groups' && groupId && groupName) {
+      linkCopy.subsection = getGroupSubsections(groupId, groupName,
+          groupIsInternal, isAdmin, groupOwner);
+      expandedSection = linkCopy.subsection;
+    }
+    filteredLinks.push(linkCopy);
+  }
+  return {links: filteredLinks, expandedSection};
+}
+
+export function getGroupSubsections(groupId, groupName, groupIsInternal,
+    isAdmin, groupOwner) {
+  const subsection = {
+    name: groupName,
+    view: GerritNav.View.GROUP,
+    url: GerritNav.getUrlForGroup(groupId),
+    children: [],
+  };
+  if (groupIsInternal) {
+    subsection.children.push({
+      name: 'Members',
+      detailType: GerritNav.GroupDetailView.MEMBERS,
+      view: GerritNav.View.GROUP,
+      url: GerritNav.getUrlForGroupMembers(groupId),
+    });
+  }
+  if (groupIsInternal && (isAdmin || groupOwner)) {
+    subsection.children.push(
+        {
+          name: 'Audit Log',
+          detailType: GerritNav.GroupDetailView.LOG,
+          view: GerritNav.View.GROUP,
+          url: GerritNav.getUrlForGroupLog(groupId),
+        }
+    );
+  }
+  return subsection;
+}
+
+export function getRepoSubsections(repoName) {
+  return {
+    name: repoName,
+    view: GerritNav.View.REPO,
+    url: GerritNav.getUrlForRepo(repoName),
+    children: [{
+      name: 'Access',
+      view: GerritNav.View.REPO,
+      detailType: GerritNav.RepoDetailView.ACCESS,
+      url: GerritNav.getUrlForRepoAccess(repoName),
+    },
+    {
+      name: 'Commands',
+      view: GerritNav.View.REPO,
+      detailType: GerritNav.RepoDetailView.COMMANDS,
+      url: GerritNav.getUrlForRepoCommands(repoName),
+    },
+    {
+      name: 'Branches',
+      view: GerritNav.View.REPO,
+      detailType: GerritNav.RepoDetailView.BRANCHES,
+      url: GerritNav.getUrlForRepoBranches(repoName),
+    },
+    {
+      name: 'Tags',
+      view: GerritNav.View.REPO,
+      detailType: GerritNav.RepoDetailView.TAGS,
+      url: GerritNav.getUrlForRepoTags(repoName),
+    },
+    {
+      name: 'Dashboards',
+      view: GerritNav.View.REPO,
+      detailType: GerritNav.RepoDetailView.DASHBOARDS,
+      url: GerritNav.getUrlForRepoDashboards(repoName),
+    }],
+  };
+}
diff --git a/polygerrit-ui/app/utils/admin-nav-util_test.js b/polygerrit-ui/app/utils/admin-nav-util_test.js
new file mode 100644
index 0000000..a3dc87a
--- /dev/null
+++ b/polygerrit-ui/app/utils/admin-nav-util_test.js
@@ -0,0 +1,335 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {getAdminLinks} from './admin-nav-util.js';
+
+suite('gr-admin-nav-behavior tests', () => {
+  let capabilityStub;
+  let menuLinkStub;
+
+  setup(() => {
+    capabilityStub = sinon.stub();
+    menuLinkStub = sinon.stub().returns([]);
+  });
+
+  const testAdminLinks = (account, options, expected, done) => {
+    getAdminLinks(account,
+        capabilityStub,
+        menuLinkStub,
+        options)
+        .then(res => {
+          assert.equal(expected.totalLength, res.links.length);
+          assert.equal(res.links[0].name, 'Repositories');
+          // Repos
+          if (expected.groupListShown) {
+            assert.equal(res.links[1].name, 'Groups');
+          }
+
+          if (expected.pluginListShown) {
+            assert.equal(res.links[2].name, 'Plugins');
+            assert.isNotOk(res.links[2].subsection);
+          }
+
+          if (expected.projectPageShown) {
+            assert.isOk(res.links[0].subsection);
+            assert.equal(res.links[0].subsection.children.length, 5);
+          } else {
+            assert.isNotOk(res.links[0].subsection);
+          }
+          // Groups
+          if (expected.groupPageShown) {
+            assert.isOk(res.links[1].subsection);
+            assert.equal(res.links[1].subsection.children.length,
+                expected.groupSubpageLength);
+          } else if ( expected.totalLength > 1) {
+            assert.isNotOk(res.links[1].subsection);
+          }
+
+          if (expected.pluginGeneratedLinks) {
+            for (const link of expected.pluginGeneratedLinks) {
+              const linkMatch = res.links
+                  .find(l => (l.url === link.url && l.name === link.text));
+              assert.isTrue(!!linkMatch);
+
+              // External links should open in new tab.
+              if (link.url[0] !== '/') {
+                assert.equal(linkMatch.target, '_blank');
+              } else {
+                assert.isNotOk(linkMatch.target);
+              }
+            }
+          }
+
+          // Current section
+          if (expected.projectPageShown || expected.groupPageShown) {
+            assert.isOk(res.expandedSection);
+            assert.isOk(res.expandedSection.children);
+          } else {
+            assert.isNotOk(res.expandedSection);
+          }
+          if (expected.projectPageShown) {
+            assert.equal(res.expandedSection.name, 'my-repo');
+            assert.equal(res.expandedSection.children.length, 5);
+          } else if (expected.groupPageShown) {
+            assert.equal(res.expandedSection.name, 'my-group');
+            assert.equal(res.expandedSection.children.length,
+                expected.groupSubpageLength);
+          }
+          done();
+        });
+  };
+
+  suite('logged out', () => {
+    let account;
+    let expected;
+
+    setup(() => {
+      expected = {
+        groupListShown: false,
+        groupPageShown: false,
+        pluginListShown: false,
+      };
+    });
+
+    test('without a specific repo or group', done => {
+      let options;
+      expected = Object.assign(expected, {
+        totalLength: 1,
+        projectPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('with a repo', done => {
+      const options = {repoName: 'my-repo'};
+      expected = Object.assign(expected, {
+        totalLength: 1,
+        projectPageShown: true,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('with plugin generated links', done => {
+      let options;
+      const generatedLinks = [
+        {text: 'internal link text', url: '/internal/link/url'},
+        {text: 'external link text', url: 'http://external/link/url'},
+      ];
+      menuLinkStub.returns(generatedLinks);
+      expected = Object.assign(expected, {
+        totalLength: 3,
+        projectPageShown: false,
+        pluginGeneratedLinks: generatedLinks,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+
+  suite('no plugin capability logged in', () => {
+    const account = {
+      name: 'test-user',
+    };
+    let expected;
+
+    setup(() => {
+      expected = {
+        totalLength: 2,
+        pluginListShown: false,
+      };
+      capabilityStub.returns(Promise.resolve({}));
+    });
+
+    test('without a specific project or group', done => {
+      let options;
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupListShown: true,
+        groupPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('with a repo', done => {
+      const account = {
+        name: 'test-user',
+      };
+      const options = {repoName: 'my-repo'};
+      expected = Object.assign(expected, {
+        projectPageShown: true,
+        groupListShown: true,
+        groupPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+
+  suite('view plugin capability logged in', () => {
+    const account = {
+      name: 'test-user',
+    };
+    let expected;
+
+    setup(() => {
+      capabilityStub.returns(Promise.resolve({viewPlugins: true}));
+      expected = {
+        totalLength: 3,
+        groupListShown: true,
+        pluginListShown: true,
+      };
+    });
+
+    test('without a specific repo or group', done => {
+      let options;
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('with a repo', done => {
+      const options = {repoName: 'my-repo'};
+      expected = Object.assign(expected, {
+        projectPageShown: true,
+        groupPageShown: false,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('admin with internal group', done => {
+      const options = {
+        groupId: 'a15262',
+        groupName: 'my-group',
+        groupIsInternal: true,
+        isAdmin: true,
+        groupOwner: false,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 2,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('group owner with internal group', done => {
+      const options = {
+        groupId: 'a15262',
+        groupName: 'my-group',
+        groupIsInternal: true,
+        isAdmin: false,
+        groupOwner: true,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 2,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('non owner or admin with internal group', done => {
+      const options = {
+        groupId: 'a15262',
+        groupName: 'my-group',
+        groupIsInternal: true,
+        isAdmin: false,
+        groupOwner: false,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 1,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+
+    test('admin with external group', done => {
+      const options = {
+        groupId: 'a15262',
+        groupName: 'my-group',
+        groupIsInternal: false,
+        isAdmin: true,
+        groupOwner: true,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 0,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+
+  suite('view plugin screen with plugin capability', () => {
+    const account = {
+      name: 'test-user',
+    };
+    let expected;
+
+    setup(() => {
+      capabilityStub.returns(Promise.resolve({pluginCapability: true}));
+      expected = {};
+    });
+
+    test('with plugin with capabilities', done => {
+      let options;
+      const generatedLinks = [
+        {text: 'without capability', url: '/without'},
+        {text: 'with capability',
+          url: '/with',
+          capability: 'pluginCapability'},
+      ];
+      menuLinkStub.returns(generatedLinks);
+      expected = Object.assign(expected, {
+        totalLength: 4,
+        pluginGeneratedLinks: generatedLinks,
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+
+  suite('view plugin screen without plugin capability', () => {
+    const account = {
+      name: 'test-user',
+    };
+    let expected;
+
+    setup(() => {
+      capabilityStub.returns(Promise.resolve({}));
+      expected = {};
+    });
+
+    test('with plugin with capabilities', done => {
+      let options;
+      const generatedLinks = [
+        {text: 'without capability', url: '/without'},
+        {text: 'with capability',
+          url: '/with',
+          capability: 'pluginCapability'},
+      ];
+      menuLinkStub.returns(generatedLinks);
+      expected = Object.assign(expected, {
+        totalLength: 3,
+        pluginGeneratedLinks: [generatedLinks[0]],
+      });
+      testAdminLinks(account, options, expected, done);
+    });
+  });
+});
+
diff --git a/polygerrit-ui/app/utils/async-util.js b/polygerrit-ui/app/utils/async-util.js
new file mode 100644
index 0000000..14d0288
--- /dev/null
+++ b/polygerrit-ui/app/utils/async-util.js
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @template T
+ * @param {!Array<T>} array
+ * @param {!Function} fn An iteratee function to be passed each element of
+ *     the array in order. Must return a promise, and the following
+ *     iteration will not begin until resolution of the promise returned by
+ *     the previous iteration.
+ *
+ *     An optional second argument to fn is a callback that will halt the
+ *     loop if called.
+ * @return {!Promise<undefined>}
+ */
+export function asyncForeach(array, fn) {
+  if (!array.length) { return Promise.resolve(); }
+  let stop = false;
+  const stopCallback = () => { stop = true; };
+  return fn(array[0], stopCallback).then(exit => {
+    if (stop) { return Promise.resolve(); }
+    return asyncForeach(array.slice(1), fn);
+  });
+}
diff --git a/polygerrit-ui/app/utils/async-util_test.js b/polygerrit-ui/app/utils/async-util_test.js
new file mode 100644
index 0000000..df29e97
--- /dev/null
+++ b/polygerrit-ui/app/utils/async-util_test.js
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {asyncForeach} from './async-util.js';
+
+suite('async-util tests', () => {
+  test('loops over each item', () => {
+    const fn = sinon.stub().returns(Promise.resolve());
+    return asyncForeach([1, 2, 3], fn)
+        .then(() => {
+          assert.isTrue(fn.calledThrice);
+          assert.equal(fn.getCall(0).args[0], 1);
+          assert.equal(fn.getCall(1).args[0], 2);
+          assert.equal(fn.getCall(2).args[0], 3);
+        });
+  });
+
+  test('halts on stop condition', () => {
+    const stub = sinon.stub();
+    const fn = (e, stop) => {
+      stub(e);
+      stop();
+      return Promise.resolve();
+    };
+    return asyncForeach([1, 2, 3], fn)
+        .then(() => {
+          assert.isTrue(stub.calledOnce);
+          assert.equal(stub.lastCall.args[0], 1);
+        });
+  });
+});
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
new file mode 100644
index 0000000..5a39b23
--- /dev/null
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -0,0 +1,200 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {getBaseUrl} from './url-util';
+import {ChangeStatus} from '../constants/constants';
+
+// WARNING: The types below can be completely wrong!
+// The types was added to avoid eslinter and typescript errors.
+// Correct typing requires more analysis and (probably) code changes.
+// This will be done later.
+type ChangeNum = string; // This can be wrong! See WARNING above
+type PatchNum = string; // This can be wrong! See WARNING above
+
+// This can be wrong! See WARNING above
+interface Change {
+  status: string; // This can be wrong! See WARNING above
+  mergeable: boolean; // This can be wrong! See WARNING above
+  work_in_progress: boolean; // This can be wrong! See WARNING above
+  is_private: boolean; // This can be wrong! See WARNING above
+  submittable: boolean; // This can be wrong! See WARNING above
+}
+
+// This can be wrong! See WARNING above
+interface ChangeStatusesOptions {
+  mergeable: boolean; // This can be wrong! See WARNING above
+  submitEnabled: boolean; // This can be wrong! See WARNING above
+}
+
+export const ChangeDiffType = {
+  ADDED: 'ADDED',
+  COPIED: 'COPIED',
+  DELETED: 'DELETED',
+  MODIFIED: 'MODIFIED',
+  RENAMED: 'RENAMED',
+  REWRITE: 'REWRITE',
+};
+
+// Must be kept in sync with the ListChangesOption enum and protobuf.
+export const ListChangesOption = {
+  LABELS: 0,
+  DETAILED_LABELS: 8,
+
+  // Return information on the current patch set of the change.
+  CURRENT_REVISION: 1,
+  ALL_REVISIONS: 2,
+
+  // If revisions are included, parse the commit object.
+  CURRENT_COMMIT: 3,
+  ALL_COMMITS: 4,
+
+  // If a patch set is included, include the files of the patch set.
+  CURRENT_FILES: 5,
+  ALL_FILES: 6,
+
+  // If accounts are included, include detailed account info.
+  DETAILED_ACCOUNTS: 7,
+
+  // Include messages associated with the change.
+  MESSAGES: 9,
+
+  // Include allowed actions client could perform.
+  CURRENT_ACTIONS: 10,
+
+  // Set the reviewed boolean for the caller.
+  REVIEWED: 11,
+
+  // Include download commands for the caller.
+  DOWNLOAD_COMMANDS: 13,
+
+  // Include patch set weblinks.
+  WEB_LINKS: 14,
+
+  // Include consistency check results.
+  CHECK: 15,
+
+  // Include allowed change actions client could perform.
+  CHANGE_ACTIONS: 16,
+
+  // Include a copy of commit messages including review footers.
+  COMMIT_FOOTERS: 17,
+
+  // Include push certificate information along with any patch sets.
+  PUSH_CERTIFICATES: 18,
+
+  // Include change's reviewer updates.
+  REVIEWER_UPDATES: 19,
+
+  // Set the submittable boolean.
+  SUBMITTABLE: 20,
+
+  // If tracking ids are included, include detailed tracking ids info.
+  TRACKING_IDS: 21,
+
+  // Skip mergeability data.
+  SKIP_MERGEABLE: 22,
+
+  /**
+   * Skip diffstat computation that compute the insertions field (number of lines inserted) and
+   * deletions field (number of lines deleted)
+   */
+  SKIP_DIFFSTAT: 23,
+};
+
+export function listChangesOptionsToHex(...args: number[]) {
+  let v = 0;
+  for (let i = 0; i < args.length; i++) {
+    v |= 1 << args[i];
+  }
+  return v.toString(16);
+}
+
+/**
+ *  @return {string}
+ */
+export function changeBaseURL(
+  project: string,
+  changeNum: ChangeNum,
+  patchNum: PatchNum
+): string {
+  let v =
+    getBaseUrl() + '/changes/' + encodeURIComponent(project) + '~' + changeNum;
+  if (patchNum) {
+    v += '/revisions/' + patchNum;
+  }
+  return v;
+}
+
+export function changePath(changeNum: ChangeNum) {
+  return getBaseUrl() + '/c/' + changeNum;
+}
+
+export function changeIsOpen(change?: Change) {
+  return change && change.status === ChangeStatus.NEW;
+}
+
+/**
+ * @param {!Object} change
+ * @param {!Object=} opt_options
+ *
+ * @return {!Array}
+ */
+export function changeStatuses(
+  change: Change,
+  opt_options?: ChangeStatusesOptions
+) {
+  const states = [];
+  if (change.status === ChangeStatus.MERGED) {
+    states.push('Merged');
+  } else if (change.status === ChangeStatus.ABANDONED) {
+    states.push('Abandoned');
+  } else if (
+    change.mergeable === false ||
+    (opt_options && opt_options.mergeable === false)
+  ) {
+    // 'mergeable' prop may not always exist (@see Issue 6819)
+    states.push('Merge Conflict');
+  }
+  if (change.work_in_progress) {
+    states.push('WIP');
+  }
+  if (change.is_private) {
+    states.push('Private');
+  }
+
+  // If there are any pre-defined statuses, only return those. Otherwise,
+  // will determine the derived status.
+  if (states.length || !opt_options) {
+    return states;
+  }
+
+  // If no missing requirements, either active or ready to submit.
+  if (change.submittable && opt_options.submitEnabled) {
+    states.push('Ready to submit');
+  } else {
+    // Otherwise it is active.
+    states.push('Active');
+  }
+  return states;
+}
+
+/**
+ * @param {!Object} change
+ * @return {string}
+ */
+export function changeStatusString(change: Change) {
+  return changeStatuses(change).join(', ');
+}
diff --git a/polygerrit-ui/app/utils/change-util_test.js b/polygerrit-ui/app/utils/change-util_test.js
new file mode 100644
index 0000000..20b9578
--- /dev/null
+++ b/polygerrit-ui/app/utils/change-util_test.js
@@ -0,0 +1,202 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+  changeBaseURL,
+  changePath,
+  changeStatuses,
+  changeStatusString,
+} from './change-util.js';
+
+suite('change-util tests', () => {
+  let originalCanonicalPath;
+
+  suiteSetup(() => {
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = '/r';
+  });
+
+  suiteTeardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  test('changeBaseURL', () => {
+    assert.deepEqual(
+        changeBaseURL('test/project', '1', '2'),
+        '/r/changes/test%2Fproject~1/revisions/2'
+    );
+  });
+
+  test('changePath', () => {
+    assert.deepEqual(changePath('1'), '/r/c/1');
+  });
+
+  test('Open status', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: true,
+    };
+    let statuses = changeStatuses(change);
+    const statusString = changeStatusString(change);
+    assert.deepEqual(statuses, []);
+    assert.equal(statusString, '');
+
+    change.submittable = false;
+    statuses = changeStatuses(change,
+        {includeDerived: true});
+    assert.deepEqual(statuses, ['Active']);
+
+    // With no missing labels but no submitEnabled option.
+    change.submittable = true;
+    statuses = changeStatuses(change,
+        {includeDerived: true});
+    assert.deepEqual(statuses, ['Active']);
+
+    // Without missing labels and enabled submit
+    statuses = changeStatuses(change,
+        {includeDerived: true, submitEnabled: true});
+    assert.deepEqual(statuses, ['Ready to submit']);
+
+    change.mergeable = false;
+    change.submittable = true;
+    statuses = changeStatuses(change,
+        {includeDerived: true});
+    assert.deepEqual(statuses, ['Merge Conflict']);
+
+    delete change.mergeable;
+    change.submittable = true;
+    statuses = changeStatuses(change,
+        {includeDerived: true, mergeable: true, submitEnabled: true});
+    assert.deepEqual(statuses, ['Ready to submit']);
+
+    change.submittable = true;
+    statuses = changeStatuses(change,
+        {includeDerived: true, mergeable: false});
+    assert.deepEqual(statuses, ['Merge Conflict']);
+  });
+
+  test('Merge conflict', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+      mergeable: false,
+    };
+    const statuses = changeStatuses(change);
+    const statusString = changeStatusString(change);
+    assert.deepEqual(statuses, ['Merge Conflict']);
+    assert.equal(statusString, 'Merge Conflict');
+  });
+
+  test('mergeable prop undefined', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      labels: {},
+    };
+    const statuses = changeStatuses(change);
+    const statusString = changeStatusString(change);
+    assert.deepEqual(statuses, []);
+    assert.equal(statusString, '');
+  });
+
+  test('Merged status', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'MERGED',
+      labels: {},
+    };
+    const statuses = changeStatuses(change);
+    const statusString = changeStatusString(change);
+    assert.deepEqual(statuses, ['Merged']);
+    assert.equal(statusString, 'Merged');
+  });
+
+  test('Abandoned status', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'ABANDONED',
+      labels: {},
+    };
+    const statuses = changeStatuses(change);
+    const statusString = changeStatusString(change);
+    assert.deepEqual(statuses, ['Abandoned']);
+    assert.equal(statusString, 'Abandoned');
+  });
+
+  test('Open status with private and wip', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+      mergeable: true,
+    };
+    const statuses = changeStatuses(change);
+    const statusString = changeStatusString(change);
+    assert.deepEqual(statuses, ['WIP', 'Private']);
+    assert.equal(statusString, 'WIP, Private');
+  });
+
+  test('Merge conflict with private and wip', () => {
+    const change = {
+      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+      revisions: {
+        rev1: {_number: 1},
+      },
+      current_revision: 'rev1',
+      status: 'NEW',
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+      mergeable: false,
+    };
+    const statuses = changeStatuses(change);
+    const statusString = changeStatusString(change);
+    assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
+    assert.equal(statusString, 'Merge Conflict, WIP, Private');
+  });
+});
+
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
new file mode 100644
index 0000000..d144272
--- /dev/null
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * @fileoverview Functions in this file contains some widely used
+ * code patterns. If you noticed a repeated code and none of the existing util
+ * files are appropriate for it - you can wrap the code in a function and put it
+ * here. If you notice that several functions can be group together - create
+ * a separate util file for them.
+ */
+
+/**
+ * Wrapper for the Object.prototype.hasOwnProperty method
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function hasOwnProperty(obj: any, prop: PropertyKey) {
+  // Typescript rules don't allow to use obj.hasOwnProperty directly
+  return Object.prototype.hasOwnProperty.call(obj, prop);
+}
+
+// TODO(TS): move to common types once we have type utils
+// tslint:disable-next-line:no-any Required for constructor signature.
+export type Constructor<T> = new (...args: any[]) => T;
+
+/**
+ * Use the function for compile-time checking that all possible input
+ * values are processed
+ */
+export function assertNever(obj: never, msg: string): never {
+  console.error(msg, obj);
+  throw new Error(msg);
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.js b/polygerrit-ui/app/utils/common-util_test.js
new file mode 100644
index 0000000..60c0b0a
--- /dev/null
+++ b/polygerrit-ui/app/utils/common-util_test.js
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {hasOwnProperty} from './common-util.js';
+
+suite('common-util tests', () => {
+  suite('hasOwnProperty', () => {
+    test('object with the default prototype', () => {
+      const obj = {
+        'abc': 3,
+        'name with spaces': 5,
+      };
+      assert.isTrue(hasOwnProperty(obj, 'abc'));
+      assert.isTrue(hasOwnProperty(obj, 'name with spaces'));
+      assert.isFalse(hasOwnProperty(obj, 'def'));
+    });
+    test('object prototype has overriden hasOwnProperty', () => {
+      const F = function() {
+        this.abc = 23;
+      };
+      F.prototype.hasOwnProperty = function(key) {
+        return true;
+      };
+      const obj = new F();
+      assert.isTrue(hasOwnProperty(obj, 'abc'));
+      assert.isFalse(hasOwnProperty(obj, 'def'));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
new file mode 100644
index 0000000..98b86ec
--- /dev/null
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -0,0 +1,207 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const Duration = {
+  HOUR: 1000 * 60 * 60,
+  DAY: 1000 * 60 * 60 * 24,
+};
+
+export function parseDate(dateStr: string) {
+  // Timestamps are given in UTC and have the format
+  // "'yyyy-mm-dd hh:mm:ss.fffffffff'" where "'ffffffffff'" represents
+  // nanoseconds.
+  // Munge the date into an ISO 8061 format and parse that.
+  return new Date(dateStr.replace(' ', 'T') + 'Z');
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function isValidDate(date: any): date is Date {
+  return date instanceof Date && !isNaN(date.valueOf());
+}
+
+// similar to fromNow from moment.js
+export function fromNow(date: Date) {
+  const now = new Date();
+  const secondsAgo = Math.round((now.valueOf() - date.valueOf()) / 1000);
+  if (secondsAgo <= 44) return 'just now';
+  if (secondsAgo <= 89) return 'a minute ago';
+  const minutesAgo = Math.round(secondsAgo / 60);
+  if (minutesAgo <= 44) return `${minutesAgo} minutes ago`;
+  if (minutesAgo <= 89) return 'an hour ago';
+  const hoursAgo = Math.round(minutesAgo / 60);
+  if (hoursAgo <= 21) return `${hoursAgo} hours ago`;
+  if (hoursAgo <= 35) return 'a day ago';
+  const daysAgo = Math.round(hoursAgo / 24);
+  if (daysAgo <= 25) return `${daysAgo} days ago`;
+  if (daysAgo <= 45) return 'a month ago';
+  const monthsAgo = Math.round(daysAgo / 30);
+  if (daysAgo <= 319) return `${monthsAgo} months ago`;
+  if (daysAgo <= 547) return 'a year ago';
+  const yearsAgo = Math.round(daysAgo / 365);
+  return `${yearsAgo} years ago`;
+}
+
+/**
+ * Return true if date is within 24 hours and on the same day.
+ */
+export function isWithinDay(now: Date, date: Date) {
+  const diff = now.valueOf() - date.valueOf();
+  return diff < Duration.DAY && date.getDay() === now.getDay();
+}
+
+/**
+ * Returns true if date is from one to six months.
+ */
+export function isWithinHalfYear(now: Date, date: Date) {
+  const diff = now.valueOf() - date.valueOf();
+  return diff < 180 * Duration.DAY;
+}
+interface Options {
+  month?: string;
+  year?: string;
+  day?: string;
+  hour?: string;
+  hour12?: boolean;
+  minute?: string;
+  second?: string;
+}
+
+// TODO(dmfilippov): TS-Fix review this type. All fields here must be optional,
+// but this require some changes in the code. During JS->TS migration
+// we want to avoid code changes where possible, so for simplicity we
+// define it with almost all fields mandatory
+interface DateTimeFormatParts {
+  year: string;
+  month: string;
+  day: string;
+  hour: string;
+  minute: string;
+  second: string;
+  dayPeriod: string;
+  dayperiod?: string;
+  // Object can have other properties, but our code doesn't use it
+  [key: string]: string | undefined;
+}
+
+export function formatDate(date: Date, format: string) {
+  const options: Options = {};
+  if (format.includes('MM')) {
+    if (format.includes('MMM')) {
+      options.month = 'short';
+    } else {
+      options.month = '2-digit';
+    }
+  }
+  if (format.includes('YY')) {
+    if (format.includes('YYYY')) {
+      options.year = 'numeric';
+    } else {
+      options.year = '2-digit';
+    }
+  }
+
+  if (format.includes('DD')) {
+    options.day = '2-digit';
+  }
+
+  if (format.includes('HH')) {
+    options.hour = '2-digit';
+    options.hour12 = false;
+  }
+
+  if (format.includes('h')) {
+    options.hour = 'numeric';
+    options.hour12 = true;
+  }
+
+  if (format.includes('mm')) {
+    options.minute = '2-digit';
+  }
+
+  if (format.includes('ss')) {
+    options.second = '2-digit';
+  }
+  let locale = 'en-US';
+  // Workaround for Chrome 80, en-US is using h24 (midnight is 24:00),
+  // en-GB is using h23 (midnight is 00:00)
+  if (format.includes('HH')) {
+    locale = 'en-GB';
+  }
+
+  const dtf = new Intl.DateTimeFormat(locale, options);
+  const parts = dtf
+    .formatToParts(date)
+    .filter(o => o.type !== 'literal')
+    .reduce((acc, o: Intl.DateTimeFormatPart) => {
+      acc[o.type] = o.value;
+      return acc;
+    }, {} as DateTimeFormatParts);
+  if (format.includes('YY')) {
+    if (format.includes('YYYY')) {
+      format = format.replace('YYYY', parts.year);
+    } else {
+      format = format.replace('YY', parts.year);
+    }
+  }
+
+  if (format.includes('DD')) {
+    format = format.replace('DD', parts.day);
+  }
+
+  if (format.includes('HH')) {
+    format = format.replace('HH', parts.hour);
+  }
+
+  if (format.includes('h')) {
+    format = format.replace('h', parts.hour);
+  }
+
+  if (format.includes('mm')) {
+    format = format.replace('mm', parts.minute);
+  }
+
+  if (format.includes('ss')) {
+    format = format.replace('ss', parts.second);
+  }
+
+  if (format.includes('A')) {
+    if (parts.dayperiod) {
+      // Workaround for chrome 70 and below
+      format = format.replace('A', parts.dayperiod.toUpperCase());
+    } else {
+      format = format.replace('A', parts.dayPeriod.toUpperCase());
+    }
+  }
+  if (format.includes('MM')) {
+    if (format.includes('MMM')) {
+      format = format.replace('MMM', parts.month);
+    } else {
+      format = format.replace('MM', parts.month);
+    }
+  }
+  return format;
+}
+
+export function utcOffsetString() {
+  const now = new Date();
+  const tzo = -now.getTimezoneOffset();
+  const pad = (num: number) => {
+    const norm = Math.floor(Math.abs(num));
+    return (norm < 10 ? '0' : '') + norm.toString();
+  };
+  return ` UTC${tzo >= 0 ? '+' : '-'}${pad(tzo / 60)}:${pad(tzo % 60)}`;
+}
diff --git a/polygerrit-ui/app/utils/date-util_test.js b/polygerrit-ui/app/utils/date-util_test.js
new file mode 100644
index 0000000..7b22cc6
--- /dev/null
+++ b/polygerrit-ui/app/utils/date-util_test.js
@@ -0,0 +1,121 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../test/common-test-setup-karma.js';
+import {isValidDate, parseDate, fromNow, isWithinDay, isWithinHalfYear, formatDate} from './date-util.js';
+
+suite('date-util tests', () => {
+  suite('parseDate', () => {
+    test('parseDate server date', () => {
+      const parsed = parseDate('2015-09-15 20:34:00.000000000');
+      assert.equal('2015-09-15T20:34:00.000Z', parsed.toISOString());
+    });
+  });
+
+  suite('isValidDate', () => {
+    test('date is valid', () => {
+      assert.isTrue(isValidDate(new Date()));
+    });
+    test('broken date is invalid', () => {
+      assert.isFalse(isValidDate(new Date('xxx')));
+    });
+  });
+
+  suite('fromNow', () => {
+    test('test all variants', () => {
+      const fakeNow = new Date('May 08 2020 12:00:00');
+      sinon.useFakeTimers(fakeNow.getTime());
+      assert.equal('just now', fromNow(new Date('May 08 2020 11:59:30')));
+      assert.equal('a minute ago', fromNow(new Date('May 08 2020 11:59:00')));
+      assert.equal('5 minutes ago', fromNow(new Date('May 08 2020 11:55:00')));
+      assert.equal('an hour ago', fromNow(new Date('May 08 2020 11:00:00')));
+      assert.equal('3 hours ago', fromNow(new Date('May 08 2020 9:00:00')));
+      assert.equal('a day ago', fromNow(new Date('May 07 2020 12:00:00')));
+      assert.equal('3 days ago', fromNow(new Date('May 05 2020 12:00:00')));
+      assert.equal('a month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
+      assert.equal('2 months ago', fromNow(new Date('Mar 05 2020 12:00:00')));
+      assert.equal('a year ago', fromNow(new Date('May 05 2019 12:00:00')));
+      assert.equal('10 years ago', fromNow(new Date('May 05 2010 12:00:00')));
+    });
+  });
+
+  suite('isWithinDay', () => {
+    test('basics works', () => {
+      assert.isTrue(isWithinDay(new Date('May 08 2020 12:00:00'),
+          new Date('May 08 2020 02:00:00')));
+      assert.isFalse(isWithinDay(new Date('May 08 2020 12:00:00'),
+          new Date('May 07 2020 12:00:00')));
+    });
+  });
+
+  suite('isWithinHalfYear', () => {
+    test('basics works', () => {
+      assert.isTrue(isWithinHalfYear(new Date('May 08 2020 12:00:00'),
+          new Date('Feb 08 2020 12:00:00')));
+      assert.isFalse(isWithinHalfYear(new Date('May 08 2020 12:00:00'),
+          new Date('Nov 07 2019 12:00:00')));
+    });
+  });
+
+  suite('formatDate', () => {
+    test('works for standard format', () => {
+      const stdFormat = 'MMM DD, YYYY';
+      assert.equal('May 08, 2020',
+          formatDate(new Date('May 08 2020 12:00:00'), stdFormat));
+      assert.equal('Feb 28, 2020',
+          formatDate(new Date('Feb 28 2020 12:00:00'), stdFormat));
+
+      const time24Format = 'HH:mm:ss';
+      assert.equal('Feb 28, 2020 12:01:12',
+          formatDate(new Date('Feb 28 2020 12:01:12'), stdFormat + ' '
+          + time24Format));
+    });
+    test('works for euro format', () => {
+      const euroFormat = 'DD.MM.YYYY';
+      assert.equal('01.12.2019',
+          formatDate(new Date('Dec 01 2019 12:00:00'), euroFormat));
+      assert.equal('20.01.2002',
+          formatDate(new Date('Jan 20 2002 12:00:00'), euroFormat));
+
+      const time24Format = 'HH:mm:ss';
+      assert.equal('28.02.2020 00:01:12',
+          formatDate(new Date('Feb 28 2020 00:01:12'), euroFormat + ' '
+          + time24Format));
+    });
+    test('works for iso format', () => {
+      const isoFormat = 'YYYY-MM-DD';
+      assert.equal('2015-01-01',
+          formatDate(new Date('Jan 01 2015 12:00:00'), isoFormat));
+      assert.equal('2013-07-03',
+          formatDate(new Date('Jul 03 2013 12:00:00'), isoFormat));
+
+      const timeFormat = 'h:mm:ss A';
+      assert.equal('2013-07-03 5:00:00 AM',
+          formatDate(new Date('Jul 03 2013 05:00:00'), isoFormat + ' '
+          + timeFormat));
+      assert.equal('2013-07-03 5:00:00 PM',
+          formatDate(new Date('Jul 03 2013 17:00:00'), isoFormat + ' '
+          + timeFormat));
+    });
+    test('h:mm:ss A shows correctly midnight and midday', () => {
+      const timeFormat = 'h:mm A';
+      assert.equal('12:14 PM',
+          formatDate(new Date('Jul 03 2013 12:14:00'), timeFormat));
+      assert.equal('12:15 AM',
+          formatDate(new Date('Jul 03 2013 00:15:00'), timeFormat));
+    });
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/utils/display-name-util.ts b/polygerrit-ui/app/utils/display-name-util.ts
new file mode 100644
index 0000000..cb89751
--- /dev/null
+++ b/polygerrit-ui/app/utils/display-name-util.ts
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {AccountInfo, GroupInfo, ServerInfo} from '../types/common';
+import {DefaultDisplayNameConfig} from '../constants/constants';
+
+const ANONYMOUS_NAME = 'Anonymous';
+
+export function getUserName(
+  config?: ServerInfo,
+  account?: AccountInfo
+): string {
+  if (account && account.name) {
+    return account.name;
+  } else if (account && account.username) {
+    return account.username;
+  } else if (account && account.email) {
+    return account.email;
+  } else if (
+    config &&
+    config.user &&
+    config.user.anonymous_coward_name !== 'Anonymous Coward'
+  ) {
+    return config.user.anonymous_coward_name;
+  }
+
+  return ANONYMOUS_NAME;
+}
+
+export function getDisplayName(
+  config?: ServerInfo,
+  account?: AccountInfo
+): string {
+  if (account && account.display_name) {
+    return account.display_name;
+  }
+  if (!account || !account.name || !config || !config.accounts) {
+    return getUserName(config, account);
+  }
+  if (
+    config.accounts.default_display_name ===
+      DefaultDisplayNameConfig.USERNAME &&
+    account.username
+  ) {
+    return account.username;
+  }
+  if (
+    config.accounts.default_display_name === DefaultDisplayNameConfig.FIRST_NAME
+  ) {
+    return account.name.trim().split(' ')[0];
+  }
+  // Treat every other value as FULL_NAME.
+  return account.name;
+}
+
+export function getAccountDisplayName(
+  config: ServerInfo | undefined,
+  account: AccountInfo
+) {
+  const reviewerName = getUserName(config, account);
+  const reviewerEmail = _accountEmail(account.email);
+  const reviewerStatus = account.status ? '(' + account.status + ')' : '';
+  return [reviewerName, reviewerEmail, reviewerStatus]
+    .filter(p => p.length > 0)
+    .join(' ');
+}
+
+function _accountEmail(email?: string) {
+  if (typeof email !== 'undefined') {
+    return '<' + email + '>';
+  }
+  return '';
+}
+
+export const _testOnly_accountEmail = _accountEmail;
+
+export function getGroupDisplayName(group: GroupInfo) {
+  return group.name + ' (group)';
+}
diff --git a/polygerrit-ui/app/utils/display-name-util_test.js b/polygerrit-ui/app/utils/display-name-util_test.js
new file mode 100644
index 0000000..68dc2e5
--- /dev/null
+++ b/polygerrit-ui/app/utils/display-name-util_test.js
@@ -0,0 +1,193 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {getDisplayName, getUserName, getGroupDisplayName, getAccountDisplayName, _testOnly_accountEmail} from './display-name-util.js';
+
+suite('display-name-utils tests', () => {
+  // eslint-disable-next-line no-unused-vars
+  const config = {
+    user: {
+      anonymous_coward_name: 'Anonymous Coward',
+    },
+  };
+
+  test('getDisplayName name only', () => {
+    const account = {
+      name: 'test-name',
+    };
+    assert.equal(getDisplayName(config, account),
+        'test-name');
+  });
+
+  test('getDisplayName prefer displayName', () => {
+    const account = {
+      name: 'test-name',
+      display_name: 'better-name',
+    };
+    assert.equal(getDisplayName(config, account),
+        'better-name');
+  });
+
+  test('getDisplayName prefer username default', () => {
+    const account = {
+      name: 'test-name',
+      username: 'user-name',
+    };
+    const config = {
+      accounts: {
+        default_display_name: 'USERNAME',
+      },
+    };
+    assert.equal(getDisplayName(config, account),
+        'user-name');
+  });
+
+  test('getDisplayName prefer first name default', () => {
+    const account = {
+      name: 'firstname lastname',
+    };
+    const config = {
+      accounts: {
+        default_display_name: 'FIRST_NAME',
+      },
+    };
+    assert.equal(getDisplayName(config, account),
+        'firstname');
+  });
+
+  test('getDisplayName ignore leading whitespace for first name', () => {
+    const account = {
+      name: '   firstname lastname',
+    };
+    const config = {
+      accounts: {
+        default_display_name: 'FIRST_NAME',
+      },
+    };
+    assert.equal(getDisplayName(config, account),
+        'firstname');
+  });
+
+  test('getDisplayName full name default', () => {
+    const account = {
+      name: 'firstname lastname',
+    };
+    const config = {
+      accounts: {
+        default_display_name: 'FULL_NAME',
+      },
+    };
+    assert.equal(getDisplayName(config, account),
+        'firstname lastname');
+  });
+
+  test('getDisplayName name only', () => {
+    const account = {
+      name: 'test-name',
+    };
+    assert.deepEqual(getUserName(config, account),
+        'test-name');
+  });
+
+  test('getUserName username only', () => {
+    const account = {
+      username: 'test-user',
+    };
+    assert.deepEqual(getUserName(config, account),
+        'test-user');
+  });
+
+  test('getUserName email only', () => {
+    const account = {
+      email: 'test-user@test-url.com',
+    };
+    assert.deepEqual(getUserName(config, account),
+        'test-user@test-url.com');
+  });
+
+  test('getUserName returns not Anonymous Coward as the anon name', () => {
+    assert.deepEqual(getUserName(config, null),
+        'Anonymous');
+  });
+
+  test('getUserName for the config returning the anon name', () => {
+    const config = {
+      user: {
+        anonymous_coward_name: 'Test Anon',
+      },
+    };
+    assert.deepEqual(getUserName(config, null),
+        'Test Anon');
+  });
+
+  test('getAccountDisplayName - account with name only', () => {
+    assert.equal(
+        getAccountDisplayName(config,
+            {name: 'Some user name'}),
+        'Some user name');
+  });
+
+  test('getAccountDisplayName - account with email only', () => {
+    assert.equal(
+        getAccountDisplayName(config,
+            {email: 'my@example.com'}),
+        'my@example.com <my@example.com>');
+  });
+
+  test('getAccountDisplayName - account with name and status', () => {
+    assert.equal(
+        getAccountDisplayName(config, {
+          name: 'Some name',
+          status: 'OOO',
+        }),
+        'Some name (OOO)');
+  });
+
+  test('getAccountDisplayName - account with name and email', () => {
+    assert.equal(
+        getAccountDisplayName(config, {
+          name: 'Some name',
+          email: 'my@example.com',
+        }),
+        'Some name <my@example.com>');
+  });
+
+  test('getAccountDisplayName - account with name, email and status', () => {
+    assert.equal(
+        getAccountDisplayName(config, {
+          name: 'Some name',
+          email: 'my@example.com',
+          status: 'OOO',
+        }),
+        'Some name <my@example.com> (OOO)');
+  });
+
+  test('getGroupDisplayName', () => {
+    assert.equal(
+        getGroupDisplayName({name: 'Some user name'}),
+        'Some user name (group)');
+  });
+
+  test('_accountEmail', () => {
+    assert.equal(
+        _testOnly_accountEmail('email@gerritreview.com'),
+        '<email@gerritreview.com>');
+    assert.equal(_testOnly_accountEmail(undefined), '');
+  });
+});
+
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
new file mode 100644
index 0000000..2f8daa6
--- /dev/null
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -0,0 +1,256 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+
+/**
+ * Event emitted from polymer elements.
+ */
+export interface PolymerEvent extends EventApi, Event {}
+
+interface ElementWithShadowRoot extends Element {
+  shadowRoot: ShadowRoot;
+}
+
+/**
+ * Type guard for element with a shadowRoot.
+ */
+function isElementWithShadowRoot(
+  el: Element | ShadowRoot
+): el is ElementWithShadowRoot {
+  return 'shadowRoot' in el;
+}
+
+// TODO: maybe should have a better name for this
+function getPathFromNode(el: EventTarget) {
+  let tagName = '';
+  let id = '';
+  let className = '';
+  if (el instanceof Element) {
+    tagName = el.tagName;
+    id = el.id;
+    className = el.className;
+  }
+  if (
+    !tagName ||
+    'GR-APP' === tagName ||
+    el instanceof DocumentFragment ||
+    el instanceof HTMLSlotElement
+  ) {
+    return '';
+  }
+  let path = '';
+  if (tagName) {
+    path += tagName.toLowerCase();
+  }
+  if (id) {
+    path += `#${id}`;
+  }
+  if (className) {
+    path += `.${className.replace(/ /g, '.')}`;
+  }
+  return path;
+}
+
+/**
+ * Get computed style value.
+ *
+ * If ShadyCSS is provided, use ShadyCSS api.
+ * If `getComputedStyleValue` is provided on the element, use it.
+ * Otherwise fallback to native method (in polymer 2).
+ *
+ */
+export function getComputedStyleValue(
+  name: string,
+  el: Element | LegacyElementMixin
+) {
+  let style;
+  if (window.ShadyCSS) {
+    style = window.ShadyCSS.getComputedStyleValue(el as Element, name);
+    // `getComputedStyleValue` defined through LegacyElementMixin
+    // TODO: It should be safe to just use `getComputedStyle`, but just to be safe
+  } else if ('getComputedStyleValue' in el) {
+    style = el.getComputedStyleValue(name);
+  } else {
+    style = getComputedStyle(el).getPropertyValue(name);
+  }
+  return style;
+}
+
+/**
+ * Query selector on a dom element.
+ *
+ * This is shadow DOM compatible, but only works when selector is within
+ * one shadow host, won't work if your selector is crossing
+ * multiple shadow hosts.
+ *
+ */
+export function querySelector(
+  el: Element | ShadowRoot,
+  selector: string
+): Element | null {
+  let nodes = [el];
+  let result = null;
+  while (nodes.length) {
+    const node = nodes.pop();
+
+    // Skip if it's an invalid node.
+    if (!node || !node.querySelector) continue;
+
+    // Try find it with native querySelector directly
+    result = node.querySelector(selector);
+
+    if (result) {
+      break;
+    }
+
+    // Add all nodes with shadowRoot and loop through
+    const allShadowNodes = [...node.querySelectorAll('*')]
+      .filter(isElementWithShadowRoot)
+      .map(child => child.shadowRoot);
+    nodes = nodes.concat(allShadowNodes);
+
+    // Add shadowRoot of current node if has one
+    // as its not included in node.querySelectorAll('*')
+    if (isElementWithShadowRoot(node)) {
+      nodes.push(node.shadowRoot);
+    }
+  }
+  return result;
+}
+
+/**
+ * Query selector all dom elements matching with certain selector.
+ *
+ * This is shadow DOM compatible, but only works when selector is within
+ * one shadow host, won't work if your selector is crossing
+ * multiple shadow hosts.
+ *
+ * Note: this can be very expensive, only use when have to.
+ */
+export function querySelectorAll(
+  el: Element | ShadowRoot,
+  selector: string
+): Element[] {
+  let nodes = [el];
+  const results = new Set<Element>();
+  while (nodes.length) {
+    const node = nodes.pop();
+
+    if (!node || !node.querySelectorAll) continue;
+
+    // Try find all from regular children
+    [...node.querySelectorAll(selector)].forEach(el => results.add(el));
+
+    // Add all nodes with shadowRoot and loop through
+    const allShadowNodes = [...node.querySelectorAll('*')]
+      .filter(isElementWithShadowRoot)
+      .map(child => child.shadowRoot);
+    nodes = nodes.concat(allShadowNodes);
+
+    // Add shadowRoot of current node if has one
+    // as its not included in node.querySelectorAll('*')
+    if (isElementWithShadowRoot(node)) {
+      nodes.push(node.shadowRoot);
+    }
+  }
+  return [...results];
+}
+
+/**
+ * Retrieves the dom path of the current event.
+ *
+ * If the event object contains a `path` property, then use it,
+ * otherwise, construct the dom path based on the event target.
+ *
+ * domNode.onclick = e => {
+ *  getEventPath(e); // eg: div.class1>p#pid.class2
+ * }
+ */
+export function getEventPath<T extends PolymerEvent>(e?: T) {
+  if (!e) return '';
+
+  let path = e.path;
+  if (!path || !path.length) {
+    path = [];
+    let el = e.target;
+    while (el) {
+      path.push(el);
+      el = (el as Node).parentNode || (el as ShadowRoot).host;
+    }
+  }
+
+  return path.reduce<string>((domPath: string, curEl: EventTarget) => {
+    const pathForEl = getPathFromNode(curEl);
+    if (!pathForEl) return domPath;
+    return domPath ? `${pathForEl}>${domPath}` : pathForEl;
+  }, '');
+}
+
+/**
+ * Are any ancestors of the element (or the element itself) members of the
+ * given class.
+ *
+ */
+export function descendedFromClass(
+  element: Element,
+  className: string,
+  opt_stopElement: Element
+) {
+  let isDescendant = element.classList.contains(className);
+  while (
+    !isDescendant &&
+    element.parentElement &&
+    (!opt_stopElement || element.parentElement !== opt_stopElement)
+  ) {
+    isDescendant = element.classList.contains(className);
+    element = element.parentElement;
+  }
+  return isDescendant;
+}
+
+/**
+ * Convert any string into a valid class name.
+ *
+ * For class names, naming rules:
+ * Must begin with a letter A-Z or a-z
+ * Can be followed by: letters (A-Za-z), digits (0-9), hyphens ("-"), and underscores ("_")
+ *
+ * @param {string} str
+ * @param {string} prefix
+ */
+export function strToClassName(str = '', prefix = 'generated_') {
+  return `${prefix}${str.replace(/[^a-zA-Z0-9-_]/g, '_')}`;
+}
+
+// shared API element
+// TODO: once gr-js-api-interface moved to ts
+// use GrJsApiInterface instead
+let _sharedApiEl: Element;
+
+/**
+ * Retrieves the shared API element.
+ * We want to keep a single instance of API element instead of
+ * creating multiple elements.
+ */
+export function getSharedApiEl() {
+  if (!_sharedApiEl) {
+    _sharedApiEl = document.createElement('gr-js-api-interface');
+  }
+  return _sharedApiEl;
+}
diff --git a/polygerrit-ui/app/utils/dom-util_test.js b/polygerrit-ui/app/utils/dom-util_test.js
new file mode 100644
index 0000000..e2d61ed
--- /dev/null
+++ b/polygerrit-ui/app/utils/dom-util_test.js
@@ -0,0 +1,155 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../test/common-test-setup-karma.js';
+import {strToClassName, getComputedStyleValue, querySelector, querySelectorAll, descendedFromClass, getEventPath} from './dom-util.js';
+import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+
+class TestEle extends PolymerElement {
+  static get is() {
+    return 'dom-util-test-element';
+  }
+
+  static get template() {
+    return html`
+    <div>
+      <div class="a">
+        <div class="b">
+          <div class="c"></div>
+        </div>
+        <span class="ss"></span>
+      </div>
+      <span class="ss"></span>
+    </div>
+    `;
+  }
+}
+
+customElements.define(TestEle.is, TestEle);
+
+const basicFixture = fixtureFromTemplate(html`
+  <div id="test" class="a b c">
+    <a class="testBtn" style="color:red;"></a>
+    <dom-util-test-element></dom-util-test-element>
+    <span class="ss"></span>
+  </div>
+`);
+
+suite('dom-util tests', () => {
+  suite('getEventPath', () => {
+    test('empty event', () => {
+      assert.equal(getEventPath(), '');
+      assert.equal(getEventPath(null), '');
+      assert.equal(getEventPath(undefined), '');
+      assert.equal(getEventPath({}), '');
+    });
+
+    test('event with fake path', () => {
+      assert.equal(getEventPath({path: []}), '');
+      const dd = document.createElement('dd');
+      assert.equal(getEventPath({path: [dd]}), 'dd');
+    });
+
+    test('event with fake complicated path', () => {
+      const dd = document.createElement('dd');
+      dd.setAttribute('id', 'test');
+      dd.className = 'a b';
+      const divNode = document.createElement('DIV');
+      divNode.id = 'test2';
+      divNode.className = 'a b c';
+      assert.equal(getEventPath(
+          {path: [dd, divNode]}),
+      'div#test2.a.b.c>dd#test.a.b'
+      );
+    });
+
+    test('event with fake target', () => {
+      const fakeTargetParent1 = document.createElement('dd');
+      fakeTargetParent1.setAttribute('id', 'test');
+      fakeTargetParent1.className = 'a b';
+      const fakeTargetParent2 = document.createElement('DIV');
+      fakeTargetParent2.id = 'test2';
+      fakeTargetParent2.className = 'a b c';
+      fakeTargetParent2.appendChild(fakeTargetParent1);
+      const fakeTarget = document.createElement('SPAN');
+      fakeTargetParent1.appendChild(fakeTarget);
+      assert.equal(
+          getEventPath({target: fakeTarget}),
+          'div#test2.a.b.c>dd#test.a.b>span'
+      );
+    });
+
+    test('event with real click', () => {
+      const element = basicFixture.instantiate();
+      const aLink = element.querySelector('a');
+      let path;
+      aLink.onclick = e => path = getEventPath(e);
+      MockInteractions.click(aLink);
+      assert.equal(
+          path,
+          `html>body>test-fixture#${basicFixture.fixtureId}>` +
+          'div#test.a.b.c>a.testBtn'
+      );
+    });
+  });
+
+  suite('querySelector and querySelectorAll', () => {
+    test('query cross shadow dom', () => {
+      const element = basicFixture.instantiate();
+      const theFirstEl = querySelector(element, '.ss');
+      const allEls = querySelectorAll(element, '.ss');
+      assert.equal(allEls.length, 3);
+      assert.equal(theFirstEl, allEls[0]);
+    });
+  });
+
+  suite('getComputedStyleValue', () => {
+    test('color style', () => {
+      const element = basicFixture.instantiate();
+      const testBtn = querySelector(element, '.testBtn');
+      assert.equal(
+          getComputedStyleValue('color', testBtn), 'rgb(255, 0, 0)'
+      );
+    });
+  });
+
+  suite('descendedFromClass', () => {
+    test('basic tests', () => {
+      const element = basicFixture.instantiate();
+      const testEl = querySelector(element, 'dom-util-test-element');
+      // .c is a child of .a and not vice versa.
+      assert.isTrue(descendedFromClass(querySelector(testEl, '.c'), 'a'));
+      assert.isFalse(descendedFromClass(querySelector(testEl, '.a'), 'c'));
+
+      // Stops at stop element.
+      assert.isFalse(descendedFromClass(querySelector(testEl, '.c'), 'a',
+          querySelector(testEl, '.b')));
+    });
+  });
+
+  suite('strToClassName', () => {
+    test('basic tests', () => {
+      assert.equal(strToClassName(''), 'generated_');
+      assert.equal(strToClassName('11'), 'generated_11');
+      assert.equal(strToClassName('0.123'), 'generated_0_123');
+      assert.equal(strToClassName('0.123', 'prefix_'), 'prefix_0_123');
+      assert.equal(strToClassName('0>123', 'prefix_'), 'prefix_0_123');
+      assert.equal(strToClassName('0<123', 'prefix_'), 'prefix_0_123');
+      assert.equal(strToClassName('0+1+23', 'prefix_'), 'prefix_0_1_23');
+    });
+  });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/utils/patch-set-util.js b/polygerrit-ui/app/utils/patch-set-util.js
new file mode 100644
index 0000000..c27a8cd
--- /dev/null
+++ b/polygerrit-ui/app/utils/patch-set-util.js
@@ -0,0 +1,265 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Tags identifying ChangeMessages that move change into WIP state.
+const WIP_TAGS = [
+  'autogenerated:gerrit:newWipPatchSet',
+  'autogenerated:gerrit:setWorkInProgress',
+];
+
+// Tags identifying ChangeMessages that move change out of WIP state.
+const READY_TAGS = [
+  'autogenerated:gerrit:setReadyForReview',
+];
+
+export const SPECIAL_PATCH_SET_NUM = {
+  EDIT: 'edit',
+  PARENT: 'PARENT',
+};
+
+/**
+ * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
+ * this function checks for patchNum equality.
+ *
+ * @param {string|number} a
+ * @param {string|number|undefined} b Undefined sometimes because
+ *    computeLatestPatchNum can return undefined.
+ * @return {boolean}
+ */
+export function patchNumEquals(a, b) {
+  return a + '' === b + '';
+}
+
+/**
+ * Whether the given patch is a numbered parent of a merge (i.e. a negative
+ * number).
+ *
+ * @param  {string|number} n
+ * @return {boolean}
+ */
+export function isMergeParent(n) {
+  return (n + '')[0] === '-';
+}
+
+/**
+ * Given an object of revisions, get a particular revision based on patch
+ * num.
+ *
+ * @param {Object} revisions The object of revisions given by the API
+ * @param {number|string} patchNum The number index of the revision
+ * @return {Object} The correspondent revision obj from {revisions}
+ */
+export function getRevisionByPatchNum(revisions, patchNum) {
+  for (const rev of Object.values(revisions || {})) {
+    if (patchNumEquals(rev._number, patchNum)) {
+      return rev;
+    }
+  }
+}
+
+/**
+ * Find change edit base revision if change edit exists.
+ *
+ * @param {!Array<!Object>} revisions The revisions array.
+ * @return {Object} change edit parent revision or null if change edit
+ *     doesn't exist.
+ */
+export function findEditParentRevision(revisions) {
+  const editInfo =
+      revisions.find(info => info._number === SPECIAL_PATCH_SET_NUM.EDIT);
+
+  if (!editInfo) { return null; }
+
+  return revisions.find(info => info._number === editInfo.basePatchNum) ||
+      null;
+}
+
+/**
+ * Find change edit base patch set number if change edit exists.
+ *
+ * @param {!Array<!Object>} revisions The revisions array.
+ * @return {number} Change edit patch set number or -1.
+ */
+export function findEditParentPatchNum(revisions) {
+  const revisionInfo = findEditParentRevision(revisions);
+  return revisionInfo ? revisionInfo._number : -1;
+}
+
+/**
+ * Sort given revisions array according to the patch set number, in
+ * descending order.
+ * The sort algorithm is change edit aware. Change edit has patch set number
+ * equals 'edit', but must appear after the patch set it was based on.
+ * Example: change edit is based on patch set 2, and another patch set was
+ * uploaded after change edit creation, the sorted order should be:
+ * 3, edit, 2, 1.
+ *
+ * @param {!Array<!Object>} revisions The revisions array
+ * @return {!Array<!Object>} The sorted {revisions} array
+ */
+export function sortRevisions(revisions) {
+  const editParent = findEditParentPatchNum(revisions);
+  // Map a normal patchNum to 2 * (patchNum - 1) + 1... I.e. 1 -> 1,
+  // 2 -> 3, 3 -> 5, etc.
+  // Map an edit to the patchNum of parent*2... I.e. edit on 2 -> 4.
+  const num = r => (r._number === SPECIAL_PATCH_SET_NUM.EDIT ?
+    2 * editParent :
+    2 * (r._number - 1) + 1);
+  return revisions.sort((a, b) => num(b) - num(a));
+}
+
+/**
+ * Construct a chronological list of patch sets derived from change details.
+ * Each element of this list is an object with the following properties:
+ *
+ *   * num {number} The number identifying the patch set
+ *   * desc {!string} Optional patch set description
+ *   * wip {boolean} If true, this patch set was never subject to review.
+ *   * sha {string} hash of the commit
+ *
+ * The wip property is determined by the change's current work_in_progress
+ * property and its log of change messages.
+ *
+ * @param {!Object} change The change details
+ * @return {!Array<!Object>} Sorted list of patch set objects, as described
+ *     above
+ */
+export function computeAllPatchSets(change) {
+  if (!change) { return []; }
+  let patchNums = [];
+  if (change.revisions && Object.keys(change.revisions).length) {
+    const revisions = Object.keys(change.revisions)
+        .map(sha => { return {sha, ...change.revisions[sha]}; });
+    patchNums = sortRevisions(revisions)
+        .map(e => {
+          // TODO(kaspern): Mark which patchset an edit was made on, if an
+          // edit exists -- perhaps with a temporary description.
+          return {
+            num: e._number,
+            desc: e.description,
+            sha: e.sha,
+          };
+        });
+  }
+  return _computeWipForPatchSets(change, patchNums);
+}
+
+/**
+ * Populate the wip properties of the given list of patch sets.
+ *
+ * @param {!Object} change The change details
+ * @param {!Array<!Object>} patchNums Sorted list of patch set objects, as
+ *     generated by computeAllPatchSets
+ * @return {!Array<!Object>} The given list of patch set objects, with the
+ *     wip property set on each of them
+ */
+function _computeWipForPatchSets(change, patchNums) {
+  if (!change.messages || !change.messages.length) {
+    return patchNums;
+  }
+  const psWip = {};
+  let wip = change.work_in_progress;
+  for (let i = 0; i < change.messages.length; i++) {
+    const msg = change.messages[i];
+    if (WIP_TAGS.includes(msg.tag)) {
+      wip = true;
+    } else if (READY_TAGS.includes(msg.tag)) {
+      wip = false;
+    }
+    if (psWip[msg._revision_number] !== false) {
+      psWip[msg._revision_number] = wip;
+    }
+  }
+
+  for (let i = 0; i < patchNums.length; i++) {
+    patchNums[i].wip = psWip[patchNums[i].num];
+  }
+  return patchNums;
+}
+
+export const _testOnly_computeWipForPatchSets = _computeWipForPatchSets;
+
+/** @return {number|undefined} */
+export function computeLatestPatchNum(allPatchSets) {
+  if (!allPatchSets || !allPatchSets.length) { return undefined; }
+  if (allPatchSets[0].num === SPECIAL_PATCH_SET_NUM.EDIT) {
+    return allPatchSets[1].num;
+  }
+  return allPatchSets[0].num;
+}
+
+/** @return {boolean} */
+export function hasEditBasedOnCurrentPatchSet(allPatchSets) {
+  if (!allPatchSets || allPatchSets.length < 2) { return false; }
+  return allPatchSets[0].num === SPECIAL_PATCH_SET_NUM.EDIT;
+}
+
+/** @return {boolean} */
+export function hasEditPatchsetLoaded(patchRangeRecord) {
+  const patchRange = patchRangeRecord.base;
+  if (!patchRange) { return false; }
+  return patchRange.patchNum === SPECIAL_PATCH_SET_NUM.EDIT ||
+      patchRange.basePatchNum === SPECIAL_PATCH_SET_NUM.EDIT;
+}
+
+/**
+ * Check whether there is no newer patch than the latest patch that was
+ * available when this change was loaded.
+ *
+ * @return {Promise<!Object>} A promise that yields true if the latest patch
+ *     has been loaded, and false if a newer patch has been uploaded in the
+ *     meantime. The promise is rejected on network error.
+ */
+export function fetchChangeUpdates(change, restAPI) {
+  const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
+  return restAPI.getChangeDetail(change._number)
+      .then(detail => {
+        if (!detail) {
+          const error = new Error('Unable to check for latest patchset.');
+          return Promise.reject(error);
+        }
+        const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
+        return {
+          isLatest: actualLatest <= knownLatest,
+          newStatus: change.status !== detail.status ? detail.status : null,
+          newMessages: change.messages.length < detail.messages.length,
+        };
+      });
+}
+
+/**
+ * @param {number|string} patchNum
+ * @param {!Array<!Object>} revisions A sorted array of revisions.
+ *
+ * @return {number} The index of the revision with the given patchNum.
+ */
+export function findSortedIndex(patchNum, revisions) {
+  revisions = revisions || [];
+  const findNum = rev => rev._number + '' === patchNum + '';
+  return revisions.findIndex(findNum);
+}
+
+/**
+ * Convert parent indexes from patch range expressions to numbers.
+ * For example, in a patch range expression `"-3"` becomes `3`.
+ *
+ * @param {number|string} rangeBase
+ * @return {number}
+ */
+export function getParentIndex(rangeBase) {
+  return -parseInt(rangeBase + '', 10);
+}
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.js b/polygerrit-ui/app/utils/patch-set-util_test.js
new file mode 100644
index 0000000..29cc370
--- /dev/null
+++ b/polygerrit-ui/app/utils/patch-set-util_test.js
@@ -0,0 +1,339 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+  _testOnly_computeWipForPatchSets, computeAllPatchSets,
+  fetchChangeUpdates, findEditParentPatchNum, findEditParentRevision,
+  getParentIndex, getRevisionByPatchNum,
+  isMergeParent,
+  patchNumEquals, sortRevisions,
+} from './patch-set-util.js';
+
+suite('gr-patch-set-util tests', () => {
+  test('getRevisionByPatchNum', () => {
+    const revisions = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    assert.deepEqual(getRevisionByPatchNum(revisions, '1'), revisions[1]);
+    assert.deepEqual(getRevisionByPatchNum(revisions, 2), revisions[2]);
+    assert.equal(getRevisionByPatchNum(revisions, '3'), undefined);
+  });
+
+  test('fetchChangeUpdates on latest', done => {
+    const knownChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const mockRestApi = {
+      getChangeDetail() {
+        return Promise.resolve(knownChange);
+      },
+    };
+    fetchChangeUpdates(knownChange, mockRestApi)
+        .then(result => {
+          assert.isTrue(result.isLatest);
+          assert.isNotOk(result.newStatus);
+          assert.isFalse(result.newMessages);
+          done();
+        });
+  });
+
+  test('fetchChangeUpdates not on latest', done => {
+    const knownChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const actualChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+        sha3: {description: 'patch 3', _number: 3},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const mockRestApi = {
+      getChangeDetail() {
+        return Promise.resolve(actualChange);
+      },
+    };
+    fetchChangeUpdates(knownChange, mockRestApi)
+        .then(result => {
+          assert.isFalse(result.isLatest);
+          assert.isNotOk(result.newStatus);
+          assert.isFalse(result.newMessages);
+          done();
+        });
+  });
+
+  test('fetchChangeUpdates new status', done => {
+    const knownChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const actualChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'MERGED',
+      messages: [],
+    };
+    const mockRestApi = {
+      getChangeDetail() {
+        return Promise.resolve(actualChange);
+      },
+    };
+    fetchChangeUpdates(knownChange, mockRestApi)
+        .then(result => {
+          assert.isTrue(result.isLatest);
+          assert.equal(result.newStatus, 'MERGED');
+          assert.isFalse(result.newMessages);
+          done();
+        });
+  });
+
+  test('fetchChangeUpdates new messages', done => {
+    const knownChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [],
+    };
+    const actualChange = {
+      revisions: {
+        sha1: {description: 'patch 1', _number: 1},
+        sha2: {description: 'patch 2', _number: 2},
+      },
+      status: 'NEW',
+      messages: [{message: 'blah blah'}],
+    };
+    const mockRestApi = {
+      getChangeDetail() {
+        return Promise.resolve(actualChange);
+      },
+    };
+    fetchChangeUpdates(knownChange, mockRestApi)
+        .then(result => {
+          assert.isTrue(result.isLatest);
+          assert.isNotOk(result.newStatus);
+          assert.isTrue(result.newMessages);
+          done();
+        });
+  });
+
+  test('_computeWipForPatchSets', () => {
+    // Compute patch sets for a given timeline on a change. The initial WIP
+    // property of the change can be true or false. The map of tags by
+    // revision is keyed by patch set number. Each value is a list of change
+    // message tags in the order that they occurred in the timeline. These
+    // indicate actions that modify the WIP property of the change and/or
+    // create new patch sets.
+    //
+    // Returns the actual results with an assertWip method that can be used
+    // to compare against an expected value for a particular patch set.
+    const compute = (initialWip, tagsByRevision) => {
+      const change = {
+        messages: [],
+        work_in_progress: initialWip,
+      };
+      const revs = Object.keys(tagsByRevision).sort((a, b) => a - b);
+      for (const rev of revs) {
+        for (const tag of tagsByRevision[rev]) {
+          change.messages.push({
+            tag,
+            _revision_number: rev,
+          });
+        }
+      }
+      let patchNums = revs.map(rev => { return {num: rev}; });
+      patchNums = _testOnly_computeWipForPatchSets(
+          change, patchNums);
+      const actualWipsByRevision = {};
+      for (const patchNum of patchNums) {
+        actualWipsByRevision[patchNum.num] = patchNum.wip;
+      }
+      const verifier = {
+        assertWip(revision, expectedWip) {
+          const patchNum = patchNums.find(patchNum => patchNum.num == revision);
+          if (!patchNum) {
+            assert.fail('revision ' + revision + ' not found');
+          }
+          assert.equal(patchNum.wip, expectedWip,
+              'wip state for ' + revision + ' is ' +
+            patchNum.wip + '; expected ' + expectedWip);
+          return verifier;
+        },
+      };
+      return verifier;
+    };
+
+    compute(false, {1: ['upload']}).assertWip(1, false);
+    compute(true, {1: ['upload']}).assertWip(1, true);
+
+    const setWip = 'autogenerated:gerrit:setWorkInProgress';
+    const uploadInWip = 'autogenerated:gerrit:newWipPatchSet';
+    const clearWip = 'autogenerated:gerrit:setReadyForReview';
+
+    compute(false, {
+      1: ['upload', setWip],
+      2: ['upload'],
+      3: ['upload', clearWip],
+      4: ['upload', setWip],
+    }).assertWip(1, false) // Change was created with PS1 ready for review
+        .assertWip(2, true) // PS2 was uploaded during WIP
+        .assertWip(3, false) // PS3 was marked ready for review after upload
+        .assertWip(4, false); // PS4 was uploaded ready for review
+
+    compute(false, {
+      1: [uploadInWip, null, 'addReviewer'],
+      2: ['upload'],
+      3: ['upload', clearWip, setWip],
+      4: ['upload'],
+      5: ['upload', clearWip],
+      6: [uploadInWip],
+    }).assertWip(1, true) // Change was created in WIP
+        .assertWip(2, true) // PS2 was uploaded during WIP
+        .assertWip(3, false) // PS3 was marked ready for review
+        .assertWip(4, true) // PS4 was uploaded during WIP
+        .assertWip(5, false) // PS5 was marked ready for review
+        .assertWip(6, true); // PS6 was uploaded with WIP option
+  });
+
+  test('patchNumEquals', () => {
+    assert.isFalse(patchNumEquals('edit', 'PARENT'));
+    assert.isFalse(patchNumEquals('edit', NaN));
+    assert.isFalse(patchNumEquals(1, '2'));
+
+    assert.isTrue(patchNumEquals(1, '1'));
+    assert.isTrue(patchNumEquals(1, 1));
+    assert.isTrue(patchNumEquals('edit', 'edit'));
+    assert.isTrue(patchNumEquals('PARENT', 'PARENT'));
+  });
+
+  test('isMergeParent', () => {
+    assert.isFalse(isMergeParent(1));
+    assert.isFalse(isMergeParent(4321));
+    assert.isFalse(isMergeParent('52'));
+    assert.isFalse(isMergeParent('edit'));
+    assert.isFalse(isMergeParent('PARENT'));
+    assert.isFalse(isMergeParent(0));
+
+    assert.isTrue(isMergeParent(-23));
+    assert.isTrue(isMergeParent(-1));
+    assert.isTrue(isMergeParent('-42'));
+  });
+
+  test('findEditParentRevision', () => {
+    let revisions = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    assert.strictEqual(findEditParentRevision(revisions), null);
+
+    revisions = [...revisions, {_number: 'edit', basePatchNum: 3}];
+    assert.strictEqual(findEditParentRevision(revisions), null);
+
+    revisions = [...revisions, {_number: 3}];
+    assert.deepEqual(findEditParentRevision(revisions), {_number: 3});
+  });
+
+  test('findEditParentPatchNum', () => {
+    let revisions = [
+      {_number: 0},
+      {_number: 1},
+      {_number: 2},
+    ];
+    assert.equal(findEditParentPatchNum(revisions), -1);
+
+    revisions =
+        [...revisions, {_number: 'edit', basePatchNum: 3}, {_number: 3}];
+    assert.deepEqual(findEditParentPatchNum(revisions), 3);
+  });
+
+  test('sortRevisions', () => {
+    const revisions = [
+      {_number: 0},
+      {_number: 2},
+      {_number: 1},
+    ];
+    const sorted = [
+      {_number: 2},
+      {_number: 1},
+      {_number: 0},
+    ];
+
+    assert.deepEqual(sortRevisions(revisions), sorted);
+
+    // Edit patchset should follow directly after its basePatchNum.
+    revisions.push({_number: 'edit', basePatchNum: 2});
+    sorted.unshift({_number: 'edit', basePatchNum: 2});
+    assert.deepEqual(sortRevisions(revisions), sorted);
+
+    revisions[0].basePatchNum = 0;
+    const edit = sorted.shift();
+    edit.basePatchNum = 0;
+    // Edit patchset should be at index 2.
+    sorted.splice(2, 0, edit);
+    assert.deepEqual(sortRevisions(revisions), sorted);
+  });
+
+  test('getParentIndex', () => {
+    assert.equal(getParentIndex('-13'), 13);
+    assert.equal(getParentIndex(-4), 4);
+  });
+
+  test('computeAllPatchSets', () => {
+    const expected = [
+      {num: 4, desc: 'test', sha: 'rev4'},
+      {num: 3, desc: 'test', sha: 'rev3'},
+      {num: 2, desc: 'test', sha: 'rev2'},
+      {num: 1, desc: 'test', sha: 'rev1'},
+    ];
+    const patchNums = computeAllPatchSets({
+      revisions: {
+        rev3: {_number: 3, description: 'test', date: 3},
+        rev1: {_number: 1, description: 'test', date: 1},
+        rev4: {_number: 4, description: 'test', date: 4},
+        rev2: {_number: 2, description: 'test', date: 2},
+      },
+    });
+    assert.equal(patchNums.length, expected.length);
+    for (let i = 0; i < expected.length; i++) {
+      assert.deepEqual(patchNums[i], expected[i]);
+    }
+  });
+});
+
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
new file mode 100644
index 0000000..40fb8d4
--- /dev/null
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -0,0 +1,130 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {SpecialFilePath, FileInfoStatus} from '../constants/constants';
+import {FileInfo} from '../types/common';
+import {hasOwnProperty} from './common-util';
+
+/**
+ * @param {string} a
+ * @param {string} b
+ * @return {number}
+ */
+export function specialFilePathCompare(a: string, b: string) {
+  // The commit message always goes first.
+  if (a === SpecialFilePath.COMMIT_MESSAGE) {
+    return -1;
+  }
+  if (b === SpecialFilePath.COMMIT_MESSAGE) {
+    return 1;
+  }
+
+  // The merge list always comes next.
+  if (a === SpecialFilePath.MERGE_LIST) {
+    return -1;
+  }
+  if (b === SpecialFilePath.MERGE_LIST) {
+    return 1;
+  }
+
+  const aLastDotIndex = a.lastIndexOf('.');
+  const aExt = a.substr(aLastDotIndex + 1);
+  const aFile = a.substr(0, aLastDotIndex) || a;
+
+  const bLastDotIndex = b.lastIndexOf('.');
+  const bExt = b.substr(bLastDotIndex + 1);
+  const bFile = b.substr(0, bLastDotIndex) || b;
+
+  // Sort header files above others with the same base name.
+  const headerExts = ['h', 'hxx', 'hpp'];
+  if (aFile.length > 0 && aFile === bFile) {
+    if (headerExts.includes(aExt) && headerExts.includes(bExt)) {
+      return a.localeCompare(b);
+    }
+    if (headerExts.includes(aExt)) {
+      return -1;
+    }
+    if (headerExts.includes(bExt)) {
+      return 1;
+    }
+  }
+  return aFile.localeCompare(bFile) || a.localeCompare(b);
+}
+
+export function shouldHideFile(file: string) {
+  return file === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+}
+
+export function addUnmodifiedFiles(
+  files: {[filename: string]: FileInfo},
+  commentedPaths: {[fileName: string]: boolean}
+) {
+  if (!commentedPaths) return;
+  Object.keys(commentedPaths).forEach(commentedPath => {
+    if (hasOwnProperty(files, commentedPath) || shouldHideFile(commentedPath)) {
+      return;
+    }
+    // TODO(TS): either change FileInfo to mark delta and size optional
+    // or fill in 0 here
+    files[commentedPath] = {
+      status: FileInfoStatus.UNMODIFIED,
+    } as FileInfo;
+  });
+}
+
+export function computeDisplayPath(path: string) {
+  if (path === SpecialFilePath.COMMIT_MESSAGE) {
+    return 'Commit message';
+  } else if (path === SpecialFilePath.MERGE_LIST) {
+    return 'Merge list';
+  }
+  return path;
+}
+
+export function isMagicPath(path: string) {
+  return (
+    !!path &&
+    (path === SpecialFilePath.COMMIT_MESSAGE ||
+      path === SpecialFilePath.MERGE_LIST)
+  );
+}
+
+export function computeTruncatedPath(path: string) {
+  return truncatePath(computeDisplayPath(path));
+}
+
+/**
+ * Truncates URLs to display filename only
+ * Example
+ * // returns '.../text.html'
+ * util.truncatePath.('dir/text.html');
+ * Example
+ * // returns 'text.html'
+ * util.truncatePath.('text.html');
+ *
+ */
+export function truncatePath(path: string, threshold = 1) {
+  const pathPieces = path.split('/');
+
+  if (pathPieces.length <= threshold) {
+    return path;
+  }
+
+  const index = pathPieces.length - threshold;
+  // Character is an ellipsis.
+  return `\u2026/${pathPieces.slice(index).join('/')}`;
+}
diff --git a/polygerrit-ui/app/utils/path-list-util_test.js b/polygerrit-ui/app/utils/path-list-util_test.js
new file mode 100644
index 0000000..4d06344
--- /dev/null
+++ b/polygerrit-ui/app/utils/path-list-util_test.js
@@ -0,0 +1,161 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {SpecialFilePath} from '../constants/constants.js';
+import {
+  addUnmodifiedFiles,
+  computeDisplayPath,
+  isMagicPath,
+  specialFilePathCompare, truncatePath,
+} from './path-list-util.js';
+
+suite('path-list-utl tests', () => {
+  test('special sort', () => {
+    const testFiles = [
+      '/a.h',
+      '/MERGE_LIST',
+      '/a.cpp',
+      '/COMMIT_MSG',
+      '/asdasd',
+      '/mrPeanutbutter.py',
+    ];
+    assert.deepEqual(
+        testFiles.sort(specialFilePathCompare),
+        [
+          '/COMMIT_MSG',
+          '/MERGE_LIST',
+          '/a.h',
+          '/a.cpp',
+          '/asdasd',
+          '/mrPeanutbutter.py',
+        ]);
+  });
+
+  test('special file path sorting', () => {
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
+            specialFilePathCompare),
+        ['/COMMIT_MSG', '.a', '.b', 'file']);
+
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
+            specialFilePathCompare),
+        ['/COMMIT_MSG', '.b', 'foo/bar/baz.h', 'foo/bar/baz.cc']);
+
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hpp'].sort(
+            specialFilePathCompare),
+        ['/COMMIT_MSG', '.b', 'foo/bar/baz.hpp', 'foo/bar/baz.cc']);
+
+    assert.deepEqual(
+        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hxx'].sort(
+            specialFilePathCompare),
+        ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']);
+
+    assert.deepEqual(
+        ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(
+            specialFilePathCompare),
+        ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']);
+
+    // Regression test for Issue 4448.
+    assert.deepEqual(
+        [
+          'minidump/minidump_memory_writer.cc',
+          'minidump/minidump_memory_writer.h',
+          'minidump/minidump_thread_writer.cc',
+          'minidump/minidump_thread_writer.h',
+        ].sort(specialFilePathCompare),
+        [
+          'minidump/minidump_memory_writer.h',
+          'minidump/minidump_memory_writer.cc',
+          'minidump/minidump_thread_writer.h',
+          'minidump/minidump_thread_writer.cc',
+        ]);
+
+    // Regression test for Issue 4545.
+    assert.deepEqual(
+        [
+          'task_test.go',
+          'task.go',
+        ].sort(specialFilePathCompare),
+        [
+          'task.go',
+          'task_test.go',
+        ]);
+  });
+
+  test('file display name', () => {
+    assert.equal(computeDisplayPath('/foo/bar/baz'), '/foo/bar/baz');
+    assert.equal(computeDisplayPath('/foobarbaz'), '/foobarbaz');
+    assert.equal(computeDisplayPath('/COMMIT_MSG'), 'Commit message');
+    assert.equal(computeDisplayPath('/MERGE_LIST'), 'Merge list');
+  });
+
+  test('isMagicPath', () => {
+    assert.isFalse(isMagicPath(undefined));
+    assert.isFalse(isMagicPath('/foo.cc'));
+    assert.isTrue(isMagicPath('/COMMIT_MSG'));
+    assert.isTrue(isMagicPath('/MERGE_LIST'));
+  });
+
+  test('patchset level comments are hidden', () => {
+    const commentedPaths = {
+      [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: true,
+      'file1.txt': true,
+    };
+
+    const files = {'file2.txt': {status: 'M'}};
+    addUnmodifiedFiles(files, commentedPaths);
+    assert.equal(files['file1.txt'].status, 'U');
+    assert.equal(files['file2.txt'].status, 'M');
+    assert.isFalse(files.hasOwnProperty(
+        SpecialFilePath.PATCHSET_LEVEL_COMMENTS));
+  });
+
+  test('truncatePath with long path should add ellipsis', () => {
+    let path = 'level1/level2/level3/level4/file.js';
+    let shortenedPath = truncatePath(path);
+    // The expected path is truncated with an ellipsis.
+    const expectedPath = '\u2026/file.js';
+    assert.equal(shortenedPath, expectedPath);
+
+    path = 'level2/file.js';
+    shortenedPath = truncatePath(path);
+    assert.equal(shortenedPath, expectedPath);
+  });
+
+  test('truncatePath with opt_threshold', () => {
+    let path = 'level1/level2/level3/level4/file.js';
+    let shortenedPath = truncatePath(path, 2);
+    // The expected path is truncated with an ellipsis.
+    const expectedPath = '\u2026/level4/file.js';
+    assert.equal(shortenedPath, expectedPath);
+
+    path = 'level2/file.js';
+    shortenedPath = truncatePath(path, 2);
+    assert.equal(shortenedPath, path);
+  });
+
+  test('truncatePath with short path should not add ellipsis', () => {
+    const path = 'file.js';
+    const expectedPath = 'file.js';
+    const shortenedPath = truncatePath(path);
+    assert.equal(shortenedPath, expectedPath);
+  });
+});
+
diff --git a/polygerrit-ui/app/utils/safe-types-util.js b/polygerrit-ui/app/utils/safe-types-util.js
new file mode 100644
index 0000000..c4181db
--- /dev/null
+++ b/polygerrit-ui/app/utils/safe-types-util.js
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const SAFE_URL_PATTERN = /^(https?:\/\/|mailto:|[^:/?#]*(?:[/?#]|$))/i;
+
+/**
+ * Wraps a string to be used as a URL. An error is thrown if the string cannot
+ * be considered safe.
+ *
+ * @constructor
+ * @param {string} url the unwrapped, potentially unsafe URL.
+ */
+class SafeUrl {
+  constructor(url) {
+    if (!SAFE_URL_PATTERN.test(url)) {
+      throw new Error(`URL not marked as safe: ${url}`);
+    }
+    this._url = url;
+  }
+
+  toString() {
+    return this._url;
+  }
+}
+
+export const _testOnly_SafeUrl = SafeUrl;
+
+/**
+ * Get the string representation of the safe URL.
+ *
+ * @returns {string}
+ */
+export function safeTypesBridge(value, type) {
+  // If the value is being bound to a URL, ensure the value is wrapped in the
+  // SafeUrl type first. If the URL is not safe, allow the SafeUrl constructor
+  // to surface the error.
+  if (type === 'URL') {
+    let safeValue = null;
+    if (value instanceof SafeUrl) {
+      safeValue = value;
+    } else if (typeof value === 'string') {
+      safeValue = new SafeUrl(value);
+    }
+    if (safeValue) {
+      return safeValue.toString();
+    }
+  }
+
+  // If the value is being bound to a string or a constant, then the string
+  // can be used as is.
+  if (type === 'STRING' || type === 'CONSTANT') {
+    return value;
+  }
+
+  // Otherwise fail.
+  throw new Error(`Refused to bind value as ${type}: ${value}`);
+}
diff --git a/polygerrit-ui/app/utils/safe-types-util_test.js b/polygerrit-ui/app/utils/safe-types-util_test.js
new file mode 100644
index 0000000..e3968d0
--- /dev/null
+++ b/polygerrit-ui/app/utils/safe-types-util_test.js
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {safeTypesBridge, _testOnly_SafeUrl} from './safe-types-util.js';
+
+suite('safe-types-util tests', () => {
+  test('SafeUrl accepts valid urls', () => {
+    function accepts(url) {
+      const safeUrl = new _testOnly_SafeUrl(url);
+      assert.isOk(safeUrl);
+      assert.equal(url, safeUrl.toString());
+    }
+    accepts('http://www.google.com/');
+    accepts('https://www.google.com/');
+    accepts('HtTpS://www.google.com/');
+    accepts('//www.google.com/');
+    accepts('/c/1234/file/path.html@45');
+    accepts('#hash-url');
+    accepts('mailto:name@example.com');
+  });
+
+  test('SafeUrl rejects invalid urls', () => {
+    function rejects(url) {
+      assert.throws(() => { new _testOnly_SafeUrl(url); });
+    }
+    rejects('javascript://alert("evil");');
+    rejects('ftp:example.com');
+    rejects('data:text/html,scary business');
+  });
+
+  suite('safeTypesBridge', () => {
+    function acceptsString(value, type) {
+      assert.equal(safeTypesBridge(value, type),
+          value);
+    }
+
+    function rejects(value, type) {
+      assert.throws(() => { safeTypesBridge(value, type); });
+    }
+
+    test('accepts valid URL strings', () => {
+      acceptsString('/foo/bar', 'URL');
+      acceptsString('#baz', 'URL');
+    });
+
+    test('rejects invalid URL strings', () => {
+      rejects('javascript://void();', 'URL');
+    });
+
+    test('accepts SafeUrl values', () => {
+      const url = '/abc/123';
+      const safeUrl = new _testOnly_SafeUrl(url);
+      assert.equal(safeTypesBridge(safeUrl, 'URL'), url);
+    });
+
+    test('rejects non-string or non-SafeUrl types', () => {
+      rejects(3.1415926, 'URL');
+    });
+
+    test('accepts any binding to STRING or CONSTANT', () => {
+      acceptsString('foo/bar/baz', 'STRING');
+      acceptsString('lorem ipsum dolor', 'CONSTANT');
+    });
+
+    test('rejects all other types', () => {
+      rejects('foo', 'JAVASCRIPT');
+      rejects('foo', 'HTML');
+      rejects('foo', 'RESOURCE_URL');
+      rejects('foo', 'STYLE');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
new file mode 100644
index 0000000..15ab75b
--- /dev/null
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -0,0 +1,98 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+const PROBE_PATH = '/Documentation/index.html';
+const DOCS_BASE_PATH = '/Documentation';
+
+// NOTE: Below we define 2 types (DocUrlBehaviorConfig and RestApi) to avoid
+// type 'any'. These are temporary definitions and they must be
+// updated/moved/removed when we start converting our codebase to typescript.
+// Right now we are using these types here just for adding typescript support to
+// our build/test infrastructure. Doing so we avoid massive code updates at this
+// stage.
+
+// TODO: introduce global gerrit config type instead of DocUrlBehaviorConfig.
+// The DocUrlBehaviorConfig is a temporary type
+interface DocUrlBehaviorConfig {
+  gerrit?: {doc_url?: string};
+}
+
+// TODO: implement RestApi type correctly and remove interface from this file
+interface RestApi {
+  probePath(url: string): Promise<boolean>;
+}
+
+export function getBaseUrl(): string {
+  return window.CANONICAL_PATH || '';
+}
+
+let getDocsBaseUrlCachedPromise: Promise<string | null> | undefined;
+
+/**
+ * Get the docs base URL from either the server config or by probing.
+ *
+ * @param {Object} config The server config.
+ * @param {!Object} restApi A REST API instance
+ * @return {!Promise<string>} A promise that resolves with the docs base
+ *     URL.
+ */
+export function getDocsBaseUrl(
+  config: DocUrlBehaviorConfig,
+  restApi: RestApi
+): Promise<string | null> {
+  if (!getDocsBaseUrlCachedPromise) {
+    getDocsBaseUrlCachedPromise = new Promise(resolve => {
+      if (config && config.gerrit && config.gerrit.doc_url) {
+        resolve(config.gerrit.doc_url);
+      } else {
+        restApi.probePath(getBaseUrl() + PROBE_PATH).then(ok => {
+          resolve(ok ? getBaseUrl() + DOCS_BASE_PATH : null);
+        });
+      }
+    });
+  }
+  return getDocsBaseUrlCachedPromise;
+}
+
+export function _testOnly_clearDocsBaseUrlCache() {
+  getDocsBaseUrlCachedPromise = undefined;
+}
+
+/**
+ * Pretty-encodes a URL. Double-encodes the string, and then replaces
+ *   benevolent characters for legibility.
+ */
+export function encodeURL(url: string, replaceSlashes?: boolean): string {
+  // @see Issue 4255 regarding double-encoding.
+  let output = encodeURIComponent(encodeURIComponent(url));
+  // @see Issue 4577 regarding more readable URLs.
+  output = output.replace(/%253A/g, ':');
+  output = output.replace(/%2520/g, '+');
+  if (replaceSlashes) {
+    output = output.replace(/%252F/g, '/');
+  }
+  return output;
+}
+
+/**
+ * Single decode for URL components. Will decode plus signs ('+') to spaces.
+ * Note: because this function decodes once, it is not the inverse of
+ * encodeURL.
+ */
+export function singleDecodeURL(url: string): string {
+  const withoutPlus = url.replace(/\+/g, '%20');
+  return decodeURIComponent(withoutPlus);
+}
diff --git a/polygerrit-ui/app/utils/url-util_test.js b/polygerrit-ui/app/utils/url-util_test.js
new file mode 100644
index 0000000..0658be3
--- /dev/null
+++ b/polygerrit-ui/app/utils/url-util_test.js
@@ -0,0 +1,127 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+  getBaseUrl,
+  getDocsBaseUrl,
+  _testOnly_clearDocsBaseUrlCache,
+  encodeURL, singleDecodeURL,
+} from './url-util.js';
+
+suite('url-util tests', () => {
+  suite('getBaseUrl tests', () => {
+    let originialCanonicalPath;
+
+    suiteSetup(() => {
+      originialCanonicalPath = window.CANONICAL_PATH;
+      window.CANONICAL_PATH = '/r';
+    });
+
+    suiteTeardown(() => {
+      window.CANONICAL_PATH = originialCanonicalPath;
+    });
+
+    test('getBaseUrl', () => {
+      assert.deepEqual(getBaseUrl(), '/r');
+    });
+  });
+
+  suite('getDocsBaseUrl tests', () => {
+    setup(() => {
+      _testOnly_clearDocsBaseUrlCache();
+    });
+
+    test('null config', async () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(true)),
+      };
+      const docsBaseUrl = await getDocsBaseUrl(null, mockRestApi);
+      assert.isTrue(
+          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      assert.equal(docsBaseUrl, '/Documentation');
+    });
+
+    test('no doc config', async () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(true)),
+      };
+      const config = {gerrit: {}};
+      const docsBaseUrl = await getDocsBaseUrl(config, mockRestApi);
+      assert.isTrue(
+          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      assert.equal(docsBaseUrl, '/Documentation');
+    });
+
+    test('has doc config', async () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(true)),
+      };
+      const config = {gerrit: {doc_url: 'foobar'}};
+      const docsBaseUrl = await getDocsBaseUrl(config, mockRestApi);
+      assert.isFalse(mockRestApi.probePath.called);
+      assert.equal(docsBaseUrl, 'foobar');
+    });
+
+    test('no probe', async () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(false)),
+      };
+      const docsBaseUrl = await getDocsBaseUrl(null, mockRestApi);
+      assert.isTrue(
+          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      assert.isNotOk(docsBaseUrl);
+    });
+  });
+
+  suite('url encoding and decoding tests', () => {
+    suite('encodeURL', () => {
+      test('double encodes', () => {
+        assert.equal(encodeURL('abc?123'), 'abc%253F123');
+        assert.equal(encodeURL('def/ghi'), 'def%252Fghi');
+        assert.equal(encodeURL('jkl'), 'jkl');
+        assert.equal(encodeURL(''), '');
+      });
+
+      test('does not convert colons', () => {
+        assert.equal(encodeURL('mno:pqr'), 'mno:pqr');
+      });
+
+      test('converts spaces to +', () => {
+        assert.equal(encodeURL('words with spaces'), 'words+with+spaces');
+      });
+
+      test('does not convert slashes when configured', () => {
+        assert.equal(encodeURL('stu/vwx', true), 'stu/vwx');
+      });
+
+      test('does not convert slashes when configured', () => {
+        assert.equal(encodeURL('stu/vwx', true), 'stu/vwx');
+      });
+    });
+
+    suite('singleDecodeUrl', () => {
+      test('single decodes', () => {
+        assert.equal(singleDecodeURL('abc%3Fdef'), 'abc?def');
+      });
+
+      test('converts + to space', () => {
+        assert.equal(singleDecodeURL('ghi+jkl'), 'ghi jkl');
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/wct.conf.js b/polygerrit-ui/app/wct.conf.js
deleted file mode 100644
index 1a9300e..0000000
--- a/polygerrit-ui/app/wct.conf.js
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/*
-For some reason wct tries to install selenium into its node_modules
-directory on first run. If you've installed into /usr/local and
-aren't running wct as root, you're screwed. Turning this option off
-through skipSeleniumInstall seems to still work, so there's that.
-
-Sauce tests are disabled by default in order to run local tests
-only.  Run it with (saucelabs.com account required; free for open
-source): ./polygerrit-ui/app/run_test.sh --test_arg=--plugin --test_arg=sauce
-*/
-
-const headless = 'WCT_HEADLESS_MODE' in process.env ?
-  process.env['WCT_HEADLESS_MODE'] === '1' : false;
-
-const headlessBrowserOptions = {
-  chrome: ['start-maximized', 'headless', 'disable-gpu', 'no-sandbox'],
-  firefox: ['-headless'],
-};
-
-const defaultBrowserOptions = {
-  chrome: ['start-maximized'],
-  firefox: [],
-};
-
-module.exports = {
-  suites: ['test'],
-  npm: true,
-  moduleResolution: 'node',
-  wctPackageName: 'wct-browser-legacy',
-  plugins: {
-    local: {
-      skipSeleniumInstall: true,
-      browserOptions: headless ? headlessBrowserOptions : defaultBrowserOptions,
-    },
-    sauce: {
-      disabled: true,
-      browsers: [
-        'OS X 10.12/chrome',
-        'Windows 10/chrome',
-        'Linux/firefox',
-        'OS X 10.12/safari',
-        'Windows 10/microsoftedge',
-      ],
-    },
-  },
-};
diff --git a/polygerrit-ui/app/wct_test.sh b/polygerrit-ui/app/wct_test.sh
deleted file mode 100755
index 42b98ab..0000000
--- a/polygerrit-ui/app/wct_test.sh
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/sh
-
-set -ex
-root_dir=$(pwd)
-t=$TEST_TMPDIR
-export JSON_CONFIG=$2
-
-mkdir -p $t/node_modules
-# WCT doesn't implement node module resolution.
-# WCT uses only node_module/ directory from current directory when looking for a module
-# So, it is impossible to make hierarchical node_modules. Instead, we copy
-# all node_modules to one directory.
-cp -R -L ./external/ui_dev_npm/node_modules/* $t/node_modules
-
-# Copy ui_npm, so it will override ui_dev_npm modules (in case of conflicts)
-# Because browser always requests specific exact files (i.e. not a directory),
-# it always receives file from ui_npm. It can broke WCT itself but luckely it works.
-cp -R -L ./external/ui_npm/node_modules/* $t/node_modules
-
-cp -R -L ./polygerrit-ui/app/* $t/
-
-export PATH="$(dirname $NPM):$PATH"
-
-cd $t
-echo "export const config=$JSON_CONFIG;" > ./test/suite_conf.js
-echo "export const testsPerFileString=\`" >> ./test/suite_conf.js
-# Count number of tests in each file.
-# We don't need accurate data, use simpliest method
-# TODO(dmfilippov): collect data only once
-# In the current implementation, the same data is collected for each split,
-# It takes less than a second which many times less than the overall wct test time
-grep -rnw '.' --include=\*_test.html -e "test(" -c >> ./test/suite_conf.js
-echo "\`;" >>./test/suite_conf.js
-
-# If wct doesn't receive any paramenters, it fails (can't find files)
-# Pass --config-file as a parameter to have some arguments in command line
-$root_dir/$1 --config-file wct.conf.js ${WCT_ARGS}
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index 5724ffa..45e0ea7 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -2,6 +2,13 @@
 # yarn lockfile v1
 
 
+"@polymer/decorators@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@polymer/decorators/-/decorators-3.0.0.tgz#e4212ac976d9abd1210f560b6e1be4165c1c0183"
+  integrity sha512-qh+VID9nDV9q3ABvIfWgm7/+udl7v2HKsMLPXFm8tj1fI7qr7yWJMFwS3xWBkMmuNPtmkS8MDP0vqLAQIEOWzg==
+  dependencies:
+    "@polymer/polymer" "^3.0.5"
+
 "@polymer/font-roboto-local@^3.0.2":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@polymer/font-roboto-local/-/font-roboto-local-3.0.2.tgz#563cd6cabbcaef54999d654c0f3d476bcc49ce58"
@@ -13,9 +20,9 @@
   integrity sha512-tx5TauYSmzsIvmSqepUPDYbs4/Ejz2XbZ1IkD7JEGqkdNUJlh+9KU85G56Tfdk/xjEZ8zorFfN09OSwiMrIQWA==
 
 "@polymer/iron-a11y-announcer@^3.0.0-pre.26":
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-announcer/-/iron-a11y-announcer-3.0.2.tgz#730dd36ccb2e042ecd5160ba439c2bf2f8a97412"
-  integrity sha512-LqnMF39mXyxSSRbTHRzGbcJS8nU0NVTo2raBOgOlpxw5yfGJUVcwaTJ/qy5NtWCZLRfa4suycf0oAkuUjHTXHQ==
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-announcer/-/iron-a11y-announcer-3.1.0.tgz#3d3712a165070ed3cdfc39e54f95515c913c9613"
+  integrity sha512-lc5i4NKB8kSQHH0Hwu8WS3ym93m+J69OHJWSSBxwd17FI+h2wmgxDzeG9LI4ojMMck17/uc2pLe7g/UHt5/K/A==
   dependencies:
     "@polymer/polymer" "^3.0.0"
 
@@ -26,10 +33,10 @@
   dependencies:
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/iron-autogrow-textarea@^3.0.0-pre.26", "@polymer/iron-autogrow-textarea@^3.0.1":
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/@polymer/iron-autogrow-textarea/-/iron-autogrow-textarea-3.0.1.tgz#0205d9c5ca16f3afd505f41e9037989707d59dce"
-  integrity sha512-FgSL7APrOSL9Vu812sBCFlQ17hvnJsBAV2C2e1UAiaHhB+dyfLq8gGdGUpqVWuGJ50q4Y/49QwCNnLf85AdVYA==
+"@polymer/iron-autogrow-textarea@^3.0.0-pre.26", "@polymer/iron-autogrow-textarea@^3.0.3":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-autogrow-textarea/-/iron-autogrow-textarea-3.0.3.tgz#b75dbebc23ce47d428a26156709d4a8a4c05823e"
+  integrity sha512-5r0VkWrIlm0JIp5E5wlnvkw7slK72lFRZXncmrsLZF+6n1dg2rI8jt7xpFzSmUWrqpcyXwyKaGaDvUjl3j4JLA==
   dependencies:
     "@polymer/iron-behaviors" "^3.0.0-pre.26"
     "@polymer/iron-flex-layout" "^3.0.0-pre.26"
@@ -63,7 +70,7 @@
     "@polymer/neon-animation" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/iron-fit-behavior@^3.0.0-pre.26", "@polymer/iron-fit-behavior@^3.0.1":
+"@polymer/iron-fit-behavior@^3.0.0-pre.26", "@polymer/iron-fit-behavior@^3.0.2":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@polymer/iron-fit-behavior/-/iron-fit-behavior-3.0.2.tgz#2ec460d8a6b0151394b55631a72a68b92e14e2e0"
   integrity sha512-JndryJYbBR3gSN5IlST4rCHsd01+OyvYpRO6z5Zd3C6u5V/m07TwAtcf3aXwZ8WBNt2eLG28OcvdSO7XR2v2pg==
@@ -127,7 +134,7 @@
   dependencies:
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/iron-overlay-behavior@^3.0.0-pre.27", "@polymer/iron-overlay-behavior@^3.0.2":
+"@polymer/iron-overlay-behavior@^3.0.0-pre.27", "@polymer/iron-overlay-behavior@^3.0.3":
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/@polymer/iron-overlay-behavior/-/iron-overlay-behavior-3.0.3.tgz#29c198e19e05bb2bcf7d86d3c11848cb93301d00"
   integrity sha512-Q/Fp0+uOQQ145ebZ7T8Cxl4m1tUKYjyymkjcL2rXUm+aDQGb1wA1M1LYxUF5YBqd+9lipE0PTIiYwA2ZL/sznA==
@@ -227,10 +234,10 @@
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/paper-input@^3.0.2":
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/@polymer/paper-input/-/paper-input-3.2.0.tgz#a07dbc1b009bac97a5a86eccb57d99b17bd96285"
-  integrity sha512-vYEBxq6LDR+QGDrAO/il0JNhCd+31TwSnv58MVV+ijaGKz1qAuSJw4NSsgF3lrXCwomqnpME19vbp2ktrcluVA==
+"@polymer/paper-input@^3.2.1":
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-input/-/paper-input-3.2.1.tgz#0fd0d30de3b43ba7d2c8d5d76f870d257b667ebf"
+  integrity sha512-6ghgwQKM6mS0hAQxQqj+tkeEY1VUBqAsrasAm8V5RpNcfSWQC/hhRFxU0beGuKTAhndzezDzWYP6Zz4b8fExGg==
   dependencies:
     "@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26"
     "@polymer/iron-autogrow-textarea" "^3.0.0-pre.26"
@@ -303,7 +310,14 @@
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.2", "@polymer/polymer@^3.3.0":
+"@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.5", "@polymer/polymer@^3.4.1":
+  version "3.4.1"
+  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.4.1.tgz#333bef25711f8411bb5624fb3eba8212ef8bee96"
+  integrity sha512-KPWnhDZibtqKrUz7enIPOiO4ZQoJNOuLwqrhV2MXzIt3VVnUVJVG5ORz4Z2sgO+UZ+/UZnPD0jqY+jmw/+a9mQ==
+  dependencies:
+    "@webcomponents/shadycss" "^1.9.1"
+
+"@polymer/polymer@^3.0.2":
   version "3.3.1"
   resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.3.1.tgz#9ad48992d2a96775f80b0673f3a615d6df8a3dfc"
   integrity sha512-8KaB48tzyMjdsHdxo5KvCAaqmTe7rYDzQAoj/pyEfq9Fp4YfUaS+/xqwYj0GbiDAUNzwkmEQ7dw9cgnRNdKO8A==
@@ -328,21 +342,11 @@
 "ba-linkify@file:../../lib/ba-linkify/src":
   version "1.0.0"
 
-es6-promise@^3.3.1:
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613"
-  integrity sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=
-
 isarray@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
   integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
 
-moment@^2.24.0:
-  version "2.24.0"
-  resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
-  integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
-
 page@^1.11.5:
   version "1.11.5"
   resolved "https://registry.yarnpkg.com/page/-/page-1.11.5.tgz#0cfc8608be337f26f4377f31df0787aef0ca1af7"
@@ -367,8 +371,3 @@
   dependencies:
     "@polymer/polymer" "^3.0.2"
     "@webcomponents/webcomponentsjs" "^2.0.3"
-
-whatwg-fetch@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb"
-  integrity sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==
diff --git a/polygerrit-ui/karma.conf.js b/polygerrit-ui/karma.conf.js
new file mode 100644
index 0000000..8d302ef
--- /dev/null
+++ b/polygerrit-ui/karma.conf.js
@@ -0,0 +1,155 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const runUnderBazel = !!process.env["RUNFILES_DIR"];
+const path = require('path');
+
+function getModulesDir() {
+  if(runUnderBazel) {
+    // Run under bazel
+    return [
+      `external/ui_npm/node_modules`,
+      `external/ui_dev_npm/node_modules`
+    ];
+  }
+
+  // Run from intellij or npm run test:kdebug
+  return [
+    path.join(__dirname, 'app/node_modules'),
+    path.join(__dirname, 'node_modules'),
+  ];
+}
+
+function getUiDevNpmFilePath(importPath) {
+  if(runUnderBazel) {
+    return `external/ui_dev_npm/node_modules/${importPath}`;
+  }
+  else {
+    return `polygerrit-ui/node_modules/${importPath}`
+  }
+}
+
+module.exports = function(config) {
+  const localDirName = path.resolve(__dirname, '../.ts-out/polygerrit-ui/app');
+  const rootDir = runUnderBazel ?
+      'polygerrit-ui/app/_pg_with_tests_out/' : localDirName + '/';
+  const testFilesLocationPattern =
+      `${rootDir}**/!(template_test_srcs)/`;
+  // Use --test-files to specify pattern for a test files.
+  // It can be just a file name, without a path:
+  // --test-files async-foreach-behavior_test.js
+  // If you specify --test-files without pattern, it gets true value
+  // In this case we ill run all tests (usefull for package.json "debugtest"
+  // script)
+  const testFilesPattern = (typeof config.testFiles == 'string') ?
+      testFilesLocationPattern + config.testFiles :
+      testFilesLocationPattern + '*_test.js';
+  config.set({
+    // base path that will be used to resolve all patterns (eg. files, exclude)
+    basePath: '../',
+    plugins: [
+      // Do not use karma-* to load all installed plugin
+      // This can lead to unexpected behavior under bazel
+      // if you forget to add a plugin in a bazel rule.
+      require.resolve('@open-wc/karma-esm'),
+      'karma-mocha',
+      'karma-chrome-launcher',
+      'karma-mocha-reporter',
+    ],
+    // frameworks to use
+    // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
+    frameworks: ['mocha', 'esm'],
+
+    // list of files / patterns to load in the browser
+    files: [
+      getUiDevNpmFilePath('accessibility-developer-tools/dist/js/axs_testing.js'),
+      getUiDevNpmFilePath('sinon/pkg/sinon.js'),
+      { pattern: testFilesPattern, type: 'module' },
+    ],
+    esm: {
+      nodeResolve: true,
+      moduleDirs: getModulesDir(),
+      // Bazel and yarn uses symlinks for files.
+      // preserveSymlinks is necessary for correct modules paths resolving
+      preserveSymlinks: true,
+      // By default, esm-dev-server uses 'auto' compatibility mode.
+      // In the 'auto' mode it incorrectly applies polyfills and
+      // breaks tests in some browser versions
+      // (for example, Chrome 69 on gerrit-ci).
+      compatibility: 'none',
+    },
+    // test results reporter to use
+    // possible values: 'dots', 'progress'
+    // available reporters: https://npmjs.org/browse/keyword/karma-reporter
+    reporters: ['mocha'],
+
+
+    // web server port
+    port: 9876,
+
+
+    // enable / disable colors in the output (reporters and logs)
+    colors: true,
+
+
+    // level of logging
+    // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+    logLevel: config.LOG_INFO,
+
+
+    // enable / disable watching file and executing tests whenever any file changes
+    autoWatch: false,
+
+
+    // start these browsers
+    // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
+    browsers: ["CustomChromeHeadless"],
+    browserForDebugging: "CustomChromeHeadlessWithDebugPort",
+
+
+    // Continuous Integration mode
+    // if true, Karma captures browsers, runs the tests and exits
+    singleRun: true,
+
+    // Concurrency level
+    // how many browser should be started simultaneous
+    concurrency: Infinity,
+
+    client: {
+      mocha: {
+        ui: 'tdd',
+        timeout: 5000,
+      }
+    },
+
+    customLaunchers: {
+      // Based on https://developers.google.com/web/updates/2017/06/headless-karma-mocha-chai
+      "CustomChromeHeadless": {
+        base: 'ChromeHeadless',
+        flags: ['--disable-translate', '--disable-extensions'],
+      },
+      "ChromeDev": {
+        base: 'Chrome',
+        flags: ['--disable-extensions', ' --auto-open-devtools-for-tabs'],
+      },
+      "CustomChromeHeadlessWithDebugPort": {
+        base: 'CustomChromeHeadless',
+        flags: ['--remote-debugging-port=9222'],
+      }
+    }
+  });
+};
diff --git a/polygerrit-ui/karma_test.sh b/polygerrit-ui/karma_test.sh
new file mode 100755
index 0000000..5fab442
--- /dev/null
+++ b/polygerrit-ui/karma_test.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+
+set -euo pipefail
+./$1 start $2 --single-run
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 3d35e3e..7de55aa 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -4,11 +4,18 @@
   "browser": true,
   "dependencies": {},
   "devDependencies": {
+    "@open-wc/karma-esm": "^2.16.16",
     "@polymer/iron-test-helpers": "^3.0.1",
+    "@polymer/test-fixture": "^4.0.2",
+    "accessibility-developer-tools": "^2.12.0",
     "chai": "^4.2.0",
-    "mocha": "^6.2.2",
-    "wct-browser-legacy": "^1.0.2",
-    "web-component-tester": "^6.9.2"
+    "karma": "^4.4.1",
+    "karma-chrome-launcher": "^3.1.0",
+    "karma-mocha": "^2.0.1",
+    "karma-mocha-reporter": "^2.2.5",
+    "lodash": "^4.17.15",
+    "mocha": "7.2.0",
+    "sinon": "^9.0.2"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 120aff5..d01045a 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -29,9 +29,12 @@
 	"net/http"
 	"net/url"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"regexp"
 	"strings"
+	"sync"
+	"time"
 
 	"golang.org/x/tools/godoc/vfs/httpfs"
 	"golang.org/x/tools/godoc/vfs/zipfs"
@@ -59,12 +62,30 @@
 		log.Fatal(err)
 	}
 
+	compiledSrcPath := filepath.Join(workspace, "./.ts-out/server-go")
+
+	tsInstance := newTypescriptInstance(
+		filepath.Join(workspace, "./node_modules/.bin/tsc"),
+		filepath.Join(workspace, "./polygerrit-ui/app/tsconfig.json"),
+		compiledSrcPath,
+	)
+
+	if err := tsInstance.StartWatch(); err != nil {
+		log.Fatal(err)
+	}
+
 	dirListingMux := http.NewServeMux()
 	dirListingMux.Handle("/styles/", http.StripPrefix("/styles/", http.FileServer(http.Dir("app/styles"))))
+	dirListingMux.Handle("/samples/", http.StripPrefix("/samples/", http.FileServer(http.Dir("app/samples"))))
 	dirListingMux.Handle("/elements/", http.StripPrefix("/elements/", http.FileServer(http.Dir("app/elements"))))
 	dirListingMux.Handle("/behaviors/", http.StripPrefix("/behaviors/", http.FileServer(http.Dir("app/behaviors"))))
 
-	http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { handleSrcRequest(dirListingMux, w, req) })
+	http.HandleFunc("/",
+		func(w http.ResponseWriter, req *http.Request) {
+			// If typescript compiler hasn't finished yet, wait for it
+			tsInstance.WaitForCompilationComplete()
+			handleSrcRequest(compiledSrcPath, dirListingMux, w, req)
+		})
 
 	http.Handle("/fonts/",
 		addDevHeadersMiddleware(http.FileServer(httpfs.New(zipfs.New(fontsArchive, "fonts")))))
@@ -104,7 +125,7 @@
 
 }
 
-func handleSrcRequest(dirListingMux *http.ServeMux, writer http.ResponseWriter, originalRequest *http.Request) {
+func handleSrcRequest(compiledSrcPath string, dirListingMux *http.ServeMux, writer http.ResponseWriter, originalRequest *http.Request) {
 	parsedUrl, err := url.Parse(originalRequest.RequestURI)
 	if err != nil {
 		writer.WriteHeader(500)
@@ -122,18 +143,54 @@
 	}
 
 	isJsFile := strings.HasSuffix(normalizedContentPath, ".js") || strings.HasSuffix(normalizedContentPath, ".mjs")
-	data, err := getContent(normalizedContentPath)
+	isTsFile := strings.HasSuffix(normalizedContentPath, ".ts")
+
+	// Source map in a compiled js file point to a file inside /app/... directory
+	// Browser tries to load original file from the directory when debugger is
+	// activated. In this case we return original content without any processing
+	isOriginalFileRequest := strings.HasPrefix(normalizedContentPath, "/polygerrit-ui/app/") && (isTsFile || isJsFile)
+
+	data, err := getContent(compiledSrcPath, normalizedContentPath, isOriginalFileRequest)
 	if err != nil {
-		data, err = getContent(normalizedContentPath + ".js")
+		if !isOriginalFileRequest {
+			data, err = getContent(compiledSrcPath, normalizedContentPath+".js", false)
+		}
 		if err != nil {
 			writer.WriteHeader(404)
 			return
 		}
 		isJsFile = true
 	}
-	if isJsFile {
-		moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'([^/.].*)';$")
+	if isOriginalFileRequest {
+		// Explicitly set text/html Content-Type. If live code tries
+		// to import javascript from the /app/ folder accidentally, browser fails
+		// with the import error, so we can catch this problem easily.
+		writer.Header().Set("Content-Type", "text/html")
+	} else if isJsFile {
+		// import ... from '@polymer/decorators'
+		// must be transformed into
+		// import ... from '@polymer/decorators/lib/decorators.js'
+		// The correct way to do it is to use value of the "main" property
+		// from the @polymer/decorators/package.json. However, parsing package.json
+		// is overcomplicated right now, hard-code exact path here.
+		moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'@polymer/decorators';$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '@polymer/decorators/lib/decorators.js';"))
+
+		// The following code updates import statements.
+		// 1. if an in imported file has .js or .mjs extension, the code keeps
+		//	  the file extension unchanged. Otherwise, it adds .js extension
+		// 2. For module imports it adds '/node_modules/' prefix.
+		//   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(`(?m)^(import.*)'(.*?)(\.(m?)js)?';$`)
+		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '$2.${4}js';"))
+
+		moduleImportRegexp = regexp.MustCompile("(?m)^(import.*)'([^/.].*)';$")
 		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '/node_modules/$2';"))
+
 		writer.Header().Set("Content-Type", "application/javascript")
 	} else if strings.HasSuffix(normalizedContentPath, ".css") {
 		writer.Header().Set("Content-Type", "text/css")
@@ -149,9 +206,17 @@
 	writer.Write(data)
 }
 
-func getContent(normalizedContentPath string) ([]byte, error) {
+func getContent(compiledSrcPath string, normalizedContentPath string, isOriginalFileRequest bool) ([]byte, error) {
 	// normalizedContentPath must always starts with '/'
 
+	if isOriginalFileRequest {
+		data, err := ioutil.ReadFile(normalizedContentPath[len("/polygerrit-ui/"):])
+		if err != nil {
+			return nil, errors.New("File not found")
+		}
+		return data, nil
+	}
+
 	// gerrit loads gr-app.js as an ordinary script, without type="module" attribute.
 	// If server.go serves this file as is, browser shows the error:
 	// Uncaught SyntaxError: Cannot use import statement outside a module
@@ -172,7 +237,7 @@
 		normalizedContentPath = "/elements/gr-app.js"
 	}
 
-	pathsToTry := []string{"app" + normalizedContentPath}
+	pathsToTry := []string{compiledSrcPath + normalizedContentPath, "app" + normalizedContentPath}
 	bowerComponentsSuffix := "/bower_components/"
 	nodeModulesPrefix := "/node_modules/"
 	testComponentsPrefix := "/components/"
@@ -431,3 +496,97 @@
 	defer gzw.Close()
 	http.DefaultServeMux.ServeHTTP(gzw, r)
 }
+
+// Typescript compiler support
+// The code below runs typescript compiler in watch mode and redirect
+// all output from the compiler to the standard logger with the prefix "TSC -"
+// Additionally, the code analyzes messages produced by the typescript compiler
+// and allows to wait until compilation is finished.
+var (
+	tsStartingCompilation   = "- Starting compilation in watch mode..."
+	tsFileChangeDetectedMsg = "- File change detected. Starting incremental compilation..."
+	// If there is only one error typescript outputs:
+	// Found 1 error
+	// In all other cases it outputs
+	// Found X errors
+	tsStartWatchingMsg        = regexp.MustCompile(`^.* - Found \d+ error(s)?\. Watching for file changes\.$`)
+	waitForNextChangeInterval = 1 * time.Second
+)
+
+type typescriptLogWriter struct {
+	logger *log.Logger
+	// when WaitGroup counter is 0 the compilation is complete
+	compilationDoneWaiter *sync.WaitGroup
+}
+
+func newTypescriptLogWriter(compilationCompleteWaiter *sync.WaitGroup) *typescriptLogWriter {
+	return &typescriptLogWriter{
+		logger:                log.New(log.Writer(), "TSC - ", log.Flags()),
+		compilationDoneWaiter: compilationCompleteWaiter,
+	}
+}
+
+func (lw typescriptLogWriter) Write(p []byte) (n int, err error) {
+	text := strings.TrimSpace(string(p))
+	if strings.HasSuffix(text, tsFileChangeDetectedMsg) ||
+		strings.HasSuffix(text, tsStartingCompilation) {
+		lw.compilationDoneWaiter.Add(1)
+	}
+	if tsStartWatchingMsg.MatchString(text) {
+		// A source code can be changed while previous compiler run is in progress.
+		// In this case typescript reruns compilation again almost immediately
+		// after the previous run finishes. To detect this situation, we are
+		// waiting waitForNextChangeInterval before decreasing the counter.
+		// If another compiler run is started in this interval, we will wait
+		// again until it finishes.
+		go func() {
+			time.Sleep(waitForNextChangeInterval)
+			lw.compilationDoneWaiter.Add(-1)
+		}()
+
+	}
+	lw.logger.Print(text)
+	return len(p), nil
+}
+
+type typescriptInstance struct {
+	cmd                       *exec.Cmd
+	compilationCompleteWaiter *sync.WaitGroup
+}
+
+func newTypescriptInstance(tscBinaryPath string, projectPath string, outdir string) *typescriptInstance {
+	cmd := exec.Command(tscBinaryPath,
+		"--watch",
+		"--preserveWatchOutput",
+		"--project",
+		projectPath,
+		"--outDir",
+		outdir)
+
+	compilationCompleteWaiter := &sync.WaitGroup{}
+	logWriter := newTypescriptLogWriter(compilationCompleteWaiter)
+	cmd.Stdout = logWriter
+	cmd.Stderr = logWriter
+
+	return &typescriptInstance{
+		cmd:                       cmd,
+		compilationCompleteWaiter: compilationCompleteWaiter,
+	}
+}
+
+func (ts *typescriptInstance) StartWatch() error {
+	err := ts.cmd.Start()
+	if err != nil {
+		return err
+	}
+	go func() {
+		ts.cmd.Wait()
+		log.Fatal("Typescript exits unexpected")
+	}()
+
+	return nil
+}
+
+func (ts *typescriptInstance) WaitForCompilationComplete() {
+	ts.compilationCompleteWaiter.Wait()
+}
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index 12d39aa..dfc5a43 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -2,500 +2,854 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.8.3.tgz#33e25903d7481181534e12ec0a25f16b6fcf419e"
-  integrity sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==
+"@babel/code-frame@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a"
+  integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==
   dependencies:
-    "@babel/highlight" "^7.8.3"
+    "@babel/highlight" "^7.10.4"
 
-"@babel/core@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.8.3.tgz#30b0ebb4dd1585de6923a0b4d179e0b9f5d82941"
-  integrity sha512-4XFkf8AwyrEG7Ziu3L2L0Cv+WyY47Tcsp70JFmpftbAA1K7YL/sgE9jh9HyNj08Y/U50ItUchpN0w6HxAoX1rA==
+"@babel/compat-data@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.10.4.tgz#706a6484ee6f910b719b696a9194f8da7d7ac241"
+  integrity sha512-t+rjExOrSVvjQQXNp5zAIYDp00KjdvGl/TpDX5REPr0S9IAIPQMTilcfG6q8c0QFmj9lSTVySV2VTsyggvtNIw==
   dependencies:
-    "@babel/code-frame" "^7.8.3"
-    "@babel/generator" "^7.8.3"
-    "@babel/helpers" "^7.8.3"
-    "@babel/parser" "^7.8.3"
-    "@babel/template" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    browserslist "^4.12.0"
+    invariant "^2.2.4"
+    semver "^5.5.0"
+
+"@babel/core@^7.9.0":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.10.4.tgz#780e8b83e496152f8dd7df63892b2e052bf1d51d"
+  integrity sha512-3A0tS0HWpy4XujGc7QtOIHTeNwUgWaZc/WuS5YQrfhU67jnVmsD6OGPc1AKHH0LJHQICGncy3+YUjIhVlfDdcA==
+  dependencies:
+    "@babel/code-frame" "^7.10.4"
+    "@babel/generator" "^7.10.4"
+    "@babel/helper-module-transforms" "^7.10.4"
+    "@babel/helpers" "^7.10.4"
+    "@babel/parser" "^7.10.4"
+    "@babel/template" "^7.10.4"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
     gensync "^1.0.0-beta.1"
-    json5 "^2.1.0"
+    json5 "^2.1.2"
     lodash "^4.17.13"
     resolve "^1.3.2"
     semver "^5.4.1"
     source-map "^0.5.0"
 
-"@babel/generator@^7.0.0-beta.42", "@babel/generator@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.8.3.tgz#0e22c005b0a94c1c74eafe19ef78ce53a4d45c03"
-  integrity sha512-WjoPk8hRpDRqqzRpvaR8/gDUPkrnOOeuT2m8cNICJtZH6mwaCo3v0OKMI7Y6SM1pBtyijnLtAL0HDi41pf41ug==
+"@babel/generator@^7.10.4", "@babel/generator@^7.4.0":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.10.4.tgz#e49eeed9fe114b62fa5b181856a43a5e32f5f243"
+  integrity sha512-toLIHUIAgcQygFZRAQcsLQV3CBuX6yOIru1kJk/qqqvcRmZrYe6WavZTSG+bB8MxhnL9YPf+pKQfuiP161q7ng==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
     jsesc "^2.5.1"
     lodash "^4.17.13"
     source-map "^0.5.0"
 
-"@babel/helper-annotate-as-pure@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.8.3.tgz#60bc0bc657f63a0924ff9a4b4a0b24a13cf4deee"
-  integrity sha512-6o+mJrZBxOoEX77Ezv9zwW7WV8DdluouRKNY/IR5u/YTMuKHgugHOzYWlYvYLpLA9nPsQCAAASpCIbjI9Mv+Uw==
+"@babel/helper-annotate-as-pure@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3"
+  integrity sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@babel/helper-builder-binary-assignment-operator-visitor@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.8.3.tgz#c84097a427a061ac56a1c30ebf54b7b22d241503"
-  integrity sha512-5eFOm2SyFPK4Rh3XMMRDjN7lBH0orh3ss0g3rTYZnBQ+r6YPj7lgDyCvPphynHvUrobJmeMignBr6Acw9mAPlw==
+"@babel/helper-builder-binary-assignment-operator-visitor@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.10.4.tgz#bb0b75f31bf98cbf9ff143c1ae578b87274ae1a3"
+  integrity sha512-L0zGlFrGWZK4PbT8AszSfLTM5sDU1+Az/En9VrdT8/LmEiJt4zXt+Jve9DCAnQcbqDhCI+29y/L93mrDzddCcg==
   dependencies:
-    "@babel/helper-explode-assignable-expression" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-explode-assignable-expression" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@babel/helper-call-delegate@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-call-delegate/-/helper-call-delegate-7.8.3.tgz#de82619898aa605d409c42be6ffb8d7204579692"
-  integrity sha512-6Q05px0Eb+N4/GTyKPPvnkig7Lylw+QzihMpws9iiZQv7ZImf84ZsZpQH7QoWN4n4tm81SnSzPgHw2qtO0Zf3A==
+"@babel/helper-compilation-targets@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.10.4.tgz#804ae8e3f04376607cc791b9d47d540276332bd2"
+  integrity sha512-a3rYhlsGV0UHNDvrtOXBg8/OpfV0OKTkxKPzIplS1zpx7CygDcWWxckxZeDd3gzPzC4kUT0A4nVFDK0wGMh4MQ==
   dependencies:
-    "@babel/helper-hoist-variables" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/compat-data" "^7.10.4"
+    browserslist "^4.12.0"
+    invariant "^2.2.4"
+    levenary "^1.1.1"
+    semver "^5.5.0"
 
-"@babel/helper-create-regexp-features-plugin@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.8.3.tgz#c774268c95ec07ee92476a3862b75cc2839beb79"
-  integrity sha512-Gcsm1OHCUr9o9TcJln57xhWHtdXbA2pgQ58S0Lxlks0WMGNXuki4+GLfX0p+L2ZkINUGZvfkz8rzoqJQSthI+Q==
+"@babel/helper-create-class-features-plugin@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.4.tgz#2d4015d0136bd314103a70d84a7183e4b344a355"
+  integrity sha512-9raUiOsXPxzzLjCXeosApJItoMnX3uyT4QdM2UldffuGApNrF8e938MwNpDCK9CPoyxrEoCgT+hObJc3mZa6lQ==
   dependencies:
-    "@babel/helper-regex" "^7.8.3"
-    regexpu-core "^4.6.0"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-member-expression-to-functions" "^7.10.4"
+    "@babel/helper-optimise-call-expression" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-replace-supers" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.10.4"
 
-"@babel/helper-define-map@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.8.3.tgz#a0655cad5451c3760b726eba875f1cd8faa02c15"
-  integrity sha512-PoeBYtxoZGtct3md6xZOCWPcKuMuk3IHhgxsRRNtnNShebf4C8YonTSblsK4tvDbm+eJAw2HAPOfCr+Q/YRG/g==
+"@babel/helper-create-regexp-features-plugin@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.10.4.tgz#fdd60d88524659a0b6959c0579925e425714f3b8"
+  integrity sha512-2/hu58IEPKeoLF45DBwx3XFqsbCXmkdAay4spVr2x0jYgRxrSNp+ePwvSsy9g6YSaNDcKIQVPXk1Ov8S2edk2g==
   dependencies:
-    "@babel/helper-function-name" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-annotate-as-pure" "^7.10.4"
+    "@babel/helper-regex" "^7.10.4"
+    regexpu-core "^4.7.0"
+
+"@babel/helper-define-map@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.10.4.tgz#f037ad794264f729eda1889f4ee210b870999092"
+  integrity sha512-nIij0oKErfCnLUCWaCaHW0Bmtl2RO9cN7+u2QT8yqTywgALKlyUVOvHDElh+b5DwVC6YB1FOYFOTWcN/+41EDA==
+  dependencies:
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/types" "^7.10.4"
     lodash "^4.17.13"
 
-"@babel/helper-explode-assignable-expression@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.8.3.tgz#a728dc5b4e89e30fc2dfc7d04fa28a930653f982"
-  integrity sha512-N+8eW86/Kj147bO9G2uclsg5pwfs/fqqY5rwgIL7eTBklgXjcOJ3btzS5iM6AitJcftnY7pm2lGsrJVYLGjzIw==
+"@babel/helper-explode-assignable-expression@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.10.4.tgz#40a1cd917bff1288f699a94a75b37a1a2dbd8c7c"
+  integrity sha512-4K71RyRQNPRrR85sr5QY4X3VwG4wtVoXZB9+L3r1Gp38DhELyHCtovqydRi7c1Ovb17eRGiQ/FD5s8JdU0Uy5A==
   dependencies:
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@babel/helper-function-name@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz#eeeb665a01b1f11068e9fb86ad56a1cb1a824cca"
-  integrity sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==
+"@babel/helper-function-name@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz#d2d3b20c59ad8c47112fa7d2a94bc09d5ef82f1a"
+  integrity sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ==
   dependencies:
-    "@babel/helper-get-function-arity" "^7.8.3"
-    "@babel/template" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-get-function-arity" "^7.10.4"
+    "@babel/template" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@babel/helper-get-function-arity@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz#b894b947bd004381ce63ea1db9f08547e920abd5"
-  integrity sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==
+"@babel/helper-get-function-arity@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2"
+  integrity sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@babel/helper-hoist-variables@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.8.3.tgz#1dbe9b6b55d78c9b4183fc8cdc6e30ceb83b7134"
-  integrity sha512-ky1JLOjcDUtSc+xkt0xhYff7Z6ILTAHKmZLHPxAhOP0Nd77O+3nCsd6uSVYur6nJnCI029CrNbYlc0LoPfAPQg==
+"@babel/helper-hoist-variables@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.10.4.tgz#d49b001d1d5a68ca5e6604dda01a6297f7c9381e"
+  integrity sha512-wljroF5PgCk2juF69kanHVs6vrLwIPNp6DLD+Lrl3hoQ3PpPPikaDRNFA+0t81NOoMt2DL6WW/mdU8k4k6ZzuA==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@babel/helper-member-expression-to-functions@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz#659b710498ea6c1d9907e0c73f206eee7dadc24c"
-  integrity sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA==
+"@babel/helper-member-expression-to-functions@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.10.4.tgz#7cd04b57dfcf82fce9aeae7d4e4452fa31b8c7c4"
+  integrity sha512-m5j85pK/KZhuSdM/8cHUABQTAslV47OjfIB9Cc7P+PvlAoBzdb79BGNfw8RhT5Mq3p+xGd0ZfAKixbrUZx0C7A==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@babel/helper-module-imports@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz#7fe39589b39c016331b6b8c3f441e8f0b1419498"
-  integrity sha512-R0Bx3jippsbAEtzkpZ/6FIiuzOURPcMjHp+Z6xPe6DtApDJx+w7UYyOLanZqO8+wKR9G10s/FmHXvxaMd9s6Kg==
+"@babel/helper-module-imports@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz#4c5c54be04bd31670a7382797d75b9fa2e5b5620"
+  integrity sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@babel/helper-module-transforms@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.8.3.tgz#d305e35d02bee720fbc2c3c3623aa0c316c01590"
-  integrity sha512-C7NG6B7vfBa/pwCOshpMbOYUmrYQDfCpVL/JCRu0ek8B5p8kue1+BCXpg2vOYs7w5ACB9GTOBYQ5U6NwrMg+3Q==
+"@babel/helper-module-transforms@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.10.4.tgz#ca1f01fdb84e48c24d7506bb818c961f1da8805d"
+  integrity sha512-Er2FQX0oa3nV7eM1o0tNCTx7izmQtwAQsIiaLRWtavAAEcskb0XJ5OjJbVrYXWOTr8om921Scabn4/tzlx7j1Q==
   dependencies:
-    "@babel/helper-module-imports" "^7.8.3"
-    "@babel/helper-simple-access" "^7.8.3"
-    "@babel/helper-split-export-declaration" "^7.8.3"
-    "@babel/template" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-module-imports" "^7.10.4"
+    "@babel/helper-replace-supers" "^7.10.4"
+    "@babel/helper-simple-access" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.10.4"
+    "@babel/template" "^7.10.4"
+    "@babel/types" "^7.10.4"
     lodash "^4.17.13"
 
-"@babel/helper-optimise-call-expression@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz#7ed071813d09c75298ef4f208956006b6111ecb9"
-  integrity sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ==
+"@babel/helper-optimise-call-expression@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673"
+  integrity sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz#9ea293be19babc0f52ff8ca88b34c3611b208670"
-  integrity sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==
+"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375"
+  integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==
 
-"@babel/helper-regex@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.8.3.tgz#139772607d51b93f23effe72105b319d2a4c6965"
-  integrity sha512-BWt0QtYv/cg/NecOAZMdcn/waj/5P26DR4mVLXfFtDokSR6fyuG0Pj+e2FqtSME+MqED1khnSMulkmGl8qWiUQ==
+"@babel/helper-regex@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.10.4.tgz#59b373daaf3458e5747dece71bbaf45f9676af6d"
+  integrity sha512-inWpnHGgtg5NOF0eyHlC0/74/VkdRITY9dtTpB2PrxKKn+AkVMRiZz/Adrx+Ssg+MLDesi2zohBW6MVq6b4pOQ==
   dependencies:
     lodash "^4.17.13"
 
-"@babel/helper-remap-async-to-generator@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.8.3.tgz#273c600d8b9bf5006142c1e35887d555c12edd86"
-  integrity sha512-kgwDmw4fCg7AVgS4DukQR/roGp+jP+XluJE5hsRZwxCYGg+Rv9wSGErDWhlI90FODdYfd4xG4AQRiMDjjN0GzA==
+"@babel/helper-remap-async-to-generator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.10.4.tgz#fce8bea4e9690bbe923056ded21e54b4e8b68ed5"
+  integrity sha512-86Lsr6NNw3qTNl+TBcF1oRZMaVzJtbWTyTko+CQL/tvNvcGYEFKbLXDPxtW0HKk3McNOk4KzY55itGWCAGK5tg==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.8.3"
-    "@babel/helper-wrap-function" "^7.8.3"
-    "@babel/template" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-annotate-as-pure" "^7.10.4"
+    "@babel/helper-wrap-function" "^7.10.4"
+    "@babel/template" "^7.10.4"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@babel/helper-replace-supers@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.8.3.tgz#91192d25f6abbcd41da8a989d4492574fb1530bc"
-  integrity sha512-xOUssL6ho41U81etpLoT2RTdvdus4VfHamCuAm4AHxGr+0it5fnwoVdwUJ7GFEqCsQYzJUhcbsN9wB9apcYKFA==
+"@babel/helper-replace-supers@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz#d585cd9388ea06e6031e4cd44b6713cbead9e6cf"
+  integrity sha512-sPxZfFXocEymYTdVK1UNmFPBN+Hv5mJkLPsYWwGBxZAxaWfFu+xqp7b6qWD0yjNuNL2VKc6L5M18tOXUP7NU0A==
   dependencies:
-    "@babel/helper-member-expression-to-functions" "^7.8.3"
-    "@babel/helper-optimise-call-expression" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-member-expression-to-functions" "^7.10.4"
+    "@babel/helper-optimise-call-expression" "^7.10.4"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@babel/helper-simple-access@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.8.3.tgz#7f8109928b4dab4654076986af575231deb639ae"
-  integrity sha512-VNGUDjx5cCWg4vvCTR8qQ7YJYZ+HBjxOgXEl7ounz+4Sn7+LMD3CFrCTEU6/qXKbA2nKg21CwhhBzO0RpRbdCw==
+"@babel/helper-simple-access@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz#0f5ccda2945277a2a7a2d3a821e15395edcf3461"
+  integrity sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw==
   dependencies:
-    "@babel/template" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/template" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@babel/helper-split-export-declaration@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz#31a9f30070f91368a7182cf05f831781065fc7a9"
-  integrity sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==
+"@babel/helper-split-export-declaration@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.4.tgz#2c70576eaa3b5609b24cb99db2888cc3fc4251d1"
+  integrity sha512-pySBTeoUff56fL5CBU2hWm9TesA4r/rOkI9DyJLvvgz09MB9YtfIYe3iBriVaYNaPe+Alua0vBIOVOLs2buWhg==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.10.4"
 
-"@babel/helper-wrap-function@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz#9dbdb2bb55ef14aaa01fe8c99b629bd5352d8610"
-  integrity sha512-LACJrbUET9cQDzb6kG7EeD7+7doC3JNvUgTEQOx2qaO1fKlzE/Bf05qs9w1oXQMmXlPO65lC3Tq9S6gZpTErEQ==
-  dependencies:
-    "@babel/helper-function-name" "^7.8.3"
-    "@babel/template" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+"@babel/helper-validator-identifier@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2"
+  integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==
 
-"@babel/helpers@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.8.3.tgz#382fbb0382ce7c4ce905945ab9641d688336ce85"
-  integrity sha512-LmU3q9Pah/XyZU89QvBgGt+BCsTPoQa+73RxAQh8fb8qkDyIfeQnmgs+hvzhTCKTzqOyk7JTkS3MS1S8Mq5yrQ==
+"@babel/helper-wrap-function@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.10.4.tgz#8a6f701eab0ff39f765b5a1cfef409990e624b87"
+  integrity sha512-6py45WvEF0MhiLrdxtRjKjufwLL1/ob2qDJgg5JgNdojBAZSAKnAjkyOCNug6n+OBl4VW76XjvgSFTdaMcW0Ug==
   dependencies:
-    "@babel/template" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/template" "^7.10.4"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
 
-"@babel/highlight@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.8.3.tgz#28f173d04223eaaa59bc1d439a3836e6d1265797"
-  integrity sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==
+"@babel/helpers@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.10.4.tgz#2abeb0d721aff7c0a97376b9e1f6f65d7a475044"
+  integrity sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA==
   dependencies:
+    "@babel/template" "^7.10.4"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
+
+"@babel/highlight@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143"
+  integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.10.4"
     chalk "^2.0.0"
-    esutils "^2.0.2"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.8.3.tgz#790874091d2001c9be6ec426c2eed47bc7679081"
-  integrity sha512-/V72F4Yp/qmHaTALizEm9Gf2eQHV3QyTL3K0cNfijwnMnb1L+LDlAubb/ZnSdGAVzVSWakujHYs1I26x66sMeQ==
+"@babel/parser@^7.1.0", "@babel/parser@^7.10.4", "@babel/parser@^7.4.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.10.4.tgz#9eedf27e1998d87739fb5028a5120557c06a1a64"
+  integrity sha512-8jHII4hf+YVDsskTF6WuMB3X4Eh+PsUkC2ljq22so5rHvH+T8BzyL94VOdyFLNR8tBSVXOTbNHOKpR4TfRxVtA==
 
-"@babel/plugin-external-helpers@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-external-helpers/-/plugin-external-helpers-7.8.3.tgz#5a94164d9af393b2820a3cdc407e28ebf237de4b"
-  integrity sha512-mx0WXDDiIl5DwzMtzWGRSPugXi9BxROS05GQrhLNbEamhBiicgn994ibwkyiBH+6png7bm/yA7AUsvHyCXi4Vw==
+"@babel/plugin-proposal-async-generator-functions@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.10.4.tgz#4b65abb3d9bacc6c657aaa413e56696f9f170fc6"
+  integrity sha512-MJbxGSmejEFVOANAezdO39SObkURO5o/8b6fSH6D1pi9RZQt+ldppKPXfqgUWpSQ9asM6xaSaSJIaeWMDRP0Zg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
-
-"@babel/plugin-proposal-async-generator-functions@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.8.3.tgz#bad329c670b382589721b27540c7d288601c6e6f"
-  integrity sha512-NZ9zLv848JsV3hs8ryEh7Uaz/0KsmPLqv0+PdkDJL1cJy0K4kOCFa8zc1E3mp+RHPQcpdfb/6GovEsW4VDrOMw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/helper-remap-async-to-generator" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-remap-async-to-generator" "^7.10.4"
     "@babel/plugin-syntax-async-generators" "^7.8.0"
 
-"@babel/plugin-proposal-object-rest-spread@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.8.3.tgz#eb5ae366118ddca67bed583b53d7554cad9951bb"
-  integrity sha512-8qvuPwU/xxUCt78HocNlv0mXXo0wdh9VT1R04WU8HGOfaOob26pF+9P5/lYjN/q7DHOX1bvX60hnhOvuQUJdbA==
+"@babel/plugin-proposal-class-properties@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.10.4.tgz#a33bf632da390a59c7a8c570045d1115cd778807"
+  integrity sha512-vhwkEROxzcHGNu2mzUC0OFFNXdZ4M23ib8aRRcJSsW8BZK9pQMD7QB7csl97NBbgGZO7ZyHUyKDnxzOaP4IrCg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/plugin-syntax-object-rest-spread" "^7.8.0"
+    "@babel/helper-create-class-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-syntax-async-generators@^7.0.0", "@babel/plugin-syntax-async-generators@^7.8.0":
+"@babel/plugin-proposal-dynamic-import@^7.10.4", "@babel/plugin-proposal-dynamic-import@^7.8.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.10.4.tgz#ba57a26cb98b37741e9d5bca1b8b0ddf8291f17e"
+  integrity sha512-up6oID1LeidOOASNXgv/CFbgBqTuKJ0cJjz6An5tWD+NVBNlp3VNSBxv2ZdU7SYl3NxJC7agAQDApZusV6uFwQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-dynamic-import" "^7.8.0"
+
+"@babel/plugin-proposal-json-strings@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.10.4.tgz#593e59c63528160233bd321b1aebe0820c2341db"
+  integrity sha512-fCL7QF0Jo83uy1K0P2YXrfX11tj3lkpN7l4dMv9Y9VkowkhkQDwFHFd8IiwyK5MZjE8UpbgokkgtcReH88Abaw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-json-strings" "^7.8.0"
+
+"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4", "@babel/plugin-proposal-nullish-coalescing-operator@^7.8.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.10.4.tgz#02a7e961fc32e6d5b2db0649e01bf80ddee7e04a"
+  integrity sha512-wq5n1M3ZUlHl9sqT2ok1T2/MTt6AXE0e1Lz4WzWBr95LsAZ5qDXe4KnFuauYyEyLiohvXFMdbsOTMyLZs91Zlw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0"
+
+"@babel/plugin-proposal-numeric-separator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.10.4.tgz#ce1590ff0a65ad12970a609d78855e9a4c1aef06"
+  integrity sha512-73/G7QoRoeNkLZFxsoCCvlg4ezE4eM+57PnOqgaPOozd5myfj7p0muD1mRVJvbUWbOzD+q3No2bWbaKy+DJ8DA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-numeric-separator" "^7.10.4"
+
+"@babel/plugin-proposal-object-rest-spread@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.10.4.tgz#50129ac216b9a6a55b3853fdd923e74bf553a4c0"
+  integrity sha512-6vh4SqRuLLarjgeOf4EaROJAHjvu9Gl+/346PbDH9yWbJyfnJ/ah3jmYKYtswEyCoWZiidvVHjHshd4WgjB9BA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-object-rest-spread" "^7.8.0"
+    "@babel/plugin-transform-parameters" "^7.10.4"
+
+"@babel/plugin-proposal-optional-catch-binding@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.10.4.tgz#31c938309d24a78a49d68fdabffaa863758554dd"
+  integrity sha512-LflT6nPh+GK2MnFiKDyLiqSqVHkQnVf7hdoAvyTnnKj9xB3docGRsdPuxp6qqqW19ifK3xgc9U5/FwrSaCNX5g==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-optional-catch-binding" "^7.8.0"
+
+"@babel/plugin-proposal-optional-chaining@^7.10.4", "@babel/plugin-proposal-optional-chaining@^7.9.0":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.10.4.tgz#750f1255e930a1f82d8cdde45031f81a0d0adff7"
+  integrity sha512-ZIhQIEeavTgouyMSdZRap4VPPHqJJ3NEs2cuHs5p0erH+iz6khB0qfgU8g7UuJkG88+fBMy23ZiU+nuHvekJeQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-optional-chaining" "^7.8.0"
+
+"@babel/plugin-proposal-private-methods@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.10.4.tgz#b160d972b8fdba5c7d111a145fc8c421fc2a6909"
+  integrity sha512-wh5GJleuI8k3emgTg5KkJK6kHNsGEr0uBTDBuQUBJwckk9xs1ez79ioheEVVxMLyPscB0LfkbVHslQqIzWV6Bw==
+  dependencies:
+    "@babel/helper-create-class-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-proposal-unicode-property-regex@^7.10.4", "@babel/plugin-proposal-unicode-property-regex@^7.4.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.10.4.tgz#4483cda53041ce3413b7fe2f00022665ddfaa75d"
+  integrity sha512-H+3fOgPnEXFL9zGYtKQe4IDOPKYlZdF1kqFDQRRb8PK4B8af1vAGK04tF5iQAAsui+mHNBQSAtd2/ndEDe9wuA==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-syntax-async-generators@^7.8.0":
   version "7.8.4"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d"
   integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==
   dependencies:
     "@babel/helper-plugin-utils" "^7.8.0"
 
-"@babel/plugin-syntax-dynamic-import@^7.0.0":
+"@babel/plugin-syntax-class-properties@^7.10.4", "@babel/plugin-syntax-class-properties@^7.8.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.10.4.tgz#6644e6a0baa55a61f9e3231f6c9eeb6ee46c124c"
+  integrity sha512-GCSBF7iUle6rNugfURwNmCGG3Z/2+opxAMLs1nND4bhEG5PuxTIggDBoeYYSujAlLtsupzOHYJQgPS3pivwXIA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-syntax-dynamic-import@^7.8.0", "@babel/plugin-syntax-dynamic-import@^7.8.3":
   version "7.8.3"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3"
   integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==
   dependencies:
     "@babel/helper-plugin-utils" "^7.8.0"
 
-"@babel/plugin-syntax-import-meta@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.8.3.tgz#230afff79d3ccc215b5944b438e4e266daf3d84d"
-  integrity sha512-vYiGd4wQ9gx0Lngb7+bPCwQXGK/PR6FeTIJ+TIOlq+OfOKG/kCAOO2+IBac3oMM9qV7/fU76hfcqxUaLKZf1hQ==
+"@babel/plugin-syntax-import-meta@^7.8.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51"
+  integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.8.0":
+"@babel/plugin-syntax-json-strings@^7.8.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a"
+  integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
+
+"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0", "@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9"
+  integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
+
+"@babel/plugin-syntax-numeric-separator@^7.10.4", "@babel/plugin-syntax-numeric-separator@^7.8.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97"
+  integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-syntax-object-rest-spread@^7.8.0":
   version "7.8.3"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871"
   integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==
   dependencies:
     "@babel/helper-plugin-utils" "^7.8.0"
 
-"@babel/plugin-transform-arrow-functions@^7.0.0":
+"@babel/plugin-syntax-optional-catch-binding@^7.8.0":
   version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.8.3.tgz#82776c2ed0cd9e1a49956daeb896024c9473b8b6"
-  integrity sha512-0MRF+KC8EqH4dbuITCWwPSzsyO3HIWWlm30v8BbbpOrS1B++isGxPnnuq/IZvOX5J2D/p7DQalQm+/2PnlKGxg==
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1"
+  integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.0"
 
-"@babel/plugin-transform-async-to-generator@^7.0.0":
+"@babel/plugin-syntax-optional-chaining@^7.8.0", "@babel/plugin-syntax-optional-chaining@^7.8.3":
   version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.8.3.tgz#4308fad0d9409d71eafb9b1a6ee35f9d64b64086"
-  integrity sha512-imt9tFLD9ogt56Dd5CI/6XgpukMwd/fLGSrix2httihVe7LOGVPhyhMh1BU5kDM7iHD08i8uUtmV2sWaBFlHVQ==
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a"
+  integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==
   dependencies:
-    "@babel/helper-module-imports" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/helper-remap-async-to-generator" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.8.0"
 
-"@babel/plugin-transform-block-scoped-functions@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.8.3.tgz#437eec5b799b5852072084b3ae5ef66e8349e8a3"
-  integrity sha512-vo4F2OewqjbB1+yaJ7k2EJFHlTP3jR634Z9Cj9itpqNjuLXvhlVxgnjsHsdRgASR8xYDrx6onw4vW5H6We0Jmg==
+"@babel/plugin-syntax-top-level-await@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.10.4.tgz#4bbeb8917b54fcf768364e0a81f560e33a3ef57d"
+  integrity sha512-ni1brg4lXEmWyafKr0ccFWkJG0CeMt4WV1oyeBW6EFObF4oOHclbkj5cARxAPQyAQ2UTuplJyK4nfkXIMMFvsQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-block-scoping@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.8.3.tgz#97d35dab66857a437c166358b91d09050c868f3a"
-  integrity sha512-pGnYfm7RNRgYRi7bids5bHluENHqJhrV4bCZRwc5GamaWIIs07N4rZECcmJL6ZClwjDz1GbdMZFtPs27hTB06w==
+"@babel/plugin-transform-arrow-functions@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.10.4.tgz#e22960d77e697c74f41c501d44d73dbf8a6a64cd"
+  integrity sha512-9J/oD1jV0ZCBcgnoFWFq1vJd4msoKb/TCpGNFyyLt0zABdcvgK3aYikZ8HjzB14c26bc7E3Q1yugpwGy2aTPNA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-async-to-generator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.10.4.tgz#41a5017e49eb6f3cda9392a51eef29405b245a37"
+  integrity sha512-F6nREOan7J5UXTLsDsZG3DXmZSVofr2tGNwfdrVwkDWHfQckbQXnXSPfD7iO+c/2HGqycwyLST3DnZ16n+cBJQ==
+  dependencies:
+    "@babel/helper-module-imports" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-remap-async-to-generator" "^7.10.4"
+
+"@babel/plugin-transform-block-scoped-functions@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.10.4.tgz#1afa595744f75e43a91af73b0d998ecfe4ebc2e8"
+  integrity sha512-WzXDarQXYYfjaV1szJvN3AD7rZgZzC1JtjJZ8dMHUyiK8mxPRahynp14zzNjU3VkPqPsO38CzxiWO1c9ARZ8JA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-block-scoping@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.10.4.tgz#a670d1364bb5019a621b9ea2001482876d734787"
+  integrity sha512-J3b5CluMg3hPUii2onJDRiaVbPtKFPLEaV5dOPY5OeAbDi1iU/UbbFFTgwb7WnanaDy7bjU35kc26W3eM5Qa0A==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
     lodash "^4.17.13"
 
-"@babel/plugin-transform-classes@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.8.3.tgz#46fd7a9d2bb9ea89ce88720477979fe0d71b21b8"
-  integrity sha512-SjT0cwFJ+7Rbr1vQsvphAHwUHvSUPmMjMU/0P59G8U2HLFqSa082JO7zkbDNWs9kH/IUqpHI6xWNesGf8haF1w==
+"@babel/plugin-transform-classes@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.10.4.tgz#405136af2b3e218bc4a1926228bc917ab1a0adc7"
+  integrity sha512-2oZ9qLjt161dn1ZE0Ms66xBncQH4In8Sqw1YWgBUZuGVJJS5c0OFZXL6dP2MRHrkU/eKhWg8CzFJhRQl50rQxA==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.8.3"
-    "@babel/helper-define-map" "^7.8.3"
-    "@babel/helper-function-name" "^7.8.3"
-    "@babel/helper-optimise-call-expression" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/helper-replace-supers" "^7.8.3"
-    "@babel/helper-split-export-declaration" "^7.8.3"
+    "@babel/helper-annotate-as-pure" "^7.10.4"
+    "@babel/helper-define-map" "^7.10.4"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-optimise-call-expression" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-replace-supers" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.10.4"
     globals "^11.1.0"
 
-"@babel/plugin-transform-computed-properties@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.8.3.tgz#96d0d28b7f7ce4eb5b120bb2e0e943343c86f81b"
-  integrity sha512-O5hiIpSyOGdrQZRQ2ccwtTVkgUDBBiCuK//4RJ6UfePllUTCENOzKxfh6ulckXKc0DixTFLCfb2HVkNA7aDpzA==
+"@babel/plugin-transform-computed-properties@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.10.4.tgz#9ded83a816e82ded28d52d4b4ecbdd810cdfc0eb"
+  integrity sha512-JFwVDXcP/hM/TbyzGq3l/XWGut7p46Z3QvqFMXTfk6/09m7xZHJUN9xHfsv7vqqD4YnfI5ueYdSJtXqqBLyjBw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-destructuring@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.8.3.tgz#20ddfbd9e4676906b1056ee60af88590cc7aaa0b"
-  integrity sha512-H4X646nCkiEcHZUZaRkhE2XVsoz0J/1x3VVujnn96pSoGCtKPA99ZZA+va+gK+92Zycd6OBKCD8tDb/731bhgQ==
+"@babel/plugin-transform-destructuring@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.10.4.tgz#70ddd2b3d1bea83d01509e9bb25ddb3a74fc85e5"
+  integrity sha512-+WmfvyfsyF603iPa6825mq6Qrb7uLjTOsa3XOFzlYcYDHSS4QmpOWOL0NNBY5qMbvrcf3tq0Cw+v4lxswOBpgA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-duplicate-keys@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.8.3.tgz#8d12df309aa537f272899c565ea1768e286e21f1"
-  integrity sha512-s8dHiBUbcbSgipS4SMFuWGqCvyge5V2ZeAWzR6INTVC3Ltjig/Vw1G2Gztv0vU/hRG9X8IvKvYdoksnUfgXOEQ==
+"@babel/plugin-transform-dotall-regex@^7.10.4", "@babel/plugin-transform-dotall-regex@^7.4.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.10.4.tgz#469c2062105c1eb6a040eaf4fac4b488078395ee"
+  integrity sha512-ZEAVvUTCMlMFAbASYSVQoxIbHm2OkG2MseW6bV2JjIygOjdVv8tuxrCTzj1+Rynh7ODb8GivUy7dzEXzEhuPaA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-exponentiation-operator@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.8.3.tgz#581a6d7f56970e06bf51560cd64f5e947b70d7b7"
-  integrity sha512-zwIpuIymb3ACcInbksHaNcR12S++0MDLKkiqXHl3AzpgdKlFNhog+z/K0+TGW+b0w5pgTq4H6IwV/WhxbGYSjQ==
+"@babel/plugin-transform-duplicate-keys@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.10.4.tgz#697e50c9fee14380fe843d1f306b295617431e47"
+  integrity sha512-GL0/fJnmgMclHiBTTWXNlYjYsA7rDrtsazHG6mglaGSTh0KsrW04qml+Bbz9FL0LcJIRwBWL5ZqlNHKTkU3xAA==
   dependencies:
-    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-for-of@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.8.3.tgz#15f17bce2fc95c7d59a24b299e83e81cedc22e18"
-  integrity sha512-ZjXznLNTxhpf4Q5q3x1NsngzGA38t9naWH8Gt+0qYZEJAcvPI9waSStSh56u19Ofjr7QmD0wUsQ8hw8s/p1VnA==
+"@babel/plugin-transform-exponentiation-operator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.10.4.tgz#5ae338c57f8cf4001bdb35607ae66b92d665af2e"
+  integrity sha512-S5HgLVgkBcRdyQAHbKj+7KyuWx8C6t5oETmUuwz1pt3WTWJhsUV0WIIXuVvfXMxl/QQyHKlSCNNtaIamG8fysw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-function-name@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.8.3.tgz#279373cb27322aaad67c2683e776dfc47196ed8b"
-  integrity sha512-rO/OnDS78Eifbjn5Py9v8y0aR+aSYhDhqAwVfsTl0ERuMZyr05L1aFSCJnbv2mmsLkit/4ReeQ9N2BgLnOcPCQ==
+"@babel/plugin-transform-for-of@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.10.4.tgz#c08892e8819d3a5db29031b115af511dbbfebae9"
+  integrity sha512-ItdQfAzu9AlEqmusA/65TqJ79eRcgGmpPPFvBnGILXZH975G0LNjP1yjHvGgfuCxqrPPueXOPe+FsvxmxKiHHQ==
   dependencies:
-    "@babel/helper-function-name" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-instanceof@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-instanceof/-/plugin-transform-instanceof-7.8.3.tgz#a44d7d71590da36be7429573300618aefd784c3d"
-  integrity sha512-c/jB6Ebe2u17hxo+rce6PDgbkuHyfcJOleqgHYttnvMrCsxVwUnYsMq7GhxXekzUQsv9IImhv6YICKihpen+Ag==
+"@babel/plugin-transform-function-name@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.10.4.tgz#6a467880e0fc9638514ba369111811ddbe2644b7"
+  integrity sha512-OcDCq2y5+E0dVD5MagT5X+yTRbcvFjDI2ZVAottGH6tzqjx/LKpgkUepu3hp/u4tZBzxxpNGwLsAvGBvQ2mJzg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-literals@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.8.3.tgz#aef239823d91994ec7b68e55193525d76dbd5dc1"
-  integrity sha512-3Tqf8JJ/qB7TeldGl+TT55+uQei9JfYaregDcEAyBZ7akutriFrt6C/wLYIer6OYhleVQvH/ntEhjE/xMmy10A==
+"@babel/plugin-transform-literals@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.10.4.tgz#9f42ba0841100a135f22712d0e391c462f571f3c"
+  integrity sha512-Xd/dFSTEVuUWnyZiMu76/InZxLTYilOSr1UlHV+p115Z/Le2Fi1KXkJUYz0b42DfndostYlPub3m8ZTQlMaiqQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-modules-amd@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.8.3.tgz#65606d44616b50225e76f5578f33c568a0b876a5"
-  integrity sha512-MadJiU3rLKclzT5kBH4yxdry96odTUwuqrZM+GllFI/VhxfPz+k9MshJM+MwhfkCdxxclSbSBbUGciBngR+kEQ==
+"@babel/plugin-transform-member-expression-literals@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.10.4.tgz#b1ec44fcf195afcb8db2c62cd8e551c881baf8b7"
+  integrity sha512-0bFOvPyAoTBhtcJLr9VcwZqKmSjFml1iVxvPL0ReomGU53CX53HsM4h2SzckNdkQcHox1bpAqzxBI1Y09LlBSw==
   dependencies:
-    "@babel/helper-module-transforms" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
-    babel-plugin-dynamic-import-node "^2.3.0"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-object-super@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.8.3.tgz#ebb6a1e7a86ffa96858bd6ac0102d65944261725"
-  integrity sha512-57FXk+gItG/GejofIyLIgBKTas4+pEU47IXKDBWFTxdPd7F80H8zybyAY7UoblVfBhBGs2EKM+bJUu2+iUYPDQ==
+"@babel/plugin-transform-modules-amd@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.10.4.tgz#cb407c68b862e4c1d13a2fc738c7ec5ed75fc520"
+  integrity sha512-3Fw+H3WLUrTlzi3zMiZWp3AR4xadAEMv6XRCYnd5jAlLM61Rn+CRJaZMaNvIpcJpQ3vs1kyifYvEVPFfoSkKOA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/helper-replace-supers" "^7.8.3"
+    "@babel/helper-module-transforms" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    babel-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-parameters@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.8.3.tgz#7890576a13b17325d8b7d44cb37f21dc3bbdda59"
-  integrity sha512-/pqngtGb54JwMBZ6S/D3XYylQDFtGjWrnoCF4gXZOUpFV/ujbxnoNGNvDGu6doFWRPBveE72qTx/RRU44j5I/Q==
+"@babel/plugin-transform-modules-commonjs@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.10.4.tgz#66667c3eeda1ebf7896d41f1f16b17105a2fbca0"
+  integrity sha512-Xj7Uq5o80HDLlW64rVfDBhao6OX89HKUmb+9vWYaLXBZOma4gA6tw4Ni1O5qVDoZWUV0fxMYA0aYzOawz0l+1w==
   dependencies:
-    "@babel/helper-call-delegate" "^7.8.3"
-    "@babel/helper-get-function-arity" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-module-transforms" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-simple-access" "^7.10.4"
+    babel-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-regenerator@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.8.3.tgz#b31031e8059c07495bf23614c97f3d9698bc6ec8"
-  integrity sha512-qt/kcur/FxrQrzFR432FGZznkVAjiyFtCOANjkAKwCbt465L6ZCiUQh2oMYGU3Wo8LRFJxNDFwWn106S5wVUNA==
+"@babel/plugin-transform-modules-systemjs@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.10.4.tgz#8f576afd943ac2f789b35ded0a6312f929c633f9"
+  integrity sha512-Tb28LlfxrTiOTGtZFsvkjpyjCl9IoaRI52AEU/VIwOwvDQWtbNJsAqTXzh+5R7i74e/OZHH2c2w2fsOqAfnQYQ==
   dependencies:
-    regenerator-transform "^0.14.0"
+    "@babel/helper-hoist-variables" "^7.10.4"
+    "@babel/helper-module-transforms" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    babel-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-shorthand-properties@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.8.3.tgz#28545216e023a832d4d3a1185ed492bcfeac08c8"
-  integrity sha512-I9DI6Odg0JJwxCHzbzW08ggMdCezoWcuQRz3ptdudgwaHxTjxw5HgdFJmZIkIMlRymL6YiZcped4TTCB0JcC8w==
+"@babel/plugin-transform-modules-umd@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.10.4.tgz#9a8481fe81b824654b3a0b65da3df89f3d21839e"
+  integrity sha512-mohW5q3uAEt8T45YT7Qc5ws6mWgJAaL/8BfWD9Dodo1A3RKWli8wTS+WiQ/knF+tXlPirW/1/MqzzGfCExKECA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-module-transforms" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-spread@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.8.3.tgz#9c8ffe8170fdfb88b114ecb920b82fb6e95fe5e8"
-  integrity sha512-CkuTU9mbmAoFOI1tklFWYYbzX5qCIZVXPVy0jpXgGwkplCndQAa58s2jr66fTeQnA64bDox0HL4U56CFYoyC7g==
+"@babel/plugin-transform-named-capturing-groups-regex@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.10.4.tgz#78b4d978810b6f3bcf03f9e318f2fc0ed41aecb6"
+  integrity sha512-V6LuOnD31kTkxQPhKiVYzYC/Jgdq53irJC/xBSmqcNcqFGV+PER4l6rU5SH2Vl7bH9mLDHcc0+l9HUOe4RNGKA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
 
-"@babel/plugin-transform-sticky-regex@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.8.3.tgz#be7a1290f81dae767475452199e1f76d6175b100"
-  integrity sha512-9Spq0vGCD5Bb4Z/ZXXSK5wbbLFMG085qd2vhL1JYu1WcQ5bXqZBAYRzU1d+p79GcHs2szYv5pVQCX13QgldaWw==
+"@babel/plugin-transform-new-target@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.10.4.tgz#9097d753cb7b024cb7381a3b2e52e9513a9c6888"
+  integrity sha512-YXwWUDAH/J6dlfwqlWsztI2Puz1NtUAubXhOPLQ5gjR/qmQ5U96DY4FQO8At33JN4XPBhrjB8I4eMmLROjjLjw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/helper-regex" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-template-literals@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.8.3.tgz#7bfa4732b455ea6a43130adc0ba767ec0e402a80"
-  integrity sha512-820QBtykIQOLFT8NZOcTRJ1UNuztIELe4p9DCgvj4NK+PwluSJ49we7s9FB1HIGNIYT7wFUJ0ar2QpCDj0escQ==
+"@babel/plugin-transform-object-super@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.10.4.tgz#d7146c4d139433e7a6526f888c667e314a093894"
+  integrity sha512-5iTw0JkdRdJvr7sY0vHqTpnruUpTea32JHmq/atIWqsnNussbRzjEDyWep8UNztt1B5IusBYg8Irb0bLbiEBCQ==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-replace-supers" "^7.10.4"
 
-"@babel/plugin-transform-typeof-symbol@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.8.3.tgz#5cffb216fb25c8c64ba6bf5f76ce49d3ab079f4d"
-  integrity sha512-3TrkKd4LPqm4jHs6nPtSDI/SV9Cm5PRJkHLUgTcqRQQTMAZ44ZaAdDZJtvWFSaRcvT0a1rTmJ5ZA5tDKjleF3g==
+"@babel/plugin-transform-parameters@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.10.4.tgz#7b4d137c87ea7adc2a0f3ebf53266871daa6fced"
+  integrity sha512-RurVtZ/D5nYfEg0iVERXYKEgDFeesHrHfx8RT05Sq57ucj2eOYAP6eu5fynL4Adju4I/mP/I6SO0DqNWAXjfLQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-get-function-arity" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-unicode-regex@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.8.3.tgz#0cef36e3ba73e5c57273effb182f46b91a1ecaad"
-  integrity sha512-+ufgJjYdmWfSQ+6NS9VGUR2ns8cjJjYbrbi11mZBTaWm+Fui/ncTLFF28Ei1okavY+xkojGr1eJxNsWYeA5aZw==
+"@babel/plugin-transform-property-literals@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.10.4.tgz#f6fe54b6590352298785b83edd815d214c42e3c0"
+  integrity sha512-ofsAcKiUxQ8TY4sScgsGeR2vJIsfrzqvFb9GvJ5UdXDzl+MyYCaBj/FGzXuv7qE0aJcjWMILny1epqelnFlz8g==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/template@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.3.tgz#e02ad04fe262a657809327f578056ca15fd4d1b8"
-  integrity sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ==
+"@babel/plugin-transform-regenerator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.10.4.tgz#2015e59d839074e76838de2159db421966fd8b63"
+  integrity sha512-3thAHwtor39A7C04XucbMg17RcZ3Qppfxr22wYzZNcVIkPHfpM9J0SO8zuCV6SZa265kxBJSrfKTvDCYqBFXGw==
   dependencies:
-    "@babel/code-frame" "^7.8.3"
-    "@babel/parser" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    regenerator-transform "^0.14.2"
 
-"@babel/traverse@^7.0.0", "@babel/traverse@^7.0.0-beta.42", "@babel/traverse@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.8.3.tgz#a826215b011c9b4f73f3a893afbc05151358bf9a"
-  integrity sha512-we+a2lti+eEImHmEXp7bM9cTxGzxPmBiVJlLVD+FuuQMeeO7RaDbutbgeheDkw+Xe3mCfJHnGOWLswT74m2IPg==
+"@babel/plugin-transform-reserved-words@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.10.4.tgz#8f2682bcdcef9ed327e1b0861585d7013f8a54dd"
+  integrity sha512-hGsw1O6Rew1fkFbDImZIEqA8GoidwTAilwCyWqLBM9f+e/u/sQMQu7uX6dyokfOayRuuVfKOW4O7HvaBWM+JlQ==
   dependencies:
-    "@babel/code-frame" "^7.8.3"
-    "@babel/generator" "^7.8.3"
-    "@babel/helper-function-name" "^7.8.3"
-    "@babel/helper-split-export-declaration" "^7.8.3"
-    "@babel/parser" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-shorthand-properties@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.10.4.tgz#9fd25ec5cdd555bb7f473e5e6ee1c971eede4dd6"
+  integrity sha512-AC2K/t7o07KeTIxMoHneyX90v3zkm5cjHJEokrPEAGEy3UCp8sLKfnfOIGdZ194fyN4wfX/zZUWT9trJZ0qc+Q==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-spread@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.10.4.tgz#4e2c85ea0d6abaee1b24dcfbbae426fe8d674cff"
+  integrity sha512-1e/51G/Ni+7uH5gktbWv+eCED9pP8ZpRhZB3jOaI3mmzfvJTWHkuyYTv0Z5PYtyM+Tr2Ccr9kUdQxn60fI5WuQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-sticky-regex@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.10.4.tgz#8f3889ee8657581130a29d9cc91d7c73b7c4a28d"
+  integrity sha512-Ddy3QZfIbEV0VYcVtFDCjeE4xwVTJWTmUtorAJkn6u/92Z/nWJNV+mILyqHKrUxXYKA2EoCilgoPePymKL4DvQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-regex" "^7.10.4"
+
+"@babel/plugin-transform-template-literals@^7.10.4", "@babel/plugin-transform-template-literals@^7.8.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.10.4.tgz#e6375407b30fcb7fcfdbba3bb98ef3e9d36df7bc"
+  integrity sha512-4NErciJkAYe+xI5cqfS8pV/0ntlY5N5Ske/4ImxAVX7mk9Rxt2bwDTGv1Msc2BRJvWQcmYEC+yoMLdX22aE4VQ==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-typeof-symbol@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.10.4.tgz#9509f1a7eec31c4edbffe137c16cc33ff0bc5bfc"
+  integrity sha512-QqNgYwuuW0y0H+kUE/GWSR45t/ccRhe14Fs/4ZRouNNQsyd4o3PG4OtHiIrepbM2WKUBDAXKCAK/Lk4VhzTaGA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-unicode-escapes@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.10.4.tgz#feae523391c7651ddac115dae0a9d06857892007"
+  integrity sha512-y5XJ9waMti2J+e7ij20e+aH+fho7Wb7W8rNuu72aKRwCHFqQdhkdU2lo3uZ9tQuboEJcUFayXdARhcxLQ3+6Fg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-unicode-regex@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.10.4.tgz#e56d71f9282fac6db09c82742055576d5e6d80a8"
+  integrity sha512-wNfsc4s8N2qnIwpO/WP2ZiSyjfpTamT2C9V9FDH/Ljub9zw6P3SjkXcFmc0RQUt96k2fmIvtla2MMjgTwIAC+A==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/preset-env@^7.9.0":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.10.4.tgz#fbf57f9a803afd97f4f32e4f798bb62e4b2bef5f"
+  integrity sha512-tcmuQ6vupfMZPrLrc38d0sF2OjLT3/bZ0dry5HchNCQbrokoQi4reXqclvkkAT5b+gWc23meVWpve5P/7+w/zw==
+  dependencies:
+    "@babel/compat-data" "^7.10.4"
+    "@babel/helper-compilation-targets" "^7.10.4"
+    "@babel/helper-module-imports" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-proposal-async-generator-functions" "^7.10.4"
+    "@babel/plugin-proposal-class-properties" "^7.10.4"
+    "@babel/plugin-proposal-dynamic-import" "^7.10.4"
+    "@babel/plugin-proposal-json-strings" "^7.10.4"
+    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.4"
+    "@babel/plugin-proposal-numeric-separator" "^7.10.4"
+    "@babel/plugin-proposal-object-rest-spread" "^7.10.4"
+    "@babel/plugin-proposal-optional-catch-binding" "^7.10.4"
+    "@babel/plugin-proposal-optional-chaining" "^7.10.4"
+    "@babel/plugin-proposal-private-methods" "^7.10.4"
+    "@babel/plugin-proposal-unicode-property-regex" "^7.10.4"
+    "@babel/plugin-syntax-async-generators" "^7.8.0"
+    "@babel/plugin-syntax-class-properties" "^7.10.4"
+    "@babel/plugin-syntax-dynamic-import" "^7.8.0"
+    "@babel/plugin-syntax-json-strings" "^7.8.0"
+    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0"
+    "@babel/plugin-syntax-numeric-separator" "^7.10.4"
+    "@babel/plugin-syntax-object-rest-spread" "^7.8.0"
+    "@babel/plugin-syntax-optional-catch-binding" "^7.8.0"
+    "@babel/plugin-syntax-optional-chaining" "^7.8.0"
+    "@babel/plugin-syntax-top-level-await" "^7.10.4"
+    "@babel/plugin-transform-arrow-functions" "^7.10.4"
+    "@babel/plugin-transform-async-to-generator" "^7.10.4"
+    "@babel/plugin-transform-block-scoped-functions" "^7.10.4"
+    "@babel/plugin-transform-block-scoping" "^7.10.4"
+    "@babel/plugin-transform-classes" "^7.10.4"
+    "@babel/plugin-transform-computed-properties" "^7.10.4"
+    "@babel/plugin-transform-destructuring" "^7.10.4"
+    "@babel/plugin-transform-dotall-regex" "^7.10.4"
+    "@babel/plugin-transform-duplicate-keys" "^7.10.4"
+    "@babel/plugin-transform-exponentiation-operator" "^7.10.4"
+    "@babel/plugin-transform-for-of" "^7.10.4"
+    "@babel/plugin-transform-function-name" "^7.10.4"
+    "@babel/plugin-transform-literals" "^7.10.4"
+    "@babel/plugin-transform-member-expression-literals" "^7.10.4"
+    "@babel/plugin-transform-modules-amd" "^7.10.4"
+    "@babel/plugin-transform-modules-commonjs" "^7.10.4"
+    "@babel/plugin-transform-modules-systemjs" "^7.10.4"
+    "@babel/plugin-transform-modules-umd" "^7.10.4"
+    "@babel/plugin-transform-named-capturing-groups-regex" "^7.10.4"
+    "@babel/plugin-transform-new-target" "^7.10.4"
+    "@babel/plugin-transform-object-super" "^7.10.4"
+    "@babel/plugin-transform-parameters" "^7.10.4"
+    "@babel/plugin-transform-property-literals" "^7.10.4"
+    "@babel/plugin-transform-regenerator" "^7.10.4"
+    "@babel/plugin-transform-reserved-words" "^7.10.4"
+    "@babel/plugin-transform-shorthand-properties" "^7.10.4"
+    "@babel/plugin-transform-spread" "^7.10.4"
+    "@babel/plugin-transform-sticky-regex" "^7.10.4"
+    "@babel/plugin-transform-template-literals" "^7.10.4"
+    "@babel/plugin-transform-typeof-symbol" "^7.10.4"
+    "@babel/plugin-transform-unicode-escapes" "^7.10.4"
+    "@babel/plugin-transform-unicode-regex" "^7.10.4"
+    "@babel/preset-modules" "^0.1.3"
+    "@babel/types" "^7.10.4"
+    browserslist "^4.12.0"
+    core-js-compat "^3.6.2"
+    invariant "^2.2.2"
+    levenary "^1.1.1"
+    semver "^5.5.0"
+
+"@babel/preset-modules@^0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.3.tgz#13242b53b5ef8c883c3cf7dddd55b36ce80fbc72"
+  integrity sha512-Ra3JXOHBq2xd56xSF7lMKXdjBn3T772Y1Wet3yWnkDly9zHvJki029tAFzvAAK5cf4YV3yoxuP61crYRol6SVg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/plugin-proposal-unicode-property-regex" "^7.4.4"
+    "@babel/plugin-transform-dotall-regex" "^7.4.4"
+    "@babel/types" "^7.4.4"
+    esutils "^2.0.2"
+
+"@babel/runtime@^7.8.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.4.tgz#a6724f1a6b8d2f6ea5236dbfe58c7d7ea9c5eb99"
+  integrity sha512-UpTN5yUJr9b4EX2CnGNWIvER7Ab83ibv0pcvvHc4UOdrBI5jb8bj+32cCwPX6xu0mt2daFNjYhoi+X7beH0RSw==
+  dependencies:
+    regenerator-runtime "^0.13.4"
+
+"@babel/template@^7.10.4", "@babel/template@^7.4.0":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278"
+  integrity sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA==
+  dependencies:
+    "@babel/code-frame" "^7.10.4"
+    "@babel/parser" "^7.10.4"
+    "@babel/types" "^7.10.4"
+
+"@babel/traverse@^7.10.4", "@babel/traverse@^7.4.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.10.4.tgz#e642e5395a3b09cc95c8e74a27432b484b697818"
+  integrity sha512-aSy7p5THgSYm4YyxNGz6jZpXf+Ok40QF3aA2LyIONkDHpAcJzDUqlCKXv6peqYUs2gmic849C/t2HKw2a2K20Q==
+  dependencies:
+    "@babel/code-frame" "^7.10.4"
+    "@babel/generator" "^7.10.4"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.10.4"
+    "@babel/parser" "^7.10.4"
+    "@babel/types" "^7.10.4"
     debug "^4.1.0"
     globals "^11.1.0"
     lodash "^4.17.13"
 
-"@babel/types@^7.0.0-beta.42", "@babel/types@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.8.3.tgz#5a383dffa5416db1b73dedffd311ffd0788fb31c"
-  integrity sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg==
+"@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.10.4.tgz#369517188352e18219981efd156bfdb199fff1ee"
+  integrity sha512-UTCFOxC3FsFHb7lkRMVvgLzaRVamXuAs2Tz4wajva4WxtVY82eZeaUBtC2Zt95FU9TiznuC0Zk35tsim8jeVpg==
   dependencies:
-    esutils "^2.0.2"
+    "@babel/helper-validator-identifier" "^7.10.4"
     lodash "^4.17.13"
     to-fast-properties "^2.0.0"
 
-"@polymer/esm-amd-loader@^1.0.0":
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/@polymer/esm-amd-loader/-/esm-amd-loader-1.0.4.tgz#4e77f2f59b29b01e0ad02aa83d33716cddc5f9f9"
-  integrity sha512-h+hqYkL+tQV/y2ESD5gFXMl5z4cC+XY1jTlBeGSBaTcj3VbB5OBEScbvRXm63NcEbBneQQYbHfBAXAkF9i9wIA==
+"@koa/cors@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.1.0.tgz#618bb073438cfdbd3ebd0e648a76e33b84f3a3b2"
+  integrity sha512-7ulRC1da/rBa6kj6P4g2aJfnET3z8Uf3SWu60cjbtxTA5g8lxRdX/Bd2P92EagGwwAhANeNw8T8if99rJliR6Q==
+  dependencies:
+    vary "^1.1.2"
+
+"@open-wc/building-utils@^2.18.0":
+  version "2.18.0"
+  resolved "https://registry.yarnpkg.com/@open-wc/building-utils/-/building-utils-2.18.0.tgz#f80929dfcfb6d8a6cb5c933654c721808b4bb2d3"
+  integrity sha512-U1n8sLQlLt3IuqhU7tDsGQAGUfVMiB64xJsAmJEtekposrjqkjtRLU/WipvROl1A2GTsrMojMjNbFqzJghpd6g==
+  dependencies:
+    "@babel/core" "^7.9.0"
+    "@babel/plugin-syntax-dynamic-import" "^7.8.3"
+    "@webcomponents/shadycss" "^1.9.4"
+    "@webcomponents/webcomponentsjs" "^2.4.0"
+    arrify "^2.0.1"
+    browserslist "^4.9.1"
+    chokidar "^3.0.0"
+    clean-css "^4.2.1"
+    clone "^2.1.2"
+    core-js-bundle "^3.6.0"
+    deepmerge "^4.2.2"
+    es-module-shims "^0.4.6"
+    html-minifier "^4.0.0"
+    lru-cache "^5.1.1"
+    minimatch "^3.0.4"
+    parse5 "^5.1.1"
+    path-is-inside "^1.0.2"
+    regenerator-runtime "^0.13.3"
+    resolve "^1.11.1"
+    rimraf "^3.0.2"
+    shady-css-scoped-element "^0.0.2"
+    systemjs "^6.3.1"
+    terser "^4.6.7"
+    valid-url "^1.0.9"
+    whatwg-fetch "^3.0.0"
+    whatwg-url "^7.0.0"
+
+"@open-wc/karma-esm@^2.16.16":
+  version "2.16.16"
+  resolved "https://registry.yarnpkg.com/@open-wc/karma-esm/-/karma-esm-2.16.16.tgz#6ebff57f249e95f777b7e04782ef08ed41e22f53"
+  integrity sha512-IALT10JfwK+h7T0hGKTUliGdkWzQbyQg195D+RfUteIoTof6Z5+dBp7JUh2fQygIyNj7IIYHJ9ej816QlgHjdA==
+  dependencies:
+    "@open-wc/building-utils" "^2.18.0"
+    babel-plugin-istanbul "^5.1.4"
+    chokidar "^3.0.0"
+    deepmerge "^4.2.2"
+    es-dev-server "^1.56.0"
+    minimatch "^3.0.4"
+    node-fetch "^2.6.0"
+    polyfills-loader "^1.6.1"
+    portfinder "^1.0.21"
+    request "^2.88.0"
 
 "@polymer/iron-test-helpers@^3.0.1":
   version "3.0.1"
@@ -511,100 +865,139 @@
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
-"@polymer/sinonjs@^1.14.1":
-  version "1.17.1"
-  resolved "https://registry.yarnpkg.com/@polymer/sinonjs/-/sinonjs-1.17.1.tgz#e47d3785b7d0e8c29feb97f7e924b0fc597e2e9b"
-  integrity sha512-/U8F/cOTrbF2iVVYgINYmvKbtbexs+89Q3v8AaHADRYabTg7aOZGOb0RyWpOI+sUJt04kj63U4FwMhzW5r4wZA==
+"@polymer/test-fixture@^4.0.2":
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/@polymer/test-fixture/-/test-fixture-4.0.2.tgz#2f4777ecdcfb22ee000db35a05e0edf27c722c19"
+  integrity sha512-tLX8tFE4mkc4p84YG5239G0hbgTVv2irZYrSyO0OblUqIRbRoCPmbydm3HRFQkJeAB3rPCtyeZ2roJULsmTG3A==
 
-"@polymer/test-fixture@^0.0.3":
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/@polymer/test-fixture/-/test-fixture-0.0.3.tgz#4443752697d4d9293bbc412ea0b5e4d341f149d9"
-  integrity sha1-REN1JpfU2Sk7vEEuoLXk00HxSdk=
-
-"@polymer/test-fixture@^3.0.0-pre.1":
-  version "3.0.0-pre.21"
-  resolved "https://registry.yarnpkg.com/@polymer/test-fixture/-/test-fixture-3.0.0-pre.21.tgz#85152207cb0bf57caebc191c80bb0fdb6952614e"
-  integrity sha512-IxzUe6YzaORzUksafHAXHprV29YncOJgr0+1zNAifl0/f+cb5iAd4IWUrnsnVFHG5UGTLjvis5RgV6vvIZPDrA==
-
-"@types/babel-generator@^6.25.1":
-  version "6.25.3"
-  resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.3.tgz#8f06caa12d0595a0538560abe771966d77d29286"
-  integrity sha512-pGgnuxVddKcYIc+VJkRDop7gxLhqclNKBdlsm/5Vp8d+37pQkkDK7fef8d9YYImRzw9xcojEPc18pUYnbxmjqA==
+"@rollup/plugin-node-resolve@^7.1.1":
+  version "7.1.3"
+  resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz#80de384edfbd7bfc9101164910f86078151a3eca"
+  integrity sha512-RxtSL3XmdTAE2byxekYLnx+98kEUOrPHF/KRVjLH+DEIHy6kjIw7YINQzn+NXiH/NTrQLAwYs0GWB+csWygA9Q==
   dependencies:
-    "@types/babel-types" "*"
+    "@rollup/pluginutils" "^3.0.8"
+    "@types/resolve" "0.0.8"
+    builtin-modules "^3.1.0"
+    is-module "^1.0.0"
+    resolve "^1.14.2"
 
-"@types/babel-traverse@^6.25.2", "@types/babel-traverse@^6.25.3":
-  version "6.25.5"
-  resolved "https://registry.yarnpkg.com/@types/babel-traverse/-/babel-traverse-6.25.5.tgz#6d293cf7523e48b524faa7b86dc3c488191484e5"
-  integrity sha512-WrMbwmu+MWf8FiUMbmVOGkc7bHPzndUafn1CivMaBHthBBoo0VNIcYk1KV71UovYguhsNOwf3UF5oRmkkGOU3w==
+"@rollup/pluginutils@^3.0.0", "@rollup/pluginutils@^3.0.8":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
+  integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
   dependencies:
-    "@types/babel-types" "*"
+    "@types/estree" "0.0.39"
+    estree-walker "^1.0.1"
+    picomatch "^2.2.2"
 
-"@types/babel-types@*":
-  version "7.0.7"
-  resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.7.tgz#667eb1640e8039436028055737d2b9986ee336e3"
-  integrity sha512-dBtBbrc+qTHy1WdfHYjBwRln4+LWqASWakLHsWHR2NWHIFkv4W3O070IGoGLEBrJBvct3r0L1BUPuvURi7kYUQ==
-
-"@types/babel-types@^6.25.1":
-  version "6.25.2"
-  resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-6.25.2.tgz#5c57f45973e4f13742dbc5273dd84cffe7373a9e"
-  integrity sha512-+3bMuktcY4a70a0KZc8aPJlEOArPuAKQYHU5ErjkOqGJdx8xuEEVK6nWogqigBOJ8nKPxRpyCUDTCPmZ3bUxGA==
-
-"@types/babylon@^6.16.2":
-  version "6.16.5"
-  resolved "https://registry.yarnpkg.com/@types/babylon/-/babylon-6.16.5.tgz#1c5641db69eb8cdf378edd25b4be7754beeb48b4"
-  integrity sha512-xH2e58elpj1X4ynnKp9qSnWlsRTIs6n3tgLGNfwAGHwePw0mulHQllV34n0T25uYSu1k0hRKkWXF890B1yS47w==
+"@sinonjs/commons@^1", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.7.2":
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.0.tgz#c8d68821a854c555bba172f3b06959a0039b236d"
+  integrity sha512-wEj54PfsZ5jGSwMX68G8ZXFawcSglQSXqCftWX3ec8MDUzQdHgcKvw97awHbY0efQEL5iKUOAmmVtoYgmrSG4Q==
   dependencies:
-    "@types/babel-types" "*"
+    type-detect "4.0.8"
 
-"@types/bluebird@*":
-  version "3.5.29"
-  resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.29.tgz#7cd933c902c4fc83046517a1bef973886d00bdb6"
-  integrity sha512-kmVtnxTuUuhCET669irqQmPAez4KFnFVKvpleVRyfC3g+SHD1hIkFZcWLim9BVcwUBLO59o8VZE4yGCmTif8Yw==
+"@sinonjs/fake-timers@^6.0.0", "@sinonjs/fake-timers@^6.0.1":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz#293674fccb3262ac782c7aadfdeca86b10c75c40"
+  integrity sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==
+  dependencies:
+    "@sinonjs/commons" "^1.7.0"
+
+"@sinonjs/formatio@^5.0.1":
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-5.0.1.tgz#f13e713cb3313b1ab965901b01b0828ea6b77089"
+  integrity sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ==
+  dependencies:
+    "@sinonjs/commons" "^1"
+    "@sinonjs/samsam" "^5.0.2"
+
+"@sinonjs/samsam@^5.0.2", "@sinonjs/samsam@^5.0.3":
+  version "5.0.3"
+  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-5.0.3.tgz#86f21bdb3d52480faf0892a480c9906aa5a52938"
+  integrity sha512-QucHkc2uMJ0pFGjJUDP3F9dq5dx8QIaqISl9QgwLOh6P9yv877uONPGXh/OH/0zmM3tW1JjuJltAZV2l7zU+uQ==
+  dependencies:
+    "@sinonjs/commons" "^1.6.0"
+    lodash.get "^4.4.2"
+    type-detect "^4.0.8"
+
+"@sinonjs/text-encoding@^0.7.1":
+  version "0.7.1"
+  resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5"
+  integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==
+
+"@types/accepts@*":
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
+  integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/babel__core@^7.1.3":
+  version "7.1.9"
+  resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.9.tgz#77e59d438522a6fb898fa43dc3455c6e72f3963d"
+  integrity sha512-sY2RsIJ5rpER1u3/aQ8OFSI7qGIy8o1NEEbgb2UaJcvOtXOMpd39ko723NBpjQFg9SIX7TXtjejZVGeIMLhoOw==
+  dependencies:
+    "@babel/parser" "^7.1.0"
+    "@babel/types" "^7.0.0"
+    "@types/babel__generator" "*"
+    "@types/babel__template" "*"
+    "@types/babel__traverse" "*"
+
+"@types/babel__generator@*":
+  version "7.6.1"
+  resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.1.tgz#4901767b397e8711aeb99df8d396d7ba7b7f0e04"
+  integrity sha512-bBKm+2VPJcMRVwNhxKu8W+5/zT7pwNEqeokFOmbvVSqGzFneNxYcEBro9Ac7/N9tlsaPYnZLK8J1LWKkMsLAew==
+  dependencies:
+    "@babel/types" "^7.0.0"
+
+"@types/babel__template@*":
+  version "7.0.2"
+  resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.0.2.tgz#4ff63d6b52eddac1de7b975a5223ed32ecea9307"
+  integrity sha512-/K6zCpeW7Imzgab2bLkLEbz0+1JlFSrUMdw7KoIIu+IUdu51GWaBZpd3y1VXGVXzynvGa4DaIaxNZHiON3GXUg==
+  dependencies:
+    "@babel/parser" "^7.1.0"
+    "@babel/types" "^7.0.0"
+
+"@types/babel__traverse@*":
+  version "7.0.12"
+  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.0.12.tgz#22f49a028e69465390f87bb103ebd61bd086b8f5"
+  integrity sha512-t4CoEokHTfcyfb4hUaF9oOHu9RmmNWnm1CP0YmMqOOfClKascOmvlEM736vlqeScuGvBDsHkf8R2INd4DWreQA==
+  dependencies:
+    "@babel/types" "^7.3.0"
 
 "@types/body-parser@*":
-  version "1.17.1"
-  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.1.tgz#18fcf61768fb5c30ccc508c21d6fd2e8b3bf7897"
-  integrity sha512-RoX2EZjMiFMjZh9lmYrwgoP9RTpAjSHiJxdp4oidAQVO02T7HER3xj9UKue5534ULWeqVEkujhWcyvUce+d68w==
+  version "1.19.0"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f"
+  integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==
   dependencies:
     "@types/connect" "*"
     "@types/node" "*"
 
-"@types/chai-subset@^1.3.0":
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94"
-  integrity sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==
-  dependencies:
-    "@types/chai" "*"
+"@types/browserslist-useragent@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/browserslist-useragent/-/browserslist-useragent-3.0.0.tgz#d425c9818182ce71ce53866798cee9c7d41d6e53"
+  integrity sha512-ZBvKzg3yyWNYEkwxAzdmUzp27sFvw+1m080/+2lwrt+eltNefn1f4fnpMyrjOla31p8zLleCYqQXw+3EETfn0w==
 
-"@types/chai@*":
-  version "4.2.7"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.7.tgz#1c8c25cbf6e59ffa7d6b9652c78e547d9a41692d"
-  integrity sha512-luq8meHGYwvky0O7u0eQZdA7B4Wd9owUCqvbw2m3XCrCU8mplYOujMBbvyS547AxJkC+pGnd0Cm15eNxEUNU8g==
+"@types/browserslist@^4.8.0":
+  version "4.8.0"
+  resolved "https://registry.yarnpkg.com/@types/browserslist/-/browserslist-4.8.0.tgz#60489aefdf0fcb56c2d8eb65267ff08dad7a526d"
+  integrity sha512-4PyO9OM08APvxxo1NmQyQKlJdowPCOQIy5D/NLO3aO0vGC57wsMptvGp3b8IbYnupFZr92l1dlVief1JvS6STQ==
 
-"@types/chalk@^0.4.30":
-  version "0.4.31"
-  resolved "https://registry.yarnpkg.com/@types/chalk/-/chalk-0.4.31.tgz#a31d74241a6b1edbb973cf36d97a2896834a51f9"
-  integrity sha1-ox10JBprHtu5c8822XooloNKUfk=
+"@types/caniuse-api@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.0.tgz#af31cc52062be0ab24583be072fd49b634dcc2fe"
+  integrity sha512-wT1VfnScjAftZsvLYaefu/UuwYJdYBwD2JDL2OQd01plGmuAoir5V6HnVHgrfh7zEwcasoiyO2wQ+W58sNh2sw==
 
-"@types/clean-css@*":
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/@types/clean-css/-/clean-css-4.2.1.tgz#cb0134241ec5e6ede1b5344bc829668fd9871a8d"
-  integrity sha512-A1HQhQ0hkvqqByJMgg+Wiv9p9XdoYEzuwm11SVo1mX2/4PSdhjcrUlilJQoqLscIheC51t1D5g+EFWCXZ2VTQQ==
-  dependencies:
-    "@types/node" "*"
+"@types/command-line-args@^5.0.0":
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.0.0.tgz#484e704d20dbb8754a8f091eee45cdd22bcff28c"
+  integrity sha512-4eOPXyn5DmP64MCMF8ePDvdlvlzt2a+F8ZaVjqmh2yFCpGjc1kI3kGnCFYX9SCsGTjQcWIyVZ86IHCEyjy/MNg==
 
-"@types/clone@^0.1.30":
-  version "0.1.30"
-  resolved "https://registry.yarnpkg.com/@types/clone/-/clone-0.1.30.tgz#e7365648c1b42136a59c7d5040637b3b5c83b614"
-  integrity sha1-5zZWSMG0ITalnH1QQGN7O1yDthQ=
-
-"@types/compression@^0.0.33":
-  version "0.0.33"
-  resolved "https://registry.yarnpkg.com/@types/compression/-/compression-0.0.33.tgz#95dc733a2339aa846381d7f1377792d2553dc27d"
-  integrity sha1-ldxzOiM5qoRjgdfxN3eS0lU9wn0=
-  dependencies:
-    "@types/express" "*"
+"@types/command-line-usage@^5.0.1":
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/@types/command-line-usage/-/command-line-usage-5.0.1.tgz#99424950da567ba67b6b65caee57ff03c4e751ec"
+  integrity sha512-/xUgezxxYePeXhg5S04hUjxG9JZi+rJTs1+4NwpYPfSaS7BeDa6tVJkH6lN9Cb6rl8d24Fi2uX0s0Ngg2JT6gg==
 
 "@types/connect@*":
   version "3.4.33"
@@ -613,243 +1006,175 @@
   dependencies:
     "@types/node" "*"
 
-"@types/content-type@^1.1.0":
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/@types/content-type/-/content-type-1.1.3.tgz#3688bd77fc12f935548eef102a4e34c512b03a07"
-  integrity sha512-pv8VcFrZ3fN93L4rTNIbbUzdkzjEyVMp5mPVjsFfOYTDOZMZiZ8P1dhu+kEv3faYyKzZgLlSvnyQNFg+p/v5ug==
+"@types/content-disposition@*":
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.3.tgz#0aa116701955c2faa0717fc69cd1596095e49d96"
+  integrity sha512-P1bffQfhD3O4LW0ioENXUhZ9OIa0Zn+P7M+pWgkCKaT53wVLSq0mrKksCID/FGHpFhRSxRGhgrQmfhRuzwtKdg==
 
-"@types/cssbeautify@^0.3.1":
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/@types/cssbeautify/-/cssbeautify-0.3.1.tgz#8e0bee8f7decb952250da0caebe05e30591c17ef"
-  integrity sha1-jgvuj33suVIlDaDK6+BeMFkcF+8=
+"@types/cookies@*":
+  version "0.7.4"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.4.tgz#26dedf791701abc0e36b5b79a5722f40e455f87b"
+  integrity sha512-oTGtMzZZAVuEjTwCjIh8T8FrC8n/uwy+PG0yTvQcdZ7etoel7C7/3MSd7qrukENTgQtotG7gvBlBojuVs7X5rw==
+  dependencies:
+    "@types/connect" "*"
+    "@types/express" "*"
+    "@types/keygrip" "*"
+    "@types/node" "*"
 
-"@types/doctrine@^0.0.1":
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/@types/doctrine/-/doctrine-0.0.1.tgz#b999f2d9f7b43cabe0a1a2f39bc203bc7dcada9d"
-  integrity sha1-uZny2fe0PKvgoaLzm8IDvH3K2p0=
+"@types/debounce@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.0.tgz#9ee99259f41018c640b3929e1bb32c3dcecdb192"
+  integrity sha512-bWG5wapaWgbss9E238T0R6bfo5Fh3OkeoSt245CM7JJwVwpw6MEBCbIxLq5z8KzsE3uJhzcIuQkyiZmzV3M/Dw==
 
-"@types/escape-html@0.0.20":
-  version "0.0.20"
-  resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-0.0.20.tgz#cae698714dd61ebee5ab3f2aeb9a34ba1011735a"
-  integrity sha512-6dhZJLbA7aOwkYB2GDGdIqJ20wmHnkDzaxV9PJXe7O02I2dSFTERzRB6JrX6cWKaS+VqhhY7cQUMCbO5kloFUw==
+"@types/estree@0.0.39":
+  version "0.0.39"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
+  integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
 
-"@types/estree@*":
-  version "0.0.42"
-  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.42.tgz#8d0c1f480339efedb3e46070e22dd63e0430dd11"
-  integrity sha512-K1DPVvnBCPxzD+G51/cxVIoc2X8uUVl1zpJeE6iKcgHMj4+tbat5Xu4TjV7v2QSDbIeAfLi2hIk+u2+s0MlpUQ==
-
-"@types/events@*":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
-  integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==
-
-"@types/expect@^1.20.4":
-  version "1.20.4"
-  resolved "https://registry.yarnpkg.com/@types/expect/-/expect-1.20.4.tgz#8288e51737bf7e3ab5d7c77bfa695883745264e5"
-  integrity sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==
-
-"@types/express-serve-static-core@*":
-  version "4.17.2"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.2.tgz#f6f41fa35d42e79dbf6610eccbb2637e6008a0cf"
-  integrity sha512-El9yMpctM6tORDAiBwZVLMcxoTMcqqRO9dVyYcn7ycLWbvR8klrDn8CAOwRfZujZtWD7yS/mshTdz43jMOejbg==
+"@types/etag@*":
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/@types/etag/-/etag-1.8.0.tgz#37f0b1f3ea46da7ae319bbedb607e375b4c99f7e"
+  integrity sha512-EdSN0x+Y0/lBv7YAb8IU4Jgm6DWM+Bqtz7o5qozl96fzaqdqbdfHS5qjdpFeIv7xQ8jSLyjMMNShgYtMajEHyQ==
   dependencies:
     "@types/node" "*"
+
+"@types/express-serve-static-core@*":
+  version "4.17.8"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.8.tgz#b8f7b714138536742da222839892e203df569d1c"
+  integrity sha512-1SJZ+R3Q/7mLkOD9ewCBDYD2k0WyZQtWYqF/2VvoNN2/uhI49J9CDN4OAm+wGMA0DbArA4ef27xl4+JwMtGggw==
+  dependencies:
+    "@types/node" "*"
+    "@types/qs" "*"
     "@types/range-parser" "*"
 
-"@types/express@*", "@types/express@^4.0.30", "@types/express@^4.0.36":
-  version "4.17.2"
-  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.2.tgz#a0fb7a23d8855bac31bc01d5a58cadd9b2173e6c"
-  integrity sha512-5mHFNyavtLoJmnusB8OKJ5bshSzw+qkMIBAobLrIM48HJvunFva9mOa6aBwh64lBFyNwBbs0xiEFuj4eU/NjCA==
+"@types/express@*":
+  version "4.17.6"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.6.tgz#6bce49e49570507b86ea1b07b806f04697fac45e"
+  integrity sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w==
   dependencies:
     "@types/body-parser" "*"
     "@types/express-serve-static-core" "*"
+    "@types/qs" "*"
     "@types/serve-static" "*"
 
-"@types/freeport@^1.0.19":
-  version "1.0.21"
-  resolved "https://registry.yarnpkg.com/@types/freeport/-/freeport-1.0.21.tgz#73f6543ed67d3ca3fff97b985591598b7092066f"
-  integrity sha1-c/ZUPtZ9PKP/+XuYVZFZi3CSBm8=
+"@types/http-assert@*":
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.1.tgz#d775e93630c2469c2f980fc27e3143240335db3b"
+  integrity sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ==
 
-"@types/glob-stream@*":
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/@types/glob-stream/-/glob-stream-6.1.0.tgz#7ede8a33e59140534f8d8adfb8ac9edfb31897bc"
-  integrity sha512-RHv6ZQjcTncXo3thYZrsbAVwoy4vSKosSWhuhuQxLOTv74OJuFQxXkmUuZCr3q9uNBEVCvIzmZL/FeRNbHZGUg==
+"@types/keygrip@*":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72"
+  integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==
+
+"@types/koa-compose@*":
+  version "3.2.5"
+  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d"
+  integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==
   dependencies:
-    "@types/glob" "*"
+    "@types/koa" "*"
+
+"@types/koa-compress@^2.0.9":
+  version "2.0.9"
+  resolved "https://registry.yarnpkg.com/@types/koa-compress/-/koa-compress-2.0.9.tgz#5d19f7d928f78b451a9afd148863e2b45f51e541"
+  integrity sha512-1Sa9OsbHd2N2N7gLpdIRHe8W99EZbfIR31D7Iisx16XgwZCnWUtGXzXQejhu74Y1pE/wILqBP6VL49ch/MVpZw==
+  dependencies:
+    "@types/koa" "*"
     "@types/node" "*"
 
-"@types/glob@*":
-  version "7.1.1"
-  resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575"
-  integrity sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==
+"@types/koa-etag@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/koa-etag/-/koa-etag-3.0.0.tgz#d14d3dab45d5577b94bc72960631de96751341d3"
+  integrity sha512-gXQUtKGEnCy0sZLG+uE3wL4mvY1CBPcb6ECjpAoD8RGYy/8ACY1B084k8LTFPIdVcmy7GD6Y4n3up3jnupofcQ==
   dependencies:
-    "@types/events" "*"
-    "@types/minimatch" "*"
+    "@types/etag" "*"
+    "@types/koa" "*"
+
+"@types/koa-send@*":
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/@types/koa-send/-/koa-send-4.1.2.tgz#978f8267ad116d12ac6a18fecd8f34c5657e09ad"
+  integrity sha512-rfqKIv9bFds39Jxvsp8o3YJLnEQVPVriYA14AuO2OY65IHh/4UX4U/iMs5L0wATpcRmm1bbe0BNk23TRwx3VQQ==
+  dependencies:
+    "@types/koa" "*"
+
+"@types/koa-static@^4.0.1":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/koa-static/-/koa-static-4.0.1.tgz#b740d80a549b0a0a7a3b38918daecde88a7a50ec"
+  integrity sha512-SSpct5fEcAeRkBHa3RiwCIRfDHcD1cZRhwRF///ZfvRt8KhoqRrhK6wpDlYPk/vWHVFE9hPGqh68bhzsHkir4w==
+  dependencies:
+    "@types/koa" "*"
+    "@types/koa-send" "*"
+
+"@types/koa@*", "@types/koa@^2.0.48":
+  version "2.11.3"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.11.3.tgz#540ece376581b12beadf9a417dd1731bc31c16ce"
+  integrity sha512-ABxVkrNWa4O/Jp24EYI/hRNqEVRlhB9g09p48neQp4m3xL1TJtdWk2NyNQSMCU45ejeELMQZBYyfstyVvO2H3Q==
+  dependencies:
+    "@types/accepts" "*"
+    "@types/content-disposition" "*"
+    "@types/cookies" "*"
+    "@types/http-assert" "*"
+    "@types/keygrip" "*"
+    "@types/koa-compose" "*"
     "@types/node" "*"
 
-"@types/gulp-if@0.0.33":
-  version "0.0.33"
-  resolved "https://registry.yarnpkg.com/@types/gulp-if/-/gulp-if-0.0.33.tgz#edece22b7925d9a6db5f9c8c0d7882aa776fb678"
-  integrity sha512-J5lzff21X7r1x/4hSzn02GgIUEyjCqYIXZ9GgGBLhbsD3RiBdqwnkFWgF16/0jO5rcVZ52Zp+6MQMQdvIsWuKg==
+"@types/koa__cors@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.0.1.tgz#a8cf8535f0fe682c9421f1b9379837c585f8b66b"
+  integrity sha512-loqZNXliley8kncc4wrX9KMqLGN6YfiaO3a3VFX+yVkkXJwOrZU4lipdudNjw5mFyC+5hd7h9075hQWcVVpeOg==
   dependencies:
-    "@types/node" "*"
-    "@types/vinyl" "*"
+    "@types/koa" "*"
 
-"@types/html-minifier@^3.5.1":
-  version "3.5.3"
-  resolved "https://registry.yarnpkg.com/@types/html-minifier/-/html-minifier-3.5.3.tgz#5276845138db2cebc54c789e0aaf87621a21e84f"
-  integrity sha512-j1P/4PcWVVCPEy5lofcHnQ6BtXz9tHGiFPWzqm7TtGuWZEfCHEP446HlkSNc9fQgNJaJZ6ewPtp2aaFla/Uerg==
-  dependencies:
-    "@types/clean-css" "*"
-    "@types/relateurl" "*"
-    "@types/uglify-js" "*"
+"@types/lru-cache@^5.1.0":
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03"
+  integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w==
 
-"@types/is-windows@^0.2.0":
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/@types/is-windows/-/is-windows-0.2.0.tgz#6f24ee48731d31168ea510610d6dd15e5fc9c6ff"
-  integrity sha1-byTuSHMdMRaOpRBhDW3RXl/Jxv8=
+"@types/mime@*":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.2.tgz#857a118d8634c84bba7ae14088e4508490cd5da5"
+  integrity sha512-4kPlzbljFcsttWEq6aBW0OZe6BDajAmyvr2xknBG92tejQnvdGtT9+kXSZ580DqpxY9qG2xeQVF9Dq0ymUTo5Q==
 
-"@types/launchpad@^0.6.0":
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.0.tgz#37296109b7f277f6e6c5fd7e0c0706bc918fbb51"
-  integrity sha1-NylhCbfyd/bmxf1+DAcGvJGPu1E=
-
-"@types/mime@*", "@types/mime@^2.0.0":
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d"
-  integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==
-
-"@types/minimatch@*", "@types/minimatch@^3.0.1":
+"@types/minimatch@^3.0.3":
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
   integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
 
-"@types/mz@0.0.29":
-  version "0.0.29"
-  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.29.tgz#bc24728c649973f1c7851e9033f9ce525668c27b"
-  integrity sha1-vCRyjGSZc/HHhR6QM/nOUlZowns=
-  dependencies:
-    "@types/bluebird" "*"
-    "@types/node" "*"
-
-"@types/mz@0.0.31":
-  version "0.0.31"
-  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.31.tgz#a4d80c082fefe71e40a7c0f07d1e6555bbbc7b52"
-  integrity sha1-pNgMCC/v5x5Ap8DwfR5lVbu8e1I=
-  dependencies:
-    "@types/node" "*"
-
 "@types/node@*":
-  version "13.1.8"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-13.1.8.tgz#1d590429fe8187a02707720ecf38a6fe46ce294b"
-  integrity sha512-6XzyyNM9EKQW4HKuzbo/CkOIjn/evtCmsU+MUM1xDfJ+3/rNjBttM1NgN7AOQvN6tP1Sl1D1PIKMreTArnxM9A==
-
-"@types/node@^4.0.30":
-  version "4.9.4"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-4.9.4.tgz#75ef91733afaa856b01e12da6ecf48aa9d5e221f"
-  integrity sha512-nKoiCZ87x6+fs26bNHjy07AQt6f46nFEitGH0P9JmWbY6tEyum6LLfLf7SIsKFh4DnBWsyUM2gYhaQAt+aA0Sw==
-
-"@types/opn@^3.0.28":
-  version "3.0.28"
-  resolved "https://registry.yarnpkg.com/@types/opn/-/opn-3.0.28.tgz#097d0d1c9b5749573a5d96df132387bb6d02118a"
-  integrity sha1-CX0NHJtXSVc6XZbfEyOHu20CEYo=
-  dependencies:
-    "@types/node" "*"
-
-"@types/parse5@^2.2.34":
-  version "2.2.34"
-  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-2.2.34.tgz#e3870a10e82735a720f62d71dcd183ba78ef3a9d"
-  integrity sha1-44cKEOgnNacg9i1x3NGDunjvOp0=
-  dependencies:
-    "@types/node" "*"
+  version "14.0.14"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.14.tgz#24a0b5959f16ac141aeb0c5b3cd7a15b7c64cbce"
+  integrity sha512-syUgf67ZQpaJj01/tRTknkMNoBBLWJOBODF0Zm4NrXmiSuxjymFrxnTu1QVYRubhVkRcZLYZG8STTwJRdVm/WQ==
 
 "@types/path-is-inside@^1.0.0":
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.0.tgz#02d6ff38975d684bdec96204494baf9f29f0e17f"
   integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
 
-"@types/pem@^1.8.1":
-  version "1.9.5"
-  resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.5.tgz#cd5548b5e0acb4b41a9e21067e9fcd8c57089c99"
-  integrity sha512-C0txxEw8B7DCoD85Ko7SEvzUogNd5VDJ5/YBG8XUcacsOGqxr5Oo4g3OUAfdEDUbhXanwUoVh/ZkMFw77FGPQQ==
-  dependencies:
-    "@types/node" "*"
+"@types/qs@*":
+  version "6.9.3"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.3.tgz#b755a0934564a200d3efdf88546ec93c369abd03"
+  integrity sha512-7s9EQWupR1fTc2pSMtXRQ9w9gLOcrJn+h7HOXw4evxyvVqMi4f+q7d2tnFe3ng3SNHjtK+0EzGMGFUQX4/AQRA==
 
 "@types/range-parser@*":
   version "1.2.3"
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
   integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
 
-"@types/relateurl@*":
-  version "0.2.28"
-  resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.28.tgz#6bda7db8653fa62643f5ee69e9f69c11a392e3a6"
-  integrity sha1-a9p9uGU/piZD9e5p6facEaOS46Y=
-
-"@types/resolve@0.0.6":
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.6.tgz#0bd2f236c2e1cebb98b79885df57edd71a8d770e"
-  integrity sha512-g+Rg8uMWY76oYTyaL+m7ZcblqF/oj7pE6uEUyACluJx4zcop1Lk14qQiocdEkEVMDFm6DmKpxJhsER+ZuTwG3g==
+"@types/resolve@0.0.8":
+  version "0.0.8"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.8.tgz#f26074d238e02659e323ce1a13d041eee280e194"
+  integrity sha512-auApPaJf3NPfe18hSoJkp8EbZzer2ISk7o8mCC3M9he/a04+gbMF97NkpD2S8riMGvm4BMRI59/SZQSaLTKpsQ==
   dependencies:
     "@types/node" "*"
 
-"@types/resolve@0.0.7":
-  version "0.0.7"
-  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.7.tgz#b299c13be8d712b1b502fb14a084252acef84f4d"
-  integrity sha512-GPewdjkb0Q76o459qgp6pBLzJj/bD3oveS2kfLhIkZ9U3t3AFKtl5DlFB6lGTw0iZmcmxoGC8lpLW3NNJKrN9A==
-  dependencies:
-    "@types/node" "*"
-
-"@types/serve-static@*", "@types/serve-static@^1.7.31":
-  version "1.13.3"
-  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1"
-  integrity sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==
+"@types/serve-static@*":
+  version "1.13.4"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.4.tgz#6662a93583e5a6cabca1b23592eb91e12fa80e7c"
+  integrity sha512-jTDt0o/YbpNwZbQmE/+2e+lfjJEJJR0I3OFaKQKPWkASkCoW3i6fsUnqudSMcNAfbtmADGu8f4MV4q+GqULmug==
   dependencies:
     "@types/express-serve-static-core" "*"
     "@types/mime" "*"
 
-"@types/spdy@^3.4.1":
-  version "3.4.4"
-  resolved "https://registry.yarnpkg.com/@types/spdy/-/spdy-3.4.4.tgz#3282fd4ad8c4603aa49f7017dd520a08a345b2bc"
-  integrity sha512-N9LBlbVRRYq6HgYpPkqQc3a9HJ/iEtVZToW6xlTtJiMhmRJ7jJdV7TaZQJw/Ve/1ePUsQiCTDc4JMuzzag94GA==
-  dependencies:
-    "@types/node" "*"
-
-"@types/ua-parser-js@^0.7.31":
-  version "0.7.33"
-  resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.33.tgz#4a92089511574e12928a7cb6b99a01831acd1dd7"
-  integrity sha512-ngUKcHnytUodUCL7C6EZ+lVXUjTMQb+9p/e1JjV5tN9TVzS98lHozWEFRPY1QcCdwFeMsmVWfZ3DPPT/udCyIw==
-
-"@types/uglify-js@*":
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082"
-  integrity sha512-SudIN9TRJ+v8g5pTG8RRCqfqTMNqgWCKKd3vtynhGzkIIjxaicNAMuY5TRadJ6tzDu3Dotf3ngaMILtmOdmWEQ==
-  dependencies:
-    source-map "^0.6.1"
-
-"@types/uuid@^3.4.3":
-  version "3.4.6"
-  resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.6.tgz#d2c4c48eb85a757bf2927f75f939942d521e3016"
-  integrity sha512-cCdlC/1kGEZdEglzOieLDYBxHsvEOIg7kp/2FYyVR9Pxakq+Qf/inL3RKQ+PA8gOlI/NnL+fXmQH12nwcGzsHw==
-  dependencies:
-    "@types/node" "*"
-
-"@types/vinyl-fs@^2.4.8":
-  version "2.4.11"
-  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-2.4.11.tgz#b98119b8bb2494141eaf649b09fbfeb311161206"
-  integrity sha512-2OzQSfIr9CqqWMGqmcERE6Hnd2KY3eBVtFaulVo3sJghplUcaeMdL9ZjEiljcQQeHjheWY9RlNmumjIAvsBNaA==
-  dependencies:
-    "@types/glob-stream" "*"
-    "@types/node" "*"
-    "@types/vinyl" "*"
-
-"@types/vinyl@*", "@types/vinyl@^2.0.0":
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/@types/vinyl/-/vinyl-2.0.4.tgz#9a7a8071c8d14d3a95d41ebe7135babe4ad5995a"
-  integrity sha512-2o6a2ixaVI2EbwBPg1QYLGQoHK56p/8X/sGfKbFC8N6sY9lfjsMf/GprtkQkSya0D4uRiutRZ2BWj7k3JvLsAQ==
-  dependencies:
-    "@types/expect" "^1.20.4"
-    "@types/node" "*"
-
 "@types/whatwg-url@^6.4.0":
   version "6.4.0"
   resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-6.4.0.tgz#1e59b8c64bc0dbdf66d037cf8449d1c3d5270237"
@@ -857,27 +1182,27 @@
   dependencies:
     "@types/node" "*"
 
-"@types/which@^1.3.1":
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/@types/which/-/which-1.3.2.tgz#9c246fc0c93ded311c8512df2891fb41f6227fdf"
-  integrity sha512-8oDqyLC7eD4HM307boe2QWKyuzdzWBj56xI/imSl2cpL+U3tCMaTAkMJ4ee5JBZ/FsOJlvRGeIShiZDAl1qERA==
-
 "@webcomponents/shadycss@^1.9.1":
   version "1.9.4"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.9.4.tgz#4f9d8ea1526bab084c60b53d4854dc39fdb2bb48"
   integrity sha512-tgNcVEaKssyeZPbUBjVQf4aryO5Fi7fxRvOxV982ZJuRVDcefmIblBh0SXAbcvAAlQ2zpNEP4SuQUnr8uApIpw==
 
-"@webcomponents/webcomponentsjs@^1.0.7":
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-1.3.3.tgz#5bb82a0d3210c836bd4623e13a4a93145cb9dc27"
-  integrity sha512-eLH04VBMpuZGzBIhOnUjECcQPEPcmfhWEijW9u1B5I+2PPYdWf3vWUExdDxu4Y3GljRSTCOlWnGtS9tpzmXMyQ==
+"@webcomponents/shadycss@^1.9.4":
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.10.0.tgz#7a80ec1e8b271fb3f0cc02cd4358b877a303545d"
+  integrity sha512-UMS+dF4DXDrcUmQqK6aLd/3mFyfGktKG/hZR6FtrsQK/INO07G0H8FxElLkuvHj0iePeZGpR7R4lWFTvX7rc9g==
 
-"@webcomponents/webcomponentsjs@^2.0.0":
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.4.1.tgz#7baadec56ed2fd79b94ddfd509132d8c0c295c5c"
-  integrity sha512-7jxBb+KoWncKb/JGFyTY40PjV4yRx2zd35ZLuvRP+6WndJDL7X32ZIZ7bN3sSQIl+NzJkCo7chfXJyzn+6WZaQ==
+"@webcomponents/webcomponentsjs@^2.4.0":
+  version "2.4.3"
+  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.4.3.tgz#384f4f6d54563ba465fb4df21fe89e78a76fc530"
+  integrity sha512-cV4+sAmshf8ysU2USutrSRYQkJzEYKHsRCGa0CkMElGpG5747VHtkfsW3NdVIBV/m2MDKXTDydT4lkrysH7IFA==
 
-accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
+abortcontroller-polyfill@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.4.0.tgz#0d5eb58e522a461774af8086414f68e1dda7a6c4"
+  integrity sha512-3ZFfCRfDzx3GFjO6RAkYx81lPGpUS20ISxux9gLxuKnqafNcFQo59+IoZqpO2WvQlyc287B62HDnDdNYRmlvWA==
+
+accepts@^1.3.5, accepts@~1.3.4:
   version "1.3.7"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
   integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
@@ -890,72 +1215,26 @@
   resolved "https://registry.yarnpkg.com/accessibility-developer-tools/-/accessibility-developer-tools-2.12.0.tgz#3da0cce9d6ec6373964b84f35db7cfc3df7ab514"
   integrity sha1-PaDM6dbsY3OWS4TzXbfPw996tRQ=
 
-acorn-jsx@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b"
-  integrity sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=
-  dependencies:
-    acorn "^3.0.4"
-
-acorn@^3.0.4:
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a"
-  integrity sha1-ReN/s56No/JbruP/U2niu18iAXo=
-
-acorn@^5.5.0:
-  version "5.7.3"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279"
-  integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==
-
-acorn@^7.1.0:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c"
-  integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==
-
-adm-zip@~0.4.3:
-  version "0.4.13"
-  resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.13.tgz#597e2f8cc3672151e1307d3e95cddbc75672314a"
-  integrity sha512-fERNJX8sOXfel6qCBCMPvZLzENBEhZTzKqg6vrOW5pvoEaQuJhRU4ndTAh6lHOxn1I6jnz2NHra56ZODM751uw==
-
 after@0.8.2:
   version "0.8.2"
   resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
   integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
 
-agent-base@^4.3.0:
-  version "4.3.0"
-  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
-  integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
-  dependencies:
-    es6-promisify "^5.0.0"
-
 ajv@^6.5.5:
-  version "6.11.0"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.11.0.tgz#c3607cbc8ae392d8a5a536f25b21f8e5f3f87fe9"
-  integrity sha512-nCprB/0syFYy9fVYU1ox1l2KN8S9I+tziH8D4zdZuLT3N6RMlGSGt5FSTpAiHB/Whv8Qs1cWHma1aMKZyaHRKA==
+  version "6.12.2"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd"
+  integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==
   dependencies:
     fast-deep-equal "^3.1.1"
     fast-json-stable-stringify "^2.0.0"
     json-schema-traverse "^0.4.1"
     uri-js "^4.2.2"
 
-ansi-align@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f"
-  integrity sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=
-  dependencies:
-    string-width "^2.0.0"
-
 ansi-colors@3.2.3:
   version "3.2.3"
   resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.3.tgz#57d35b8686e851e2cc04c403f1c00203976a1813"
   integrity sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw==
 
-ansi-regex@^2.0.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
-  integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
-
 ansi-regex@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
@@ -966,11 +1245,6 @@
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
   integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
 
-ansi-styles@^2.2.1:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
-  integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=
-
 ansi-styles@^3.2.0, ansi-styles@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
@@ -978,49 +1252,18 @@
   dependencies:
     color-convert "^1.9.0"
 
-ansi-styles@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-1.0.0.tgz#cb102df1c56f5123eab8b67cd7b98027a0279178"
-  integrity sha1-yxAt8cVvUSPquLZ817mAJ6AnkXg=
-
-any-promise@^1.0.0:
+any-promise@^1.0.0, any-promise@^1.1.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
   integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
 
-append-field@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56"
-  integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=
-
-archiver-utils@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2"
-  integrity sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==
-  dependencies:
-    glob "^7.1.4"
-    graceful-fs "^4.2.0"
-    lazystream "^1.0.0"
-    lodash.defaults "^4.2.0"
-    lodash.difference "^4.5.0"
-    lodash.flatten "^4.4.0"
-    lodash.isplainobject "^4.0.6"
-    lodash.union "^4.6.0"
-    normalize-path "^3.0.0"
-    readable-stream "^2.0.0"
-
-archiver@^3.0.0:
+anymatch@~3.1.1:
   version "3.1.1"
-  resolved "https://registry.yarnpkg.com/archiver/-/archiver-3.1.1.tgz#9db7819d4daf60aec10fe86b16cb9258ced66ea0"
-  integrity sha512-5Hxxcig7gw5Jod/8Gq0OneVgLYET+oNHcxgWItq4TbhOzRLKNAFUb9edAftiMKXvXfCB0vbGrJdZDNq0dWMsxg==
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142"
+  integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==
   dependencies:
-    archiver-utils "^2.1.0"
-    async "^2.6.3"
-    buffer-crc32 "^0.2.1"
-    glob "^7.1.4"
-    readable-stream "^3.4.0"
-    tar-stream "^2.1.0"
-    zip-stream "^2.1.2"
+    normalize-path "^3.0.0"
+    picomatch "^2.0.4"
 
 argparse@^1.0.7:
   version "1.0.10"
@@ -1029,65 +1272,26 @@
   dependencies:
     sprintf-js "~1.0.2"
 
-arr-diff@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf"
-  integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=
-  dependencies:
-    arr-flatten "^1.0.1"
-
-arr-diff@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
-  integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=
-
-arr-flatten@^1.0.1, arr-flatten@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
-  integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==
-
-arr-union@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
-  integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
-
-array-back@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/array-back/-/array-back-2.0.0.tgz#6877471d51ecc9c9bfa6136fb6c7d5fe69748022"
-  integrity sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==
-  dependencies:
-    typical "^2.6.1"
-
 array-back@^3.0.1:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
   integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
 
-array-find-index@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1"
-  integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=
-
-array-flatten@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
-  integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
-
-array-unique@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
-  integrity sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=
-
-array-unique@^0.3.2:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
-  integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
+array-back@^4.0.0, array-back@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.1.tgz#9b80312935a52062e1a233a9c7abeb5481b30e90"
+  integrity sha512-Z/JnaVEXv+A9xabHzN43FiiiWEE7gPCRXMrVmRm00tWbjZRul1iHm7ECzlyNq1p4a4ATXz+G9FJ3GqGOkOV3fg==
 
 arraybuffer.slice@~0.0.7:
   version "0.0.7"
   resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
   integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
 
+arrify@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
+  integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
+
 asn1@~0.2.3:
   version "0.2.4"
   resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
@@ -1100,336 +1304,54 @@
   resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
   integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
 
-assertion-error@^1.0.1, assertion-error@^1.1.0:
+assertion-error@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
   integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
 
-assign-symbols@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
-  integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
-
 async-limiter@~1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
   integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
 
-async@^1.5.2:
-  version "1.5.2"
-  resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
-  integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=
-
-async@^2.0.0, async@^2.0.1, async@^2.1.2, async@^2.4.1, async@^2.6.1, async@^2.6.2, async@^2.6.3:
+async@^2.6.2:
   version "2.6.3"
   resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
   integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
   dependencies:
     lodash "^4.17.14"
 
-async@~0.2.9:
-  version "0.2.10"
-  resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
-  integrity sha1-trvgsGdLnXGXCMo43owjfLUmw9E=
-
 asynckit@^0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
   integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
 
-atob@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
-  integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
-
 aws-sign2@~0.7.0:
   version "0.7.0"
   resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
   integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
 
 aws4@^1.8.0:
-  version "1.9.1"
-  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e"
-  integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.10.0.tgz#a17b3a8ea811060e74d47d306122400ad4497ae2"
+  integrity sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA==
 
-babel-code-frame@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
-  integrity sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=
-  dependencies:
-    chalk "^1.1.3"
-    esutils "^2.0.2"
-    js-tokens "^3.0.2"
-
-babel-generator@^6.26.1:
-  version "6.26.1"
-  resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.1.tgz#1844408d3b8f0d35a404ea7ac180f087a601bd90"
-  integrity sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==
-  dependencies:
-    babel-messages "^6.23.0"
-    babel-runtime "^6.26.0"
-    babel-types "^6.26.0"
-    detect-indent "^4.0.0"
-    jsesc "^1.3.0"
-    lodash "^4.17.4"
-    source-map "^0.5.7"
-    trim-right "^1.0.1"
-
-babel-helper-evaluate-path@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-evaluate-path/-/babel-helper-evaluate-path-0.5.0.tgz#a62fa9c4e64ff7ea5cea9353174ef023a900a67c"
-  integrity sha512-mUh0UhS607bGh5wUMAQfOpt2JX2ThXMtppHRdRU1kL7ZLRWIXxoV2UIV1r2cAeeNeU1M5SB5/RSUgUxrK8yOkA==
-
-babel-helper-flip-expressions@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-flip-expressions/-/babel-helper-flip-expressions-0.4.3.tgz#3696736a128ac18bc25254b5f40a22ceb3c1d3fd"
-  integrity sha1-NpZzahKKwYvCUlS19AoizrPB0/0=
-
-babel-helper-is-nodes-equiv@^0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-is-nodes-equiv/-/babel-helper-is-nodes-equiv-0.0.1.tgz#34e9b300b1479ddd98ec77ea0bbe9342dfe39684"
-  integrity sha1-NOmzALFHnd2Y7HfqC76TQt/jloQ=
-
-babel-helper-is-void-0@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-is-void-0/-/babel-helper-is-void-0-0.4.3.tgz#7d9c01b4561e7b95dbda0f6eee48f5b60e67313e"
-  integrity sha1-fZwBtFYee5Xb2g9u7kj1tg5nMT4=
-
-babel-helper-mark-eval-scopes@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-mark-eval-scopes/-/babel-helper-mark-eval-scopes-0.4.3.tgz#d244a3bef9844872603ffb46e22ce8acdf551562"
-  integrity sha1-0kSjvvmESHJgP/tG4izorN9VFWI=
-
-babel-helper-remove-or-void@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-remove-or-void/-/babel-helper-remove-or-void-0.4.3.tgz#a4f03b40077a0ffe88e45d07010dee241ff5ae60"
-  integrity sha1-pPA7QAd6D/6I5F0HAQ3uJB/1rmA=
-
-babel-helper-to-multiple-sequence-expressions@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-to-multiple-sequence-expressions/-/babel-helper-to-multiple-sequence-expressions-0.5.0.tgz#a3f924e3561882d42fcf48907aa98f7979a4588d"
-  integrity sha512-m2CvfDW4+1qfDdsrtf4dwOslQC3yhbgyBFptncp4wvtdrDHqueW7slsYv4gArie056phvQFhT2nRcGS4bnm6mA==
-
-babel-messages@^6.23.0:
-  version "6.23.0"
-  resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e"
-  integrity sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=
-  dependencies:
-    babel-runtime "^6.22.0"
-
-babel-plugin-dynamic-import-node@^2.3.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz#f00f507bdaa3c3e3ff6e7e5e98d90a7acab96f7f"
-  integrity sha512-o6qFkpeQEBxcqt0XYlWzAVxNCSCZdUgcR8IRlhD/8DylxjjO4foPcvTW0GGKa/cVt3rvxZ7o5ippJ+/0nvLhlQ==
+babel-plugin-dynamic-import-node@^2.3.3:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3"
+  integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==
   dependencies:
     object.assign "^4.1.0"
 
-babel-plugin-minify-builtins@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-builtins/-/babel-plugin-minify-builtins-0.5.0.tgz#31eb82ed1a0d0efdc31312f93b6e4741ce82c36b"
-  integrity sha512-wpqbN7Ov5hsNwGdzuzvFcjgRlzbIeVv1gMIlICbPj0xkexnfoIDe7q+AZHMkQmAE/F9R5jkrB6TLfTegImlXag==
-
-babel-plugin-minify-constant-folding@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-constant-folding/-/babel-plugin-minify-constant-folding-0.5.0.tgz#f84bc8dbf6a561e5e350ff95ae216b0ad5515b6e"
-  integrity sha512-Vj97CTn/lE9hR1D+jKUeHfNy+m1baNiJ1wJvoGyOBUx7F7kJqDZxr9nCHjO/Ad+irbR3HzR6jABpSSA29QsrXQ==
+babel-plugin-istanbul@^5.1.4:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-5.2.0.tgz#df4ade83d897a92df069c4d9a25cf2671293c854"
+  integrity sha512-5LphC0USA8t4i1zCtjbbNb6jJj/9+X6P37Qfirc/70EQ34xKlMW+a1RHGwxGI+SwWpNwZ27HqvzAobeqaXwiZw==
   dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-
-babel-plugin-minify-dead-code-elimination@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-dead-code-elimination/-/babel-plugin-minify-dead-code-elimination-0.5.1.tgz#1a0c68e44be30de4976ca69ffc535e08be13683f"
-  integrity sha512-x8OJOZIrRmQBcSqxBcLbMIK8uPmTvNWPXH2bh5MDCW1latEqYiRMuUkPImKcfpo59pTUB2FT7HfcgtG8ZlR5Qg==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-    babel-helper-mark-eval-scopes "^0.4.3"
-    babel-helper-remove-or-void "^0.4.3"
-    lodash "^4.17.11"
-
-babel-plugin-minify-flip-comparisons@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-flip-comparisons/-/babel-plugin-minify-flip-comparisons-0.4.3.tgz#00ca870cb8f13b45c038b3c1ebc0f227293c965a"
-  integrity sha1-AMqHDLjxO0XAOLPB68DyJyk8llo=
-  dependencies:
-    babel-helper-is-void-0 "^0.4.3"
-
-babel-plugin-minify-guarded-expressions@^0.4.3, babel-plugin-minify-guarded-expressions@^0.4.4:
-  version "0.4.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-guarded-expressions/-/babel-plugin-minify-guarded-expressions-0.4.4.tgz#818960f64cc08aee9d6c75bec6da974c4d621135"
-  integrity sha512-RMv0tM72YuPPfLT9QLr3ix9nwUIq+sHT6z8Iu3sLbqldzC1Dls8DPCywzUIzkTx9Zh1hWX4q/m9BPoPed9GOfA==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-    babel-helper-flip-expressions "^0.4.3"
-
-babel-plugin-minify-infinity@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-infinity/-/babel-plugin-minify-infinity-0.4.3.tgz#dfb876a1b08a06576384ef3f92e653ba607b39ca"
-  integrity sha1-37h2obCKBldjhO8/kuZTumB7Oco=
-
-babel-plugin-minify-mangle-names@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-mangle-names/-/babel-plugin-minify-mangle-names-0.5.0.tgz#bcddb507c91d2c99e138bd6b17a19c3c271e3fd3"
-  integrity sha512-3jdNv6hCAw6fsX1p2wBGPfWuK69sfOjfd3zjUXkbq8McbohWy23tpXfy5RnToYWggvqzuMOwlId1PhyHOfgnGw==
-  dependencies:
-    babel-helper-mark-eval-scopes "^0.4.3"
-
-babel-plugin-minify-numeric-literals@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-numeric-literals/-/babel-plugin-minify-numeric-literals-0.4.3.tgz#8e4fd561c79f7801286ff60e8c5fd9deee93c0bc"
-  integrity sha1-jk/VYcefeAEob/YOjF/Z3u6TwLw=
-
-babel-plugin-minify-replace@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-replace/-/babel-plugin-minify-replace-0.5.0.tgz#d3e2c9946c9096c070efc96761ce288ec5c3f71c"
-  integrity sha512-aXZiaqWDNUbyNNNpWs/8NyST+oU7QTpK7J9zFEFSA0eOmtUNMU3fczlTTTlnCxHmq/jYNFEmkkSG3DDBtW3Y4Q==
-
-babel-plugin-minify-simplify@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-simplify/-/babel-plugin-minify-simplify-0.5.1.tgz#f21613c8b95af3450a2ca71502fdbd91793c8d6a"
-  integrity sha512-OSYDSnoCxP2cYDMk9gxNAed6uJDiDz65zgL6h8d3tm8qXIagWGMLWhqysT6DY3Vs7Fgq7YUDcjOomhVUb+xX6A==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-    babel-helper-flip-expressions "^0.4.3"
-    babel-helper-is-nodes-equiv "^0.0.1"
-    babel-helper-to-multiple-sequence-expressions "^0.5.0"
-
-babel-plugin-minify-type-constructors@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-type-constructors/-/babel-plugin-minify-type-constructors-0.4.3.tgz#1bc6f15b87f7ab1085d42b330b717657a2156500"
-  integrity sha1-G8bxW4f3qxCF1CszC3F2V6IVZQA=
-  dependencies:
-    babel-helper-is-void-0 "^0.4.3"
-
-babel-plugin-transform-inline-consecutive-adds@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-inline-consecutive-adds/-/babel-plugin-transform-inline-consecutive-adds-0.4.3.tgz#323d47a3ea63a83a7ac3c811ae8e6941faf2b0d1"
-  integrity sha1-Mj1Ho+pjqDp6w8gRro5pQfrysNE=
-
-babel-plugin-transform-member-expression-literals@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-member-expression-literals/-/babel-plugin-transform-member-expression-literals-6.9.4.tgz#37039c9a0c3313a39495faac2ff3a6b5b9d038bf"
-  integrity sha1-NwOcmgwzE6OUlfqsL/OmtbnQOL8=
-
-babel-plugin-transform-merge-sibling-variables@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-merge-sibling-variables/-/babel-plugin-transform-merge-sibling-variables-6.9.4.tgz#85b422fc3377b449c9d1cde44087203532401dae"
-  integrity sha1-hbQi/DN3tEnJ0c3kQIcgNTJAHa4=
-
-babel-plugin-transform-minify-booleans@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-minify-booleans/-/babel-plugin-transform-minify-booleans-6.9.4.tgz#acbb3e56a3555dd23928e4b582d285162dd2b198"
-  integrity sha1-rLs+VqNVXdI5KOS1gtKFFi3SsZg=
-
-babel-plugin-transform-property-literals@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-property-literals/-/babel-plugin-transform-property-literals-6.9.4.tgz#98c1d21e255736573f93ece54459f6ce24985d39"
-  integrity sha1-mMHSHiVXNlc/k+zlRFn2ziSYXTk=
-  dependencies:
-    esutils "^2.0.2"
-
-babel-plugin-transform-regexp-constructors@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-regexp-constructors/-/babel-plugin-transform-regexp-constructors-0.4.3.tgz#58b7775b63afcf33328fae9a5f88fbd4fb0b4965"
-  integrity sha1-WLd3W2OvzzMyj66aX4j71PsLSWU=
-
-babel-plugin-transform-remove-console@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz#b980360c067384e24b357a588d807d3c83527780"
-  integrity sha1-uYA2DAZzhOJLNXpYjYB9PINSd4A=
-
-babel-plugin-transform-remove-debugger@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-debugger/-/babel-plugin-transform-remove-debugger-6.9.4.tgz#42b727631c97978e1eb2d199a7aec84a18339ef2"
-  integrity sha1-QrcnYxyXl44estGZp67IShgznvI=
-
-babel-plugin-transform-remove-undefined@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-undefined/-/babel-plugin-transform-remove-undefined-0.5.0.tgz#80208b31225766c630c97fa2d288952056ea22dd"
-  integrity sha512-+M7fJYFaEE/M9CXa0/IRkDbiV3wRELzA1kKQFCJ4ifhrzLKn/9VCCgj9OFmYWwBd8IB48YdgPkHYtbYq+4vtHQ==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-
-babel-plugin-transform-simplify-comparison-operators@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-simplify-comparison-operators/-/babel-plugin-transform-simplify-comparison-operators-6.9.4.tgz#f62afe096cab0e1f68a2d753fdf283888471ceb9"
-  integrity sha1-9ir+CWyrDh9ootdT/fKDiIRxzrk=
-
-babel-plugin-transform-undefined-to-void@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-undefined-to-void/-/babel-plugin-transform-undefined-to-void-6.9.4.tgz#be241ca81404030678b748717322b89d0c8fe280"
-  integrity sha1-viQcqBQEAwZ4t0hxcyK4nQyP4oA=
-
-babel-preset-minify@^0.5.0:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/babel-preset-minify/-/babel-preset-minify-0.5.1.tgz#25f5d0bce36ec818be80338d0e594106e21eaa9f"
-  integrity sha512-1IajDumYOAPYImkHbrKeiN5AKKP9iOmRoO2IPbIuVp0j2iuCcj0n7P260z38siKMZZ+85d3mJZdtW8IgOv+Tzg==
-  dependencies:
-    babel-plugin-minify-builtins "^0.5.0"
-    babel-plugin-minify-constant-folding "^0.5.0"
-    babel-plugin-minify-dead-code-elimination "^0.5.1"
-    babel-plugin-minify-flip-comparisons "^0.4.3"
-    babel-plugin-minify-guarded-expressions "^0.4.4"
-    babel-plugin-minify-infinity "^0.4.3"
-    babel-plugin-minify-mangle-names "^0.5.0"
-    babel-plugin-minify-numeric-literals "^0.4.3"
-    babel-plugin-minify-replace "^0.5.0"
-    babel-plugin-minify-simplify "^0.5.1"
-    babel-plugin-minify-type-constructors "^0.4.3"
-    babel-plugin-transform-inline-consecutive-adds "^0.4.3"
-    babel-plugin-transform-member-expression-literals "^6.9.4"
-    babel-plugin-transform-merge-sibling-variables "^6.9.4"
-    babel-plugin-transform-minify-booleans "^6.9.4"
-    babel-plugin-transform-property-literals "^6.9.4"
-    babel-plugin-transform-regexp-constructors "^0.4.3"
-    babel-plugin-transform-remove-console "^6.9.4"
-    babel-plugin-transform-remove-debugger "^6.9.4"
-    babel-plugin-transform-remove-undefined "^0.5.0"
-    babel-plugin-transform-simplify-comparison-operators "^6.9.4"
-    babel-plugin-transform-undefined-to-void "^6.9.4"
-    lodash "^4.17.11"
-
-babel-runtime@^6.22.0, babel-runtime@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
-  integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4=
-  dependencies:
-    core-js "^2.4.0"
-    regenerator-runtime "^0.11.0"
-
-babel-traverse@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee"
-  integrity sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=
-  dependencies:
-    babel-code-frame "^6.26.0"
-    babel-messages "^6.23.0"
-    babel-runtime "^6.26.0"
-    babel-types "^6.26.0"
-    babylon "^6.18.0"
-    debug "^2.6.8"
-    globals "^9.18.0"
-    invariant "^2.2.2"
-    lodash "^4.17.4"
-
-babel-types@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497"
-  integrity sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=
-  dependencies:
-    babel-runtime "^6.26.0"
-    esutils "^2.0.2"
-    lodash "^4.17.4"
-    to-fast-properties "^1.0.3"
-
-babylon@^6.18.0:
-  version "6.18.0"
-  resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
-  integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==
-
-babylon@^7.0.0-beta.42:
-  version "7.0.0-beta.47"
-  resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.47.tgz#6d1fa44f0abec41ab7c780481e62fd9aafbdea80"
-  integrity sha512-+rq2cr4GDhtToEzKFD6KZZMDBXhjFAr9JjPw9pAppZACeEWqNM294j+NdBzkSHYXwzzBmVjZ3nEVJlOhbR2gOQ==
+    "@babel/helper-plugin-utils" "^7.0.0"
+    find-up "^3.0.0"
+    istanbul-lib-instrument "^3.3.0"
+    test-exclude "^5.2.3"
 
 backo2@1.0.2:
   version "1.0.2"
@@ -1446,33 +1368,10 @@
   resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
   integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg=
 
-base64-js@1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.0.tgz#a39992d723584811982be5e290bb6a53d86700f1"
-  integrity sha1-o5mS1yNYSBGYK+XikLtqU9hnAPE=
-
-base64-js@^1.0.2:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
-  integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
-
-base64id@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
-  integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
-
-base@^0.11.1:
-  version "0.11.2"
-  resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
-  integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==
-  dependencies:
-    cache-base "^1.0.1"
-    class-utils "^0.3.5"
-    component-emitter "^1.2.1"
-    define-property "^1.0.0"
-    isobject "^3.0.1"
-    mixin-deep "^1.2.0"
-    pascalcase "^0.1.1"
+base64id@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6"
+  integrity sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=
 
 bcrypt-pbkdf@^1.0.0:
   version "1.0.2"
@@ -1488,27 +1387,22 @@
   dependencies:
     callsite "1.0.0"
 
-bl@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/bl/-/bl-2.2.0.tgz#e1a574cdf528e4053019bb800b041c0ac88da493"
-  integrity sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==
-  dependencies:
-    readable-stream "^2.3.5"
-    safe-buffer "^5.1.1"
-
-bl@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/bl/-/bl-3.0.0.tgz#3611ec00579fd18561754360b21e9f784500ff88"
-  integrity sha512-EUAyP5UHU5hxF8BPT0LKW8gjYLhq1DQIcneOX/pL/m2Alo+OYDQAJlHq+yseMP50Os2nHXOSic6Ss3vSQeyf4A==
-  dependencies:
-    readable-stream "^3.0.1"
+binary-extensions@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c"
+  integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==
 
 blob@0.0.5:
   version "0.0.5"
   resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
   integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
 
-body-parser@1.19.0, body-parser@^1.17.2:
+bluebird@^3.3.0:
+  version "3.7.2"
+  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
+  integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
+
+body-parser@^1.16.1:
   version "1.19.0"
   resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
   integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
@@ -1524,30 +1418,6 @@
     raw-body "2.4.0"
     type-is "~1.6.17"
 
-bower-config@^1.4.0, bower-config@^1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/bower-config/-/bower-config-1.4.1.tgz#85fd9df367c2b8dbbd0caa4c5f2bad40cd84c2cc"
-  integrity sha1-hf2d82fCuNu9DKpMXyutQM2Ewsw=
-  dependencies:
-    graceful-fs "^4.1.3"
-    mout "^1.0.0"
-    optimist "^0.6.1"
-    osenv "^0.1.3"
-    untildify "^2.1.0"
-
-boxen@^1.2.1:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
-  integrity sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==
-  dependencies:
-    ansi-align "^2.0.0"
-    camelcase "^4.0.0"
-    chalk "^2.0.1"
-    cli-boxes "^1.0.0"
-    string-width "^2.0.0"
-    term-size "^1.2.0"
-    widest-line "^2.0.0"
-
 brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -1556,113 +1426,84 @@
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
-braces@^1.8.2:
-  version "1.8.5"
-  resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7"
-  integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=
+braces@^3.0.2, braces@~3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
+  integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
   dependencies:
-    expand-range "^1.8.1"
-    preserve "^0.2.0"
-    repeat-element "^1.1.2"
-
-braces@^2.3.1:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
-  integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==
-  dependencies:
-    arr-flatten "^1.1.0"
-    array-unique "^0.3.2"
-    extend-shallow "^2.0.1"
-    fill-range "^4.0.0"
-    isobject "^3.0.1"
-    repeat-element "^1.1.2"
-    snapdragon "^0.8.1"
-    snapdragon-node "^2.0.1"
-    split-string "^3.0.2"
-    to-regex "^3.0.1"
-
-browser-capabilities@^1.0.0:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/browser-capabilities/-/browser-capabilities-1.1.4.tgz#a6bd657a07a134532ad66c722b8949904478b973"
-  integrity sha512-BezMQhbQklxjRQpZZQ8tnbzEo6AldUwMh8/PeWt5/CTBSwByQRXZEAK2fbnEahQ4poeeaI0suAYRq25A1YGOmw==
-  dependencies:
-    "@types/ua-parser-js" "^0.7.31"
-    ua-parser-js "^0.7.15"
-
-browser-stdout@1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.0.tgz#f351d32969d32fa5d7a5567154263d928ae3bd1f"
-  integrity sha1-81HTKWnTL6XXpVZxVCY9korjvR8=
+    fill-range "^7.0.1"
 
 browser-stdout@1.3.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"
   integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==
 
-browserstack@^1.2.0:
-  version "1.5.3"
-  resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.5.3.tgz#93ab48799a12ef99dbd074dd595410ddb196a7ac"
-  integrity sha512-AO+mECXsW4QcqC9bxwM29O7qWa7bJT94uBFzeb5brylIQwawuEziwq20dPYbins95GlWzOawgyDNdjYAo32EKg==
+browserslist-useragent@^3.0.2:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/browserslist-useragent/-/browserslist-useragent-3.0.3.tgz#d06c062a4e444ad5e1a80323131d4508450c9af5"
+  integrity sha512-8KKO6kOXu/93IkMi8zVqzU72BgpoxcITIHtkM1qmlnxJtIMF9Y+2uWL9JS2uUbzj/PaS3kaA6LcICBThMojGjA==
   dependencies:
-    https-proxy-agent "^2.2.1"
+    browserslist "^4.12.0"
+    semver "^7.3.2"
+    useragent "^2.3.0"
 
-buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3:
-  version "0.2.13"
-  resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
-  integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
+browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.5, browserslist@^4.9.1:
+  version "4.12.2"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.12.2.tgz#76653d7e4c57caa8a1a28513e2f4e197dc11a711"
+  integrity sha512-MfZaeYqR8StRZdstAK9hCKDd2StvePCYp5rHzQCPicUjfFliDgmuaBNPHYUTpAywBN8+Wc/d7NYVFkO0aqaBUw==
+  dependencies:
+    caniuse-lite "^1.0.30001088"
+    electron-to-chromium "^1.3.483"
+    escalade "^3.0.1"
+    node-releases "^1.1.58"
+
+buffer-alloc-unsafe@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
+  integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==
+
+buffer-alloc@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec"
+  integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==
+  dependencies:
+    buffer-alloc-unsafe "^1.1.0"
+    buffer-fill "^1.0.0"
+
+buffer-fill@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c"
+  integrity sha1-+PeLdniYiO858gXNY39o5wISKyw=
 
 buffer-from@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
   integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
 
-buffer@^5.1.0:
-  version "5.4.3"
-  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.4.3.tgz#3fbc9c69eb713d323e3fc1a895eee0710c072115"
-  integrity sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==
-  dependencies:
-    base64-js "^1.0.2"
-    ieee754 "^1.1.4"
+builtin-modules@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.1.0.tgz#aad97c15131eb76b65b50ef208e7584cd76a7484"
+  integrity sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw==
 
-busboy@^0.2.11:
-  version "0.2.14"
-  resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453"
-  integrity sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=
-  dependencies:
-    dicer "0.2.5"
-    readable-stream "1.1.x"
-
-bytes@3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
-  integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=
-
-bytes@3.1.0:
+bytes@3.1.0, bytes@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
   integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
 
-cache-base@^1.0.1:
+cache-content-type@^1.0.0:
   version "1.0.1"
-  resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
-  integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==
+  resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c"
+  integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==
   dependencies:
-    collection-visit "^1.0.0"
-    component-emitter "^1.2.1"
-    get-value "^2.0.6"
-    has-value "^1.0.0"
-    isobject "^3.0.1"
-    set-value "^2.0.0"
-    to-object-path "^0.3.0"
-    union-value "^1.0.0"
-    unset-value "^1.0.0"
+    mime-types "^2.1.18"
+    ylru "^1.2.0"
 
 callsite@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
   integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA=
 
-camel-case@3.0.x:
+camel-case@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73"
   integrity sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=
@@ -1670,55 +1511,31 @@
     no-case "^2.2.0"
     upper-case "^1.1.1"
 
-camelcase-keys@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7"
-  integrity sha1-MIvur/3ygRkFHvodkyITyRuPkuc=
-  dependencies:
-    camelcase "^2.0.0"
-    map-obj "^1.0.0"
-
-camelcase@^2.0.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
-  integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=
-
-camelcase@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
-  integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
-
-camelcase@^5.0.0:
+camelcase@^5.0.0, camelcase@^5.3.1:
   version "5.3.1"
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
   integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
 
-cancel-token@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/cancel-token/-/cancel-token-0.1.1.tgz#c18197674bb1c84c1d6933ebf15d8d5a5ce79b4f"
-  integrity sha1-wYGXZ0uxyEwdaTPr8V2NWlznm08=
+caniuse-api@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"
+  integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==
   dependencies:
-    "@types/node" "^4.0.30"
+    browserslist "^4.0.0"
+    caniuse-lite "^1.0.0"
+    lodash.memoize "^4.1.2"
+    lodash.uniq "^4.5.0"
 
-capture-stack-trace@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d"
-  integrity sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001033, caniuse-lite@^1.0.30001088:
+  version "1.0.30001093"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001093.tgz#833e80f64b1a0455cbceed2a4a3baf19e4abd312"
+  integrity sha512-0+ODNoOjtWD5eS9aaIpf4K0gQqZfILNY4WSNuYzeT1sXni+lMrrVjc0odEobJt6wrODofDZUX8XYi/5y7+xl8g==
 
 caseless@~0.12.0:
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
   integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
 
-chai@^3.5.0:
-  version "3.5.0"
-  resolved "https://registry.yarnpkg.com/chai/-/chai-3.5.0.tgz#4d02637b067fe958bdbfdd3a40ec56fef7373247"
-  integrity sha1-TQJjewZ/6Vi9v906QOxW/vc3Mkc=
-  dependencies:
-    assertion-error "^1.0.1"
-    deep-eql "^0.1.3"
-    type-detect "^1.0.0"
-
 chai@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/chai/-/chai-4.2.0.tgz#760aa72cf20e3795e84b12877ce0e83737aa29e5"
@@ -1731,18 +1548,7 @@
     pathval "^1.1.0"
     type-detect "^4.0.5"
 
-chalk@^1.1.1, chalk@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
-  integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=
-  dependencies:
-    ansi-styles "^2.2.1"
-    escape-string-regexp "^1.0.2"
-    has-ansi "^2.0.0"
-    strip-ansi "^3.0.0"
-    supports-color "^2.0.0"
-
-chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1:
+chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.2:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
   integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
@@ -1751,57 +1557,48 @@
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
-chalk@~0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.4.0.tgz#5199a3ddcd0c1efe23bc08c1b027b06176e0c64f"
-  integrity sha1-UZmj3c0MHv4jvAjBsCewYXbgxk8=
-  dependencies:
-    ansi-styles "~1.0.0"
-    has-color "~0.1.0"
-    strip-ansi "~0.1.0"
-
-charenc@~0.0.1:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
-  integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=
-
 check-error@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
   integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=
 
-ci-info@^1.5.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497"
-  integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==
-
-class-utils@^0.3.5:
-  version "0.3.6"
-  resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
-  integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==
+chokidar@3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.0.tgz#12c0714668c55800f659e262d4962a97faf554a6"
+  integrity sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A==
   dependencies:
-    arr-union "^3.1.0"
-    define-property "^0.2.5"
-    isobject "^3.0.0"
-    static-extend "^0.1.1"
+    anymatch "~3.1.1"
+    braces "~3.0.2"
+    glob-parent "~5.1.0"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.2.0"
+  optionalDependencies:
+    fsevents "~2.1.1"
 
-clean-css@4.2.x:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.1.tgz#2d411ef76b8569b6d0c84068dabe85b0aa5e5c17"
-  integrity sha512-4ZxI6dy4lrY6FHzfiy1aEOXgu4LIsW2MhwG0VBKdcoGoH/XLFgaHSdLTGr4O8Be6A8r3MOphEiI8Gc1n0ecf3g==
+chokidar@^3.0.0:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.1.tgz#c84e5b3d18d9a4d77558fef466b1bf16bbeb3450"
+  integrity sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg==
+  dependencies:
+    anymatch "~3.1.1"
+    braces "~3.0.2"
+    glob-parent "~5.1.0"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.3.0"
+  optionalDependencies:
+    fsevents "~2.1.2"
+
+clean-css@^4.2.1:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"
+  integrity sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA==
   dependencies:
     source-map "~0.6.0"
 
-cleankill@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/cleankill/-/cleankill-2.0.0.tgz#59830dfc8b411d53dc72ad09d45a78ea33161a91"
-  integrity sha1-WYMN/ItBHVPccq0J1Fp46jMWGpE=
-
-cli-boxes@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
-  integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM=
-
 cliui@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5"
@@ -1811,30 +1608,17 @@
     strip-ansi "^5.2.0"
     wrap-ansi "^5.1.0"
 
-clone-stats@^0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1"
-  integrity sha1-uI+UqCzzi4eR1YBG6kAprYjKmdE=
-
-clone@^1.0.0:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
-  integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
-
-clone@^2.0.0, clone@^2.1.0:
+clone@^2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
   integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
 
-collection-visit@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
-  integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=
-  dependencies:
-    map-visit "^1.0.0"
-    object-visit "^1.0.0"
+co@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
+  integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=
 
-color-convert@^1.9.0, color-convert@^1.9.1:
+color-convert@^1.9.0:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
   integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
@@ -1846,45 +1630,11 @@
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
   integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
 
-color-name@^1.0.0:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
-  integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
-
-color-string@^1.5.2:
-  version "1.5.3"
-  resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc"
-  integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==
-  dependencies:
-    color-name "^1.0.0"
-    simple-swizzle "^0.2.2"
-
-color@3.0.x:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a"
-  integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==
-  dependencies:
-    color-convert "^1.9.1"
-    color-string "^1.5.2"
-
-colornames@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/colornames/-/colornames-1.1.1.tgz#f8889030685c7c4ff9e2a559f5077eb76a816f96"
-  integrity sha1-+IiQMGhcfE/54qVZ9Qd+t2qBb5Y=
-
-colors@^1.2.1:
+colors@^1.1.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
   integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
 
-colorspace@1.1.x:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.2.tgz#e0128950d082b86a2168580796a0aa5d6c68d8c5"
-  integrity sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ==
-  dependencies:
-    color "3.0.x"
-    text-hex "1.0.x"
-
 combined-stream@^1.0.6, combined-stream@~1.0.6:
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
@@ -1902,38 +1652,21 @@
     lodash.camelcase "^4.3.0"
     typical "^4.0.0"
 
-command-line-usage@^5.0.5:
-  version "5.0.5"
-  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-5.0.5.tgz#5f25933ffe6dedd983c635d38a21d7e623fda357"
-  integrity sha512-d8NrGylA5oCXSbGoKz05FkehDAzSmIm4K03S5VDh4d5lZAtTWfc3D1RuETtuQCn8129nYfJfDdF7P/lwcz1BlA==
+command-line-usage@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.0.tgz#f28376a3da3361ff3d36cfd31c3c22c9a64c7cb6"
+  integrity sha512-Ew1clU4pkUeo6AFVDFxCbnN7GIZfXl48HIOQeFQnkO3oOqvpI7wdqtLRwv9iOCZ/7A+z4csVZeiDdEcj8g6Wiw==
   dependencies:
-    array-back "^2.0.0"
-    chalk "^2.4.1"
-    table-layout "^0.4.3"
-    typical "^2.6.1"
+    array-back "^4.0.0"
+    chalk "^2.4.2"
+    table-layout "^1.0.0"
+    typical "^5.2.0"
 
-commander@2.17.x:
-  version "2.17.1"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
-  integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
-
-commander@2.9.0:
-  version "2.9.0"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4"
-  integrity sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=
-  dependencies:
-    graceful-readlink ">= 1.0.0"
-
-commander@^2.19.0:
+commander@^2.19.0, commander@^2.20.0:
   version "2.20.3"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
   integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
 
-commander@~2.19.0:
-  version "2.19.0"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
-  integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==
-
 component-bind@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
@@ -1944,204 +1677,87 @@
   resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
   integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
 
-component-emitter@^1.2.1:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
-  integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
-
 component-inherit@0.0.3:
   version "0.0.3"
   resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
   integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=
 
-compress-commons@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-2.1.1.tgz#9410d9a534cf8435e3fbbb7c6ce48de2dc2f0610"
-  integrity sha512-eVw6n7CnEMFzc3duyFVrQEuY1BlHR3rYsSztyG32ibGMW722i3C6IizEGMFmfMU+A+fALvBIwxN3czffTcdA+Q==
-  dependencies:
-    buffer-crc32 "^0.2.13"
-    crc32-stream "^3.0.1"
-    normalize-path "^3.0.0"
-    readable-stream "^2.3.6"
-
-compressible@~2.0.16:
+compressible@^2.0.0:
   version "2.0.18"
   resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
   integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==
   dependencies:
     mime-db ">= 1.43.0 < 2"
 
-compression@^1.6.2:
-  version "1.7.4"
-  resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f"
-  integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==
-  dependencies:
-    accepts "~1.3.5"
-    bytes "3.0.0"
-    compressible "~2.0.16"
-    debug "2.6.9"
-    on-headers "~1.0.2"
-    safe-buffer "5.1.2"
-    vary "~1.1.2"
-
 concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
 
-concat-stream@^1.5.2:
-  version "1.6.2"
-  resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
-  integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
+connect@^3.6.0:
+  version "3.7.0"
+  resolved "https://registry.yarnpkg.com/connect/-/connect-3.7.0.tgz#5d49348910caa5e07a01800b030d0c35f20484f8"
+  integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==
   dependencies:
-    buffer-from "^1.0.0"
-    inherits "^2.0.3"
-    readable-stream "^2.2.2"
-    typedarray "^0.0.6"
+    debug "2.6.9"
+    finalhandler "1.1.2"
+    parseurl "~1.3.3"
+    utils-merge "1.0.1"
 
-configstore@^3.0.0:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.2.tgz#c6f25defaeef26df12dd33414b001fe81a543f8f"
-  integrity sha512-vtv5HtGjcYUgFrXc6Kx747B83MRRVS5R1VTEQoXvuP+kMI+if6uywV0nDGoiydJRy4yk7h9od5Og0kxx4zUXmw==
-  dependencies:
-    dot-prop "^4.1.0"
-    graceful-fs "^4.1.2"
-    make-dir "^1.0.0"
-    unique-string "^1.0.0"
-    write-file-atomic "^2.0.0"
-    xdg-basedir "^3.0.0"
-
-content-disposition@0.5.3:
+content-disposition@~0.5.2:
   version "0.5.3"
   resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
   integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
   dependencies:
     safe-buffer "5.1.2"
 
-content-type@^1.0.2, content-type@~1.0.4:
+content-type@^1.0.4, content-type@~1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
   integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
 
-convert-source-map@^1.1.1, convert-source-map@^1.7.0:
+convert-source-map@^1.7.0:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
   integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==
   dependencies:
     safe-buffer "~5.1.1"
 
-cookie-signature@1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
-  integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
-
 cookie@0.3.1:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
   integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=
 
-cookie@0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
-  integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
+cookies@~0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
+  integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==
+  dependencies:
+    depd "~2.0.0"
+    keygrip "~1.1.0"
 
-copy-descriptor@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
-  integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
+core-js-bundle@^3.6.0:
+  version "3.6.5"
+  resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.6.5.tgz#3a425ad66ad19aeefea89acfd48cff674ff58590"
+  integrity sha512-awf49McIBT3sDXceSex69w/i7PMXQwxI4ZqknCtaYbW4Q0u0HUZiaQLlPD6pU2nFBofIowgWIS1ANgHjqnQu4Q==
 
-core-js@^2.4.0:
-  version "2.6.11"
-  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c"
-  integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==
+core-js-compat@^3.6.2:
+  version "3.6.5"
+  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.6.5.tgz#2a51d9a4e25dfd6e690251aa81f99e3c05481f1c"
+  integrity sha512-7ItTKOhOZbznhXAQ2g/slGg1PJV5zDO/WdkTwi7UEOJmkvsE32PWvx6mKtDjiMpjnR2CNf6BAD6sSxIlv7ptng==
+  dependencies:
+    browserslist "^4.8.5"
+    semver "7.0.0"
 
-core-util-is@1.0.2, core-util-is@~1.0.0:
+core-util-is@1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
   integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
 
-cors@^2.8.4:
-  version "2.8.5"
-  resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
-  integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
-  dependencies:
-    object-assign "^4"
-    vary "^1"
-
-crc32-stream@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-3.0.1.tgz#cae6eeed003b0e44d739d279de5ae63b171b4e85"
-  integrity sha512-mctvpXlbzsvK+6z8kJwSJ5crm7yBwrQMTybJzMw1O4lLGJqjlDCXY2Zw7KheiA6XBEcBmfLx1D88mjRGVJtY9w==
-  dependencies:
-    crc "^3.4.4"
-    readable-stream "^3.4.0"
-
-crc@^3.4.4:
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6"
-  integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==
-  dependencies:
-    buffer "^5.1.0"
-
-create-error-class@^3.0.0:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6"
-  integrity sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=
-  dependencies:
-    capture-stack-trace "^1.0.0"
-
-cross-spawn@^5.0.1:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
-  integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=
-  dependencies:
-    lru-cache "^4.0.1"
-    shebang-command "^1.2.0"
-    which "^1.2.9"
-
-cross-spawn@^6.0.5:
-  version "6.0.5"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
-  integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
-  dependencies:
-    nice-try "^1.0.4"
-    path-key "^2.0.1"
-    semver "^5.5.0"
-    shebang-command "^1.2.0"
-    which "^1.2.9"
-
-crypt@~0.0.1:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
-  integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=
-
-crypto-random-string@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
-  integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=
-
-css-slam@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/css-slam/-/css-slam-2.1.2.tgz#3d35b1922cb3e0002a45c89ab189492508c493e5"
-  integrity sha512-cObrY+mhFEmepWpua6EpMrgRNTQ0eeym+kvR0lukI6hDEzK7F8himEDS4cJ9+fPHCoArTzVrrR0d+oAUbTR1NA==
-  dependencies:
-    command-line-args "^5.0.2"
-    command-line-usage "^5.0.5"
-    dom5 "^3.0.0"
-    parse5 "^4.0.0"
-    shady-css-parser "^0.1.0"
-
-cssbeautify@^0.3.1:
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/cssbeautify/-/cssbeautify-0.3.1.tgz#12dd1f734035c2e6faca67dcbdcef74e42811397"
-  integrity sha1-Et0fc0A1wub6ymfcvc73TkKBE5c=
-
-currently-unhandled@^0.4.1:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
-  integrity sha1-mI3zP+qxke95mmE2nddsF635V+o=
-  dependencies:
-    array-find-index "^1.0.1"
+custom-event@~1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
+  integrity sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU=
 
 dashdash@^1.12.0:
   version "1.14.1"
@@ -2150,28 +1766,31 @@
   dependencies:
     assert-plus "^1.0.0"
 
-debug@2.6.8:
-  version "2.6.8"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc"
-  integrity sha1-5zFTHKLt4n0YgiJCfaF4IdaP9Pw=
-  dependencies:
-    ms "2.0.0"
+date-format@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/date-format/-/date-format-2.1.0.tgz#31d5b5ea211cf5fd764cd38baf9d033df7e125cf"
+  integrity sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==
 
-debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8:
+debounce@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.0.tgz#44a540abc0ea9943018dc0eaa95cce87f65cd131"
+  integrity sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg==
+
+debug@2.6.9:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
   dependencies:
     ms "2.0.0"
 
-debug@3.2.6, debug@^3.0.0, debug@^3.1.0:
+debug@3.2.6, debug@^3.0.0, debug@^3.1.0, debug@^3.1.1, debug@^3.2.6:
   version "3.2.6"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
   integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
   dependencies:
     ms "^2.1.1"
 
-debug@^4.1.0, debug@^4.1.1, debug@~4.1.0:
+debug@^4.1.0, debug@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
   integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
@@ -2185,23 +1804,11 @@
   dependencies:
     ms "2.0.0"
 
-decamelize@^1.1.2, decamelize@^1.2.0:
+decamelize@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
   integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
 
-decode-uri-component@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
-  integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
-
-deep-eql@^0.1.3:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-0.1.3.tgz#ef558acab8de25206cd713906d74e56930eb69f2"
-  integrity sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=
-  dependencies:
-    type-detect "0.1.1"
-
 deep-eql@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
@@ -2209,11 +1816,21 @@
   dependencies:
     type-detect "^4.0.0"
 
-deep-extend@^0.6.0, deep-extend@~0.6.0:
+deep-equal@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
+  integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=
+
+deep-extend@~0.6.0:
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
   integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
 
+deepmerge@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
+  integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
+
 define-properties@^1.1.2, define-properties@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
@@ -2221,138 +1838,60 @@
   dependencies:
     object-keys "^1.0.12"
 
-define-property@^0.2.5:
-  version "0.2.5"
-  resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116"
-  integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=
-  dependencies:
-    is-descriptor "^0.1.0"
-
-define-property@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6"
-  integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY=
-  dependencies:
-    is-descriptor "^1.0.0"
-
-define-property@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d"
-  integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==
-  dependencies:
-    is-descriptor "^1.0.2"
-    isobject "^3.0.1"
-
 delayed-stream@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
   integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
 
-depd@~1.1.2:
+delegates@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
+
+depd@^1.1.2, depd@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
   integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
 
-destroy@~1.0.4:
+depd@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
+  integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
+
+destroy@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
   integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
 
-detect-file@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
-  integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=
+di@^0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c"
+  integrity sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw=
 
-detect-indent@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208"
-  integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg=
-  dependencies:
-    repeating "^2.0.0"
-
-detect-node@^2.0.3:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c"
-  integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==
-
-diagnostics@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/diagnostics/-/diagnostics-1.1.1.tgz#cab6ac33df70c9d9a727490ae43ac995a769b22a"
-  integrity sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ==
-  dependencies:
-    colorspace "1.1.x"
-    enabled "1.0.x"
-    kuler "1.0.x"
-
-dicer@0.2.5:
-  version "0.2.5"
-  resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f"
-  integrity sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=
-  dependencies:
-    readable-stream "1.1.x"
-    streamsearch "0.1.2"
-
-diff@3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-3.2.0.tgz#c9ce393a4b7cbd0b058a725c93df299027868ff9"
-  integrity sha1-yc45Okt8vQsFinJck98pkCeGj/k=
-
-diff@3.5.0, diff@^3.1.0:
+diff@3.5.0:
   version "3.5.0"
   resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
   integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==
 
-doctrine@^2.0.2:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
-  integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==
-  dependencies:
-    esutils "^2.0.2"
+diff@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
+  integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
 
-dom-urls@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/dom-urls/-/dom-urls-1.1.0.tgz#001ddf81628cd1e706125c7176f53ccec55d918e"
-  integrity sha1-AB3fgWKM0ecGElxxdvU8zsVdkY4=
+dom-serialize@^2.2.0:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b"
+  integrity sha1-ViromZ9Evl6jB29UGdzVnrQ6yVs=
   dependencies:
-    urijs "^1.16.1"
+    custom-event "~1.0.0"
+    ent "~2.2.0"
+    extend "^3.0.0"
+    void-elements "^2.0.0"
 
-dom5@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/dom5/-/dom5-3.0.1.tgz#cdfc7331f376e284bf379e6ea054afc136702944"
-  integrity sha512-JPFiouQIr16VQ4dX6i0+Hpbg3H2bMKPmZ+WZgBOSSvOPx9QHwwY8sPzeM2baUtViESYto6wC2nuZOMC/6gulcA==
-  dependencies:
-    "@types/parse5" "^2.2.34"
-    clone "^2.1.0"
-    parse5 "^4.0.0"
-
-dot-prop@^4.1.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57"
-  integrity sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ==
-  dependencies:
-    is-obj "^1.0.0"
-
-duplexer2@^0.1.2:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
-  integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=
-  dependencies:
-    readable-stream "^2.0.2"
-
-duplexer3@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
-  integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
-
-duplexify@^3.2.0, duplexify@^3.5.0:
-  version "3.7.1"
-  resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309"
-  integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==
-  dependencies:
-    end-of-stream "^1.0.0"
-    inherits "^2.0.1"
-    readable-stream "^2.0.0"
-    stream-shift "^1.0.0"
+dynamic-import-polyfill@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/dynamic-import-polyfill/-/dynamic-import-polyfill-0.1.1.tgz#e1f9eb1876ee242bd56572f8ed4df768e143083f"
+  integrity sha512-m953zv0w5oDagTItWm6Auhmk/pY7EiejaqiVbnzSS3HIjh1FCUeK7WzuaVtWPNs58A+/xpIE+/dVk6pKsrua8g==
 
 ecc-jsbn@~0.1.1:
   version "0.1.2"
@@ -2367,56 +1906,49 @@
   resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
 
-emitter-component@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.1.tgz#065e2dbed6959bf470679edabeaf7981d1003ab6"
-  integrity sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY=
+electron-to-chromium@^1.3.483:
+  version "1.3.486"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.486.tgz#90856e6c9f488079225cf5a0b4d4af6c241e0965"
+  integrity sha512-fmnACh6Jiuagm9tAfEZNe6QrwvOYAC5y0BwzoEOGCsbqriKOCaafXf3lsIvL55xa75Jmg4oboI7f5tMuoXrjNg==
 
 emoji-regex@^7.0.1:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
   integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
 
-enabled@1.0.x:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/enabled/-/enabled-1.0.2.tgz#965f6513d2c2d1c5f4652b64a2e3396467fc2f93"
-  integrity sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=
-  dependencies:
-    env-variable "0.0.x"
-
-encodeurl@~1.0.2:
+encodeurl@^1.0.2, encodeurl@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
   integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
 
-end-of-stream@^1.0.0, end-of-stream@^1.4.1:
+end-of-stream@^1.1.0:
   version "1.4.4"
   resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
   integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
   dependencies:
     once "^1.4.0"
 
-engine.io-client@~3.4.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.4.0.tgz#82a642b42862a9b3f7a188f41776b2deab643700"
-  integrity sha512-a4J5QO2k99CM2a0b12IznnyQndoEvtA4UAldhGzKqnHf42I3Qs2W5SPnDvatZRcMaNZs4IevVicBPayxYt6FwA==
+engine.io-client@~3.2.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.2.1.tgz#6f54c0475de487158a1a7c77d10178708b6add36"
+  integrity sha512-y5AbkytWeM4jQr7m/koQLc5AxpRKC1hEVUb/s1FUAWEJq5AzJJ4NLvzuKPuxtDi5Mq755WuDvZ6Iv2rXj4PTzw==
   dependencies:
     component-emitter "1.2.1"
     component-inherit "0.0.3"
-    debug "~4.1.0"
-    engine.io-parser "~2.2.0"
+    debug "~3.1.0"
+    engine.io-parser "~2.1.1"
     has-cors "1.1.0"
     indexof "0.0.1"
     parseqs "0.0.5"
     parseuri "0.0.5"
-    ws "~6.1.0"
+    ws "~3.3.1"
     xmlhttprequest-ssl "~1.5.4"
     yeast "0.1.2"
 
-engine.io-parser@~2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.0.tgz#312c4894f57d52a02b420868da7b5c1c84af80ed"
-  integrity sha512-6I3qD9iUxotsC5HEMuuGsKA0cXerGz+4uGcXQEkfBidgKf0amsjrrtwcbwK/nzpZBxclXlV7gGl9dgWvu4LF6w==
+engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6"
+  integrity sha512-6HXPre2O4Houl7c4g7Ic/XzPnHBvaEmN90vtRO9uLmwtRqQmTOw0QMevL1TOfL2Cpu1VzsaTmMotQgMdkzGkVA==
   dependencies:
     after "0.8.2"
     arraybuffer.slice "~0.0.7"
@@ -2424,46 +1956,126 @@
     blob "0.0.5"
     has-binary2 "~1.0.2"
 
-engine.io@~3.4.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.4.0.tgz#3a962cc4535928c252759a00f98519cb46c53ff3"
-  integrity sha512-XCyYVWzcHnK5cMz7G4VTu2W7zJS7SM1QkcelghyIk/FmobWBtXE7fwhBusEKvCSqc3bMh8fNFMlUkCKTFRxH2w==
+engine.io@~3.2.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.2.1.tgz#b60281c35484a70ee0351ea0ebff83ec8c9522a2"
+  integrity sha512-+VlKzHzMhaU+GsCIg4AoXF1UdDFjHHwMmMKqMJNDNLlUlejz58FCy4LBqB2YVJskHGYl06BatYWKP2TVdVXE5w==
   dependencies:
     accepts "~1.3.4"
-    base64id "2.0.0"
+    base64id "1.0.0"
     cookie "0.3.1"
-    debug "~4.1.0"
-    engine.io-parser "~2.2.0"
-    ws "^7.1.2"
+    debug "~3.1.0"
+    engine.io-parser "~2.1.0"
+    ws "~3.3.1"
 
-env-variable@0.0.x:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.5.tgz#913dd830bef11e96a039c038d4130604eba37f88"
-  integrity sha512-zoB603vQReOFvTg5xMl9I1P2PnHsHQQKTEowsKKD7nseUfJq6UWzK+4YtlWUO1nhiQUxe6XMkk+JleSZD1NZFA==
+ent@~2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d"
+  integrity sha1-6WQhkyWiHQX0RGai9obtbOX13R0=
 
-error-ex@^1.2.0:
+error-ex@^1.3.1:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
   integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
   dependencies:
     is-arrayish "^0.2.1"
 
-es-abstract@^1.17.0-next.1:
-  version "1.17.4"
-  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.4.tgz#e3aedf19706b20e7c2594c35fc0d57605a79e184"
-  integrity sha512-Ae3um/gb8F0mui/jPL+QiqmglkUsaQf7FwBEHYIFkztkneosu9imhqHpBzQ3h1vit8t5iQ74t6PEVvphBZiuiQ==
+es-abstract@^1.17.0-next.1, es-abstract@^1.17.5:
+  version "1.17.6"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a"
+  integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==
   dependencies:
     es-to-primitive "^1.2.1"
     function-bind "^1.1.1"
     has "^1.0.3"
     has-symbols "^1.0.1"
-    is-callable "^1.1.5"
-    is-regex "^1.0.5"
+    is-callable "^1.2.0"
+    is-regex "^1.1.0"
     object-inspect "^1.7.0"
     object-keys "^1.1.1"
     object.assign "^4.1.0"
-    string.prototype.trimleft "^2.1.1"
-    string.prototype.trimright "^2.1.1"
+    string.prototype.trimend "^1.0.1"
+    string.prototype.trimstart "^1.0.1"
+
+es-dev-server@^1.56.0:
+  version "1.56.0"
+  resolved "https://registry.yarnpkg.com/es-dev-server/-/es-dev-server-1.56.0.tgz#8703af87595f02fe9a1c92a07e64b7c7cc915a87"
+  integrity sha512-SL4CXdiku0hiB8zpsBLtEd7b8etIZE6IV0tIi02m0CcpTYV0rDMEvCBUYsQIN5hggJDDTBURgQjOWcT5kQv2eA==
+  dependencies:
+    "@babel/core" "^7.9.0"
+    "@babel/plugin-proposal-dynamic-import" "^7.8.3"
+    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.8.3"
+    "@babel/plugin-proposal-optional-chaining" "^7.9.0"
+    "@babel/plugin-syntax-class-properties" "^7.8.3"
+    "@babel/plugin-syntax-import-meta" "^7.8.3"
+    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
+    "@babel/plugin-syntax-numeric-separator" "^7.8.3"
+    "@babel/plugin-syntax-optional-chaining" "^7.8.3"
+    "@babel/plugin-transform-template-literals" "^7.8.3"
+    "@babel/preset-env" "^7.9.0"
+    "@koa/cors" "^3.1.0"
+    "@open-wc/building-utils" "^2.18.0"
+    "@rollup/plugin-node-resolve" "^7.1.1"
+    "@rollup/pluginutils" "^3.0.0"
+    "@types/babel__core" "^7.1.3"
+    "@types/browserslist" "^4.8.0"
+    "@types/browserslist-useragent" "^3.0.0"
+    "@types/caniuse-api" "^3.0.0"
+    "@types/command-line-args" "^5.0.0"
+    "@types/command-line-usage" "^5.0.1"
+    "@types/debounce" "^1.2.0"
+    "@types/koa" "^2.0.48"
+    "@types/koa-compress" "^2.0.9"
+    "@types/koa-etag" "^3.0.0"
+    "@types/koa-static" "^4.0.1"
+    "@types/koa__cors" "^3.0.1"
+    "@types/lru-cache" "^5.1.0"
+    "@types/minimatch" "^3.0.3"
+    "@types/path-is-inside" "^1.0.0"
+    "@types/whatwg-url" "^6.4.0"
+    browserslist "^4.9.1"
+    browserslist-useragent "^3.0.2"
+    builtin-modules "^3.1.0"
+    camelcase "^5.3.1"
+    caniuse-api "^3.0.0"
+    caniuse-lite "^1.0.30001033"
+    chokidar "^3.0.0"
+    command-line-args "^5.0.2"
+    command-line-usage "^6.1.0"
+    debounce "^1.2.0"
+    deepmerge "^4.2.2"
+    es-module-lexer "^0.3.13"
+    get-stream "^5.1.0"
+    is-stream "^2.0.0"
+    isbinaryfile "^4.0.2"
+    koa "^2.7.0"
+    koa-compress "^3.0.0"
+    koa-etag "^3.0.0"
+    koa-static "^5.0.0"
+    lru-cache "^5.1.1"
+    mime-types "^2.1.27"
+    minimatch "^3.0.4"
+    open "^7.0.3"
+    parse5 "^5.1.1"
+    path-is-inside "^1.0.2"
+    polyfills-loader "^1.6.1"
+    portfinder "^1.0.21"
+    rollup "^2.7.2"
+    strip-ansi "^5.2.0"
+    systemjs "^6.3.1"
+    tslib "^1.11.1"
+    useragent "^2.3.0"
+    whatwg-url "^7.0.0"
+
+es-module-lexer@^0.3.13:
+  version "0.3.24"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.3.24.tgz#e6b2900758e9e210d23aec2092efc13ca235adea"
+  integrity sha512-jm/i7KdJtaMDle921xIsA/MQQOGuZ6goYxhlV+k+gQNI7FtP4N6jknrmJvj++3ODpiyFGwQ4PIstJfHJQJNc+g==
+
+es-module-shims@^0.4.6:
+  version "0.4.7"
+  resolved "https://registry.yarnpkg.com/es-module-shims/-/es-module-shims-0.4.7.tgz#1419b65bbd38dfe91ab8ea5d7b4b454561e44641"
+  integrity sha512-0LTiSQoPWwdcaTVIQXhGlaDwTneD0g9/tnH1PNs3zHFFH+xoCeJclDM3rQeqF9nurXPfMKm3l9+kfPRa5VpbKg==
 
 es-to-primitive@^1.2.1:
   version "1.2.1"
@@ -2474,52 +2086,37 @@
     is-date-object "^1.0.1"
     is-symbol "^1.0.2"
 
-es6-promise@^4.0.3, es6-promise@^4.0.5:
-  version "4.2.8"
-  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
-  integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
-
-es6-promisify@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
-  integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
-  dependencies:
-    es6-promise "^4.0.3"
-
-es6-promisify@^6.0.0:
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-6.0.2.tgz#525c23725b8510f5f1f2feb5a1fbad93a93e29b4"
-  integrity sha512-eO6vFm0JvqGzjWIQA6QVKjxpmELfhWbDUWHm1rPfIbn55mhKPiAa5xpLmQWJrNa629ZIeQ8ZvMAi13kvrjK6Mg==
+escalade@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.0.1.tgz#52568a77443f6927cd0ab9c73129137533c965ed"
+  integrity sha512-DR6NO3h9niOT+MZs7bjxlj2a1k+POu5RN8CLTPX2+i78bRi9eLe7+0zXgUHMnGXWybYcL61E9hGhPKqedy8tQA==
 
 escape-html@^1.0.3, escape-html@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
   integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
 
-escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.4, escape-string-regexp@^1.0.5:
+escape-string-regexp@1.0.5, escape-string-regexp@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
   integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
 
-espree@^3.5.2:
-  version "3.5.4"
-  resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.4.tgz#b0f447187c8a8bed944b815a660bddf5deb5d1a7"
-  integrity sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==
-  dependencies:
-    acorn "^5.5.0"
-    acorn-jsx "^3.0.0"
-
 esprima@^4.0.0:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
   integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
 
+estree-walker@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
+  integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==
+
 esutils@^2.0.2:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
   integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
 
-etag@~1.8.1:
+etag@^1.3.0:
   version "1.8.1"
   resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
   integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
@@ -2529,130 +2126,11 @@
   resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb"
   integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==
 
-execa@^0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
-  integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=
-  dependencies:
-    cross-spawn "^5.0.1"
-    get-stream "^3.0.0"
-    is-stream "^1.1.0"
-    npm-run-path "^2.0.0"
-    p-finally "^1.0.0"
-    signal-exit "^3.0.0"
-    strip-eof "^1.0.0"
-
-expand-brackets@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
-  integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=
-  dependencies:
-    is-posix-bracket "^0.1.0"
-
-expand-brackets@^2.1.4:
-  version "2.1.4"
-  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622"
-  integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI=
-  dependencies:
-    debug "^2.3.3"
-    define-property "^0.2.5"
-    extend-shallow "^2.0.1"
-    posix-character-classes "^0.1.0"
-    regex-not "^1.0.0"
-    snapdragon "^0.8.1"
-    to-regex "^3.0.1"
-
-expand-range@^1.8.1:
-  version "1.8.2"
-  resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337"
-  integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=
-  dependencies:
-    fill-range "^2.1.0"
-
-expand-tilde@^2.0.0, expand-tilde@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
-  integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=
-  dependencies:
-    homedir-polyfill "^1.0.1"
-
-express@^4.15.3, express@^4.8.5:
-  version "4.17.1"
-  resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
-  integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
-  dependencies:
-    accepts "~1.3.7"
-    array-flatten "1.1.1"
-    body-parser "1.19.0"
-    content-disposition "0.5.3"
-    content-type "~1.0.4"
-    cookie "0.4.0"
-    cookie-signature "1.0.6"
-    debug "2.6.9"
-    depd "~1.1.2"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    finalhandler "~1.1.2"
-    fresh "0.5.2"
-    merge-descriptors "1.0.1"
-    methods "~1.1.2"
-    on-finished "~2.3.0"
-    parseurl "~1.3.3"
-    path-to-regexp "0.1.7"
-    proxy-addr "~2.0.5"
-    qs "6.7.0"
-    range-parser "~1.2.1"
-    safe-buffer "5.1.2"
-    send "0.17.1"
-    serve-static "1.14.1"
-    setprototypeof "1.1.1"
-    statuses "~1.5.0"
-    type-is "~1.6.18"
-    utils-merge "1.0.1"
-    vary "~1.1.2"
-
-extend-shallow@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
-  integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=
-  dependencies:
-    is-extendable "^0.1.0"
-
-extend-shallow@^3.0.0, extend-shallow@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8"
-  integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=
-  dependencies:
-    assign-symbols "^1.0.0"
-    is-extendable "^1.0.1"
-
 extend@^3.0.0, extend@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
   integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
 
-extglob@^0.3.1:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
-  integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=
-  dependencies:
-    is-extglob "^1.0.0"
-
-extglob@^2.0.4:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543"
-  integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==
-  dependencies:
-    array-unique "^0.3.2"
-    define-property "^1.0.0"
-    expand-brackets "^2.1.4"
-    extend-shallow "^2.0.1"
-    fragment-cache "^0.2.1"
-    regex-not "^1.0.0"
-    snapdragon "^0.8.1"
-    to-regex "^3.0.1"
-
 extsprintf@1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
@@ -2664,59 +2142,23 @@
   integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
 
 fast-deep-equal@^3.1.1:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4"
-  integrity sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
+  integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
 
 fast-json-stable-stringify@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
   integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
 
-fast-safe-stringify@^2.0.4:
-  version "2.0.7"
-  resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743"
-  integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==
-
-fd-slicer@~1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
-  integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=
+fill-range@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
+  integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
   dependencies:
-    pend "~1.2.0"
+    to-regex-range "^5.0.1"
 
-fecha@^2.3.3:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/fecha/-/fecha-2.3.3.tgz#948e74157df1a32fd1b12c3a3c3cdcb6ec9d96cd"
-  integrity sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==
-
-filename-regex@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
-  integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=
-
-fill-range@^2.1.0:
-  version "2.2.4"
-  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565"
-  integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==
-  dependencies:
-    is-number "^2.1.0"
-    isobject "^2.0.0"
-    randomatic "^3.0.0"
-    repeat-element "^1.1.2"
-    repeat-string "^1.5.2"
-
-fill-range@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
-  integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=
-  dependencies:
-    extend-shallow "^2.0.1"
-    is-number "^3.0.0"
-    repeat-string "^1.6.1"
-    to-regex-range "^2.1.0"
-
-finalhandler@~1.1.2:
+finalhandler@1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
   integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
@@ -2729,13 +2171,6 @@
     statuses "~1.5.0"
     unpipe "~1.0.0"
 
-find-port@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/find-port/-/find-port-1.0.1.tgz#db084a6cbf99564d99869ae79fbdecf66e8a185c"
-  integrity sha1-2whKbL+ZVk2Zhprnn73s9m6KGFw=
-  dependencies:
-    async "~0.2.9"
-
 find-replace@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
@@ -2750,29 +2185,6 @@
   dependencies:
     locate-path "^3.0.0"
 
-find-up@^1.0.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
-  integrity sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=
-  dependencies:
-    path-exists "^2.0.0"
-    pinkie-promise "^2.0.0"
-
-findup-sync@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc"
-  integrity sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw=
-  dependencies:
-    detect-file "^1.0.0"
-    is-glob "^3.1.0"
-    micromatch "^3.0.4"
-    resolve-dir "^1.0.1"
-
-first-chunk-stream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz#59bfb50cd905f60d7c394cd3d9acaab4e6ad934e"
-  integrity sha1-Wb+1DNkF9g18OUzT2ayqtOatk04=
-
 flat@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/flat/-/flat-4.1.0.tgz#090bec8b05e39cba309747f1d588f04dbaf98db2"
@@ -2780,6 +2192,11 @@
   dependencies:
     is-buffer "~2.0.3"
 
+flatted@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138"
+  integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==
+
 follow-redirects@^1.0.0:
   version "1.9.0"
   resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.9.0.tgz#8d5bcdc65b7108fe1508649c79c12d732dcedb4f"
@@ -2787,28 +2204,11 @@
   dependencies:
     debug "^3.0.0"
 
-for-in@^1.0.1, for-in@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
-  integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=
-
-for-own@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce"
-  integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=
-  dependencies:
-    for-in "^1.0.1"
-
 forever-agent@~0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
   integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
 
-fork-stream@^0.0.4:
-  version "0.0.4"
-  resolved "https://registry.yarnpkg.com/fork-stream/-/fork-stream-0.0.4.tgz#db849fce77f6708a5f8f386ae533a0907b54ae70"
-  integrity sha1-24Sfznf2cIpfjzhq5TOgkHtUrnA=
-
 form-data@~2.3.2:
   version "2.3.3"
   resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
@@ -2818,52 +2218,35 @@
     combined-stream "^1.0.6"
     mime-types "^2.1.12"
 
-formatio@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.1.1.tgz#5ed3ccd636551097383465d996199100e86161e9"
-  integrity sha1-XtPM1jZVEJc4NGXZlhmRAOhhYek=
-  dependencies:
-    samsam "~1.1"
-
-formatio@1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.2.0.tgz#f3b2167d9068c4698a8d51f4f760a39a54d818eb"
-  integrity sha1-87IWfZBoxGmKjVH092CjmlTYGOs=
-  dependencies:
-    samsam "1.x"
-
-forwarded@~0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
-  integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=
-
-fragment-cache@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
-  integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=
-  dependencies:
-    map-cache "^0.2.2"
-
-freeport@^1.0.4:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/freeport/-/freeport-1.0.5.tgz#255e8ab84170c33ba85d990e821ae5f4a1a9bc5d"
-  integrity sha1-JV6KuEFwwzuoXZkOghrl9KGpvF0=
-
-fresh@0.5.2:
+fresh@~0.5.2:
   version "0.5.2"
   resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
   integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
 
-fs-constants@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
-  integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+fs-extra@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9"
+  integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==
+  dependencies:
+    graceful-fs "^4.1.2"
+    jsonfile "^4.0.0"
+    universalify "^0.1.0"
 
 fs.realpath@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
+fsevents@~2.1.1:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
+  integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
+
+fsevents@~2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805"
+  integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA==
+
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -2884,20 +2267,12 @@
   resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
   integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=
 
-get-stdin@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
-  integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=
-
-get-stream@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
-  integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=
-
-get-value@^2.0.3, get-value@^2.0.6:
-  version "2.0.6"
-  resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
-  integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=
+get-stream@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.1.0.tgz#01203cdc92597f9b909067c3e656cc1f4d3c4dc9"
+  integrity sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==
+  dependencies:
+    pump "^3.0.0"
 
 getpass@^0.1.1:
   version "0.1.7"
@@ -2906,54 +2281,12 @@
   dependencies:
     assert-plus "^1.0.0"
 
-glob-base@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
-  integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=
+glob-parent@~5.1.0:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229"
+  integrity sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==
   dependencies:
-    glob-parent "^2.0.0"
-    is-glob "^2.0.0"
-
-glob-parent@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28"
-  integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=
-  dependencies:
-    is-glob "^2.0.0"
-
-glob-parent@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
-  integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=
-  dependencies:
-    is-glob "^3.1.0"
-    path-dirname "^1.0.0"
-
-glob-stream@^5.3.2:
-  version "5.3.5"
-  resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-5.3.5.tgz#a55665a9a8ccdc41915a87c701e32d4e016fad22"
-  integrity sha1-pVZlqajM3EGRWofHAeMtTgFvrSI=
-  dependencies:
-    extend "^3.0.0"
-    glob "^5.0.3"
-    glob-parent "^3.0.0"
-    micromatch "^2.3.7"
-    ordered-read-streams "^0.3.0"
-    through2 "^0.6.0"
-    to-absolute-glob "^0.1.1"
-    unique-stream "^2.0.2"
-
-glob@7.1.1:
-  version "7.1.1"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8"
-  integrity sha1-gFIR3wT6rxxjo2ADBs31reULLsg=
-  dependencies:
-    fs.realpath "^1.0.0"
-    inflight "^1.0.4"
-    inherits "2"
-    minimatch "^3.0.2"
-    once "^1.3.0"
-    path-is-absolute "^1.0.0"
+    is-glob "^4.0.1"
 
 glob@7.1.3:
   version "7.1.3"
@@ -2967,18 +2300,7 @@
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-glob@^5.0.3:
-  version "5.0.15"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
-  integrity sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=
-  dependencies:
-    inflight "^1.0.4"
-    inherits "2"
-    minimatch "2 || 3"
-    once "^1.3.0"
-    path-is-absolute "^1.0.0"
-
-glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4:
+glob@^7.1.1, glob@^7.1.3:
   version "7.1.6"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
   integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
@@ -2990,118 +2312,27 @@
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-global-dirs@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"
-  integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=
-  dependencies:
-    ini "^1.3.4"
-
-global-modules@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
-  integrity sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==
-  dependencies:
-    global-prefix "^1.0.1"
-    is-windows "^1.0.1"
-    resolve-dir "^1.0.0"
-
-global-prefix@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe"
-  integrity sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=
-  dependencies:
-    expand-tilde "^2.0.2"
-    homedir-polyfill "^1.0.1"
-    ini "^1.3.4"
-    is-windows "^1.0.1"
-    which "^1.2.14"
-
 globals@^11.1.0:
   version "11.12.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
   integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
 
-globals@^9.18.0:
-  version "9.18.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
-  integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==
-
-got@^6.7.1:
-  version "6.7.1"
-  resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0"
-  integrity sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=
-  dependencies:
-    create-error-class "^3.0.0"
-    duplexer3 "^0.1.4"
-    get-stream "^3.0.0"
-    is-redirect "^1.0.0"
-    is-retry-allowed "^1.0.0"
-    is-stream "^1.0.0"
-    lowercase-keys "^1.0.0"
-    safe-buffer "^5.0.1"
-    timed-out "^4.0.0"
-    unzip-response "^2.0.1"
-    url-parse-lax "^1.0.0"
-
-graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.2.0:
+graceful-fs@^4.1.2, graceful-fs@^4.1.6:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423"
   integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==
 
-"graceful-readlink@>= 1.0.0":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725"
-  integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=
-
 growl@1.10.5:
   version "1.10.5"
   resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e"
   integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==
 
-growl@1.9.2:
-  version "1.9.2"
-  resolved "https://registry.yarnpkg.com/growl/-/growl-1.9.2.tgz#0ea7743715db8d8de2c5ede1775e1b45ac85c02f"
-  integrity sha1-Dqd0NxXbjY3ixe3hd14bRayFwC8=
-
-gulp-if@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/gulp-if/-/gulp-if-2.0.2.tgz#a497b7e7573005041caa2bc8b7dda3c80444d629"
-  integrity sha1-pJe351cwBQQcqivIt92jyARE1ik=
-  dependencies:
-    gulp-match "^1.0.3"
-    ternary-stream "^2.0.1"
-    through2 "^2.0.1"
-
-gulp-match@^1.0.3:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/gulp-match/-/gulp-match-1.1.0.tgz#552b7080fc006ee752c90563f9fec9d61aafdf4f"
-  integrity sha512-DlyVxa1Gj24DitY2OjEsS+X6tDpretuxD6wTfhXE/Rw2hweqc1f6D/XtsJmoiCwLWfXgR87W9ozEityPCVzGtQ==
-  dependencies:
-    minimatch "^3.0.3"
-
-gulp-sourcemaps@1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/gulp-sourcemaps/-/gulp-sourcemaps-1.6.0.tgz#b86ff349d801ceb56e1d9e7dc7bbcb4b7dee600c"
-  integrity sha1-uG/zSdgBzrVuHZ59x7vLS33uYAw=
-  dependencies:
-    convert-source-map "^1.1.1"
-    graceful-fs "^4.1.2"
-    strip-bom "^2.0.0"
-    through2 "^2.0.0"
-    vinyl "^1.0.0"
-
-handle-thing@^1.2.5:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4"
-  integrity sha1-/Xqtcmvxpf0W38KbL3pmAdJxOcQ=
-
 har-schema@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
   integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
 
-har-validator@~5.1.0:
+har-validator@~5.1.3:
   version "5.1.3"
   resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080"
   integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==
@@ -3109,13 +2340,6 @@
     ajv "^6.5.5"
     har-schema "^2.0.0"
 
-has-ansi@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
-  integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=
-  dependencies:
-    ansi-regex "^2.0.0"
-
 has-binary2@~1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
@@ -3123,62 +2347,26 @@
   dependencies:
     isarray "2.0.1"
 
-has-color@~0.1.0:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/has-color/-/has-color-0.1.7.tgz#67144a5260c34fc3cca677d041daf52fe7b78b2f"
-  integrity sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=
-
 has-cors@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
   integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=
 
-has-flag@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa"
-  integrity sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=
-
 has-flag@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
   integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
 
+has-flag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+  integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
+
 has-symbols@^1.0.0, has-symbols@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
   integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
 
-has-value@^0.3.1:
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
-  integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=
-  dependencies:
-    get-value "^2.0.3"
-    has-values "^0.1.4"
-    isobject "^2.0.0"
-
-has-value@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177"
-  integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=
-  dependencies:
-    get-value "^2.0.6"
-    has-values "^1.0.0"
-    isobject "^3.0.0"
-
-has-values@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771"
-  integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E=
-
-has-values@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f"
-  integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=
-  dependencies:
-    is-number "^3.0.0"
-    kind-of "^4.0.0"
-
 has@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
@@ -3186,55 +2374,36 @@
   dependencies:
     function-bind "^1.1.1"
 
-he@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
-  integrity sha1-k0EP0hsAlzUVH4howvJx80J+I/0=
-
-he@1.2.0, he@1.2.x:
+he@1.2.0, he@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
   integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
 
-homedir-polyfill@^1.0.1:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
-  integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==
-  dependencies:
-    parse-passwd "^1.0.0"
-
 hosted-git-info@^2.1.4:
-  version "2.8.5"
-  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.5.tgz#759cfcf2c4d156ade59b0b2dfabddc42a6b9c70c"
-  integrity sha512-kssjab8CvdXfcXMXVcvsXum4Hwdq9XGtRD3TteMEvEbq0LXyiNQr6AprqKqfeaDXze7SxWvRxdpwE6ku7ikLkg==
+  version "2.8.8"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
+  integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==
 
-hpack.js@^2.1.6:
-  version "2.1.6"
-  resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2"
-  integrity sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=
+html-minifier@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-4.0.0.tgz#cca9aad8bce1175e02e17a8c33e46d8988889f56"
+  integrity sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==
   dependencies:
-    inherits "^2.0.1"
-    obuf "^1.0.0"
-    readable-stream "^2.0.1"
-    wbuf "^1.1.0"
+    camel-case "^3.0.0"
+    clean-css "^4.2.1"
+    commander "^2.19.0"
+    he "^1.2.0"
+    param-case "^2.1.1"
+    relateurl "^0.2.7"
+    uglify-js "^3.5.1"
 
-html-minifier@^3.5.10:
-  version "3.5.21"
-  resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.5.21.tgz#d0040e054730e354db008463593194015212d20c"
-  integrity sha512-LKUKwuJDhxNa3uf/LPR/KVjm/l3rBqtYeCOAekvG8F1vItxMUpueGd94i/asDDr8/1u7InxzFA5EeGjhhG5mMA==
+http-assert@^1.3.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.4.1.tgz#c5f725d677aa7e873ef736199b89686cceb37878"
+  integrity sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw==
   dependencies:
-    camel-case "3.0.x"
-    clean-css "4.2.x"
-    commander "2.17.x"
-    he "1.2.x"
-    param-case "2.1.x"
-    relateurl "0.2.x"
-    uglify-js "3.4.x"
-
-http-deceiver@^1.2.7:
-  version "1.2.7"
-  resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
-  integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=
+    deep-equal "~1.0.1"
+    http-errors "~1.7.2"
 
 http-errors@1.7.2:
   version "1.7.2"
@@ -3247,6 +2416,17 @@
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
+http-errors@^1.6.3:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.0.tgz#75d1bbe497e1044f51e4ee9e704a62f28d336507"
+  integrity sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.0"
+
 http-errors@~1.6.2:
   version "1.6.3"
   resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
@@ -3268,17 +2448,7 @@
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
-http-proxy-middleware@^0.17.2:
-  version "0.17.4"
-  resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833"
-  integrity sha1-ZC6ISIUdZvCdTxJJEoRtuutBuDM=
-  dependencies:
-    http-proxy "^1.16.2"
-    is-glob "^3.1.0"
-    lodash "^4.17.2"
-    micromatch "^2.3.11"
-
-http-proxy@^1.16.2:
+http-proxy@^1.13.0:
   version "1.18.0"
   resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a"
   integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==
@@ -3296,22 +2466,6 @@
     jsprim "^1.2.2"
     sshpk "^1.7.0"
 
-https-proxy-agent@^2.2.1:
-  version "2.2.4"
-  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b"
-  integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==
-  dependencies:
-    agent-base "^4.3.0"
-    debug "^3.1.0"
-
-https-proxy-agent@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81"
-  integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==
-  dependencies:
-    agent-base "^4.3.0"
-    debug "^3.1.0"
-
 iconv-lite@0.4.24:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -3319,33 +2473,6 @@
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-ieee754@^1.1.4:
-  version "1.1.13"
-  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
-  integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
-
-import-lazy@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
-  integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=
-
-imurmurhash@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
-  integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
-
-indent-string@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80"
-  integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=
-  dependencies:
-    repeating "^2.0.0"
-
-indent@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/indent/-/indent-0.0.2.tgz#8c79f080190559b687034b84c7aefa97d5a911d9"
-  integrity sha1-jHnwgBkFWbaHA0uEx676l9WpEdk=
-
 indexof@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
@@ -3359,7 +2486,7 @@
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3:
+inherits@2, inherits@2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -3369,152 +2496,55 @@
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
   integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
 
-ini@^1.3.4, ini@~1.3.0:
-  version "1.3.5"
-  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
-  integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
+intersection-observer@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.7.0.tgz#ee16bee978db53516ead2f0a8154b09b400bbdc9"
+  integrity sha512-Id0Fij0HsB/vKWGeBe9PxeY45ttRiBmhFyyt/geBdDHBYNctMRTE3dC1U3ujzz3lap+hVXlEcVaB56kZP/eEUg==
 
-invariant@^2.2.2:
+invariant@^2.2.2, invariant@^2.2.4:
   version "2.2.4"
   resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
   integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
   dependencies:
     loose-envify "^1.0.0"
 
-ipaddr.js@1.9.0:
-  version "1.9.0"
-  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65"
-  integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==
-
-is-accessor-descriptor@^0.1.6:
-  version "0.1.6"
-  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
-  integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=
-  dependencies:
-    kind-of "^3.0.2"
-
-is-accessor-descriptor@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656"
-  integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==
-  dependencies:
-    kind-of "^6.0.0"
-
-is-arguments@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3"
-  integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==
-
 is-arrayish@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
   integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
 
-is-arrayish@^0.3.1:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
-  integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
-
-is-buffer@^1.1.5, is-buffer@~1.1.1:
-  version "1.1.6"
-  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
-  integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
+is-binary-path@~2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+  integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+  dependencies:
+    binary-extensions "^2.0.0"
 
 is-buffer@~2.0.3:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623"
   integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==
 
-is-callable@^1.1.4, is-callable@^1.1.5:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab"
-  integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==
-
-is-ci@^1.0.10:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c"
-  integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==
-  dependencies:
-    ci-info "^1.5.0"
-
-is-data-descriptor@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
-  integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=
-  dependencies:
-    kind-of "^3.0.2"
-
-is-data-descriptor@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7"
-  integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==
-  dependencies:
-    kind-of "^6.0.0"
+is-callable@^1.1.4, is-callable@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb"
+  integrity sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==
 
 is-date-object@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e"
   integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==
 
-is-descriptor@^0.1.0:
-  version "0.1.6"
-  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca"
-  integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==
-  dependencies:
-    is-accessor-descriptor "^0.1.6"
-    is-data-descriptor "^0.1.4"
-    kind-of "^5.0.0"
+is-docker@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.0.0.tgz#2cb0df0e75e2d064fe1864c37cdeacb7b2dcf25b"
+  integrity sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ==
 
-is-descriptor@^1.0.0, is-descriptor@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec"
-  integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==
-  dependencies:
-    is-accessor-descriptor "^1.0.0"
-    is-data-descriptor "^1.0.0"
-    kind-of "^6.0.2"
-
-is-dotfile@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
-  integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=
-
-is-equal-shallow@^0.1.3:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534"
-  integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=
-  dependencies:
-    is-primitive "^2.0.0"
-
-is-extendable@^0.1.0, is-extendable@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
-  integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=
-
-is-extendable@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4"
-  integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==
-  dependencies:
-    is-plain-object "^2.0.4"
-
-is-extglob@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"
-  integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=
-
-is-extglob@^2.1.0:
+is-extglob@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
   integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
 
-is-finite@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa"
-  integrity sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=
-  dependencies:
-    number-is-nan "^1.0.0"
-
 is-fullwidth-code-point@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
@@ -3525,102 +2555,34 @@
   resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.7.tgz#d2132e529bb0000a7f80794d4bdf5cd5e5813522"
   integrity sha512-YZc5EwyO4f2kWCax7oegfuSr9mFz1ZvieNYBEjmukLxgXfBUbxAWGVF7GZf0zidYtoBl3WvC07YK0wT76a+Rtw==
 
-is-glob@^2.0.0, is-glob@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
-  integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=
+is-glob@^4.0.1, is-glob@~4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
+  integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
   dependencies:
-    is-extglob "^1.0.0"
+    is-extglob "^2.1.1"
 
-is-glob@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
-  integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=
-  dependencies:
-    is-extglob "^2.1.0"
-
-is-installed-globally@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80"
-  integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=
-  dependencies:
-    global-dirs "^0.1.0"
-    is-path-inside "^1.0.0"
-
-is-npm@^1.0.0:
+is-module@^1.0.0:
   version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
-  integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ=
+  resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
+  integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=
 
-is-number@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
-  integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=
-  dependencies:
-    kind-of "^3.0.2"
+is-number@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
 
-is-number@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
-  integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=
-  dependencies:
-    kind-of "^3.0.2"
-
-is-number@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
-  integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==
-
-is-obj@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
-  integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=
-
-is-path-inside@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036"
-  integrity sha1-jvW33lBDej/cprToZe96pVy0gDY=
-  dependencies:
-    path-is-inside "^1.0.1"
-
-is-plain-object@^2.0.3, is-plain-object@^2.0.4:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
-  integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==
-  dependencies:
-    isobject "^3.0.1"
-
-is-posix-bracket@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"
-  integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=
-
-is-primitive@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
-  integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU=
-
-is-redirect@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
-  integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=
-
-is-regex@^1.0.5:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae"
-  integrity sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==
-  dependencies:
-    has "^1.0.3"
-
-is-retry-allowed@^1.0.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4"
-  integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==
-
-is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0:
+is-regex@^1.1.0:
   version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
-  integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
+  resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.0.tgz#ece38e389e490df0dc21caea2bd596f987f767ff"
+  integrity sha512-iI97M8KTWID2la5uYXlkbSDQIg4F6o1sYboZKKTDpnDQMLtUL86zxhgDet3Q2SriaYsyGqZ6Mn2SjbRKeLHdqw==
+  dependencies:
+    has-symbols "^1.0.1"
+
+is-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3"
+  integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==
 
 is-symbol@^1.0.2:
   version "1.0.3"
@@ -3634,68 +2596,68 @@
   resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
   integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
 
-is-utf8@^0.2.0:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
-  integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=
-
-is-valid-glob@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/is-valid-glob/-/is-valid-glob-0.3.0.tgz#d4b55c69f51886f9b65c70d6c2622d37e29f48fe"
-  integrity sha1-1LVcafUYhvm2XHDWwmItN+KfSP4=
-
-is-windows@^1.0.1, is-windows@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
-  integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==
+is-wsl@^2.1.1:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
+  integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
+  dependencies:
+    is-docker "^2.0.0"
 
 isarray@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
   integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
 
-isarray@1.0.0, isarray@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
-  integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
-
 isarray@2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
   integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
 
+isbinaryfile@^3.0.0:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-3.0.3.tgz#5d6def3edebf6e8ca8cae9c30183a804b5f8be80"
+  integrity sha512-8cJBL5tTd2OS0dM4jz07wQd5g0dCCqIhUxPIGtZfa5L6hWlvV5MHTITy/DBAsF+Oe2LS1X3krBUhNwaGUWpWxw==
+  dependencies:
+    buffer-alloc "^1.2.0"
+
+isbinaryfile@^4.0.2:
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.6.tgz#edcb62b224e2b4710830b67498c8e4e5a4d2610b"
+  integrity sha512-ORrEy+SNVqUhrCaal4hA4fBzhggQQ+BaLntyPOdoEiwlKZW9BZiJXjg3RMiruE4tPEI3pyVPpySHQF/dKWperg==
+
 isexe@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
   integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
 
-isobject@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
-  integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=
-  dependencies:
-    isarray "1.0.0"
-
-isobject@^3.0.0, isobject@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
-  integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
-
 isstream@~0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
   integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
 
+istanbul-lib-coverage@^2.0.5:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz#675f0ab69503fad4b1d849f736baaca803344f49"
+  integrity sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==
+
+istanbul-lib-instrument@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-3.3.0.tgz#a5f63d91f0bbc0c3e479ef4c5de027335ec6d630"
+  integrity sha512-5nnIN4vo5xQZHdXno/YDXJ0G+I3dAm4XgzfSVTPLQpj/zAV2dV6Juy0yaf10/zrJOJeHoN3fraFe+XRq2bFVZA==
+  dependencies:
+    "@babel/generator" "^7.4.0"
+    "@babel/parser" "^7.4.3"
+    "@babel/template" "^7.4.0"
+    "@babel/traverse" "^7.4.3"
+    "@babel/types" "^7.4.0"
+    istanbul-lib-coverage "^2.0.5"
+    semver "^6.0.0"
+
 "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
   integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
 
-js-tokens@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
-  integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls=
-
 js-yaml@3.13.1:
   version "3.13.1"
   resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
@@ -3709,11 +2671,6 @@
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
   integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
 
-jsesc@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
-  integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s=
-
 jsesc@^2.5.1:
   version "2.5.2"
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
@@ -3724,6 +2681,11 @@
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
   integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
 
+json-parse-better-errors@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
+  integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==
+
 json-schema-traverse@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
@@ -3734,32 +2696,24 @@
   resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
   integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
 
-json-stable-stringify-without-jsonify@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
-  integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
-
 json-stringify-safe@~5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
   integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
 
-json3@3.3.2:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1"
-  integrity sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=
-
-json5@^2.1.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.1.tgz#81b6cb04e9ba496f1c7005d07b4368a2638f90b6"
-  integrity sha512-l+3HXD0GEI3huGq1njuqtzYK8OYJyXMkOLtQ53pjWh89tvWS2h6l+1zMkYWqlb57+SiQodKZyvMEFb2X+KrFhQ==
+json5@^2.1.2:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43"
+  integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==
   dependencies:
-    minimist "^1.2.0"
+    minimist "^1.2.5"
 
-jsonschema@^1.1.0, jsonschema@^1.1.1:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.2.5.tgz#bab69d97fa28946aec0a56a9cc266d23fe80ae61"
-  integrity sha512-kVTF+08x25PQ0CjuVc0gRM9EUPb0Fe9Ln/utFOgcdxEIOHuU7ooBk/UPTd7t1M91pP35m0MU1T8M5P7vP1bRRw==
+jsonfile@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
+  integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=
+  optionalDependencies:
+    graceful-fs "^4.1.6"
 
 jsprim@^1.2.2:
   version "1.4.1"
@@ -3771,75 +2725,184 @@
     json-schema "0.2.3"
     verror "1.10.0"
 
-kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
-  version "3.2.2"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
-  integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=
-  dependencies:
-    is-buffer "^1.1.5"
+just-extend@^4.0.2:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.1.0.tgz#7278a4027d889601640ee0ce0e5a00b992467da4"
+  integrity sha512-ApcjaOdVTJ7y4r08xI5wIqpvwS48Q0PBG4DJROcEkH1f8MdAiNFyFxz3xoL0LWAVwjrwPYZdVHHxhRHcx/uGLA==
 
-kind-of@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57"
-  integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc=
-  dependencies:
-    is-buffer "^1.1.5"
-
-kind-of@^5.0.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
-  integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==
-
-kind-of@^6.0.0, kind-of@^6.0.2:
-  version "6.0.3"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
-  integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
-
-kuler@1.0.x:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/kuler/-/kuler-1.0.1.tgz#ef7c784f36c9fb6e16dd3150d152677b2b0228a6"
-  integrity sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ==
-  dependencies:
-    colornames "^1.1.1"
-
-latest-version@^3.0.0:
+karma-chrome-launcher@^3.1.0:
   version "3.1.0"
-  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"
-  integrity sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=
+  resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.1.0.tgz#805a586799a4d05f4e54f72a204979f3f3066738"
+  integrity sha512-3dPs/n7vgz1rxxtynpzZTvb9y/GIaW8xjAwcIGttLbycqoFtI7yo1NGnQi6oFTherRE+GIhCAHZC4vEqWGhNvg==
   dependencies:
-    package-json "^4.0.0"
+    which "^1.2.1"
 
-launchpad@^0.7.0:
-  version "0.7.5"
-  resolved "https://registry.yarnpkg.com/launchpad/-/launchpad-0.7.5.tgz#a16950c937572f10ef01c9be945a96f7aef8e427"
-  integrity sha512-gsYFgT8XKL3X2XZHPPPrgwM0JqeQwGpSWnzg7EYadBY3MirbQrTVq6L4fm6l7UE2T+7gnfuhiGkKr/xxuU/fdw==
+karma-mocha-reporter@^2.2.5:
+  version "2.2.5"
+  resolved "https://registry.yarnpkg.com/karma-mocha-reporter/-/karma-mocha-reporter-2.2.5.tgz#15120095e8ed819186e47a0b012f3cd741895560"
+  integrity sha1-FRIAlejtgZGG5HoLAS8810GJVWA=
   dependencies:
-    async "^2.0.1"
-    browserstack "^1.2.0"
-    debug "^2.2.0"
-    mkdirp "^0.5.1"
-    plist "^2.0.1"
-    q "^1.4.1"
-    rimraf "^3.0.0"
-    underscore "^1.8.3"
+    chalk "^2.1.0"
+    log-symbols "^2.1.0"
+    strip-ansi "^4.0.0"
 
-lazystream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4"
-  integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=
+karma-mocha@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/karma-mocha/-/karma-mocha-2.0.1.tgz#4b0254a18dfee71bdbe6188d9a6861bf86b0cd7d"
+  integrity sha512-Tzd5HBjm8his2OA4bouAsATYEpZrp9vC7z5E5j4C5Of5Rrs1jY67RAwXNcVmd/Bnk1wgvQRou0zGVLey44G4tQ==
   dependencies:
-    readable-stream "^2.0.5"
+    minimist "^1.2.3"
 
-load-json-file@^1.0.0:
+karma@^4.4.1:
+  version "4.4.1"
+  resolved "https://registry.yarnpkg.com/karma/-/karma-4.4.1.tgz#6d9aaab037a31136dc074002620ee11e8c2e32ab"
+  integrity sha512-L5SIaXEYqzrh6b1wqYC42tNsFMx2PWuxky84pK9coK09MvmL7mxii3G3bZBh/0rvD27lqDd0le9jyhzvwif73A==
+  dependencies:
+    bluebird "^3.3.0"
+    body-parser "^1.16.1"
+    braces "^3.0.2"
+    chokidar "^3.0.0"
+    colors "^1.1.0"
+    connect "^3.6.0"
+    di "^0.0.1"
+    dom-serialize "^2.2.0"
+    flatted "^2.0.0"
+    glob "^7.1.1"
+    graceful-fs "^4.1.2"
+    http-proxy "^1.13.0"
+    isbinaryfile "^3.0.0"
+    lodash "^4.17.14"
+    log4js "^4.0.0"
+    mime "^2.3.1"
+    minimatch "^3.0.2"
+    optimist "^0.6.1"
+    qjobs "^1.1.4"
+    range-parser "^1.2.0"
+    rimraf "^2.6.0"
+    safe-buffer "^5.0.1"
+    socket.io "2.1.1"
+    source-map "^0.6.1"
+    tmp "0.0.33"
+    useragent "2.3.0"
+
+keygrip@~1.1.0:
   version "1.1.0"
-  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
-  integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=
+  resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
+  integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==
+  dependencies:
+    tsscmp "1.0.6"
+
+koa-compose@^3.0.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-3.2.1.tgz#a85ccb40b7d986d8e5a345b3a1ace8eabcf54de7"
+  integrity sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=
+  dependencies:
+    any-promise "^1.1.0"
+
+koa-compose@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877"
+  integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==
+
+koa-compress@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/koa-compress/-/koa-compress-3.1.0.tgz#00fb0af695dc4661c6de261a18da669626ea3ca1"
+  integrity sha512-0m24/yS/GbhWI+g9FqtvStY+yJwTObwoxOvPok6itVjRen7PBWkjsJ8pre76m+99YybXLKhOJ62mJ268qyBFMQ==
+  dependencies:
+    bytes "^3.0.0"
+    compressible "^2.0.0"
+    koa-is-json "^1.0.0"
+    statuses "^1.0.0"
+
+koa-convert@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-1.2.0.tgz#da40875df49de0539098d1700b50820cebcd21d0"
+  integrity sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=
+  dependencies:
+    co "^4.6.0"
+    koa-compose "^3.0.0"
+
+koa-etag@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/koa-etag/-/koa-etag-3.0.0.tgz#9ef7382ddd5a82ab0deb153415c915836f771d3f"
+  integrity sha1-nvc4Ld1agqsN6xU0FckVg293HT8=
+  dependencies:
+    etag "^1.3.0"
+    mz "^2.1.0"
+
+koa-is-json@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/koa-is-json/-/koa-is-json-1.0.0.tgz#273c07edcdcb8df6a2c1ab7d59ee76491451ec14"
+  integrity sha1-JzwH7c3Ljfaiwat9We52SRRR7BQ=
+
+koa-send@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/koa-send/-/koa-send-5.0.0.tgz#5e8441e07ef55737734d7ced25b842e50646e7eb"
+  integrity sha512-90ZotV7t0p3uN9sRwW2D484rAaKIsD8tAVtypw/aBU+ryfV+fR2xrcAwhI8Wl6WRkojLUs/cB9SBSCuIb+IanQ==
+  dependencies:
+    debug "^3.1.0"
+    http-errors "^1.6.3"
+    mz "^2.7.0"
+    resolve-path "^1.4.0"
+
+koa-static@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/koa-static/-/koa-static-5.0.0.tgz#5e92fc96b537ad5219f425319c95b64772776943"
+  integrity sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==
+  dependencies:
+    debug "^3.1.0"
+    koa-send "^5.0.0"
+
+koa@^2.7.0:
+  version "2.13.0"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.0.tgz#25217e05efd3358a7e5ddec00f0a380c9b71b501"
+  integrity sha512-i/XJVOfPw7npbMv67+bOeXr3gPqOAw6uh5wFyNs3QvJ47tUx3M3V9rIE0//WytY42MKz4l/MXKyGkQ2LQTfLUQ==
+  dependencies:
+    accepts "^1.3.5"
+    cache-content-type "^1.0.0"
+    content-disposition "~0.5.2"
+    content-type "^1.0.4"
+    cookies "~0.8.0"
+    debug "~3.1.0"
+    delegates "^1.0.0"
+    depd "^1.1.2"
+    destroy "^1.0.4"
+    encodeurl "^1.0.2"
+    escape-html "^1.0.3"
+    fresh "~0.5.2"
+    http-assert "^1.3.0"
+    http-errors "^1.6.3"
+    is-generator-function "^1.0.7"
+    koa-compose "^4.1.0"
+    koa-convert "^1.2.0"
+    on-finished "^2.3.0"
+    only "~0.0.2"
+    parseurl "^1.3.2"
+    statuses "^1.5.0"
+    type-is "^1.6.16"
+    vary "^1.1.2"
+
+leven@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2"
+  integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==
+
+levenary@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/levenary/-/levenary-1.1.1.tgz#842a9ee98d2075aa7faeedbe32679e9205f46f77"
+  integrity sha512-mkAdOIt79FD6irqjYSs4rdbnlT5vRonMEvBVPVb3XmevfS8kgRXwfes0dhPdEtzTWD/1eNE/Bm/G1iRt6DcnQQ==
+  dependencies:
+    leven "^3.1.0"
+
+load-json-file@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b"
+  integrity sha1-L19Fq5HjMhYjT9U62rZo607AmTs=
   dependencies:
     graceful-fs "^4.1.2"
-    parse-json "^2.2.0"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-    strip-bom "^2.0.0"
+    parse-json "^4.0.0"
+    pify "^3.0.0"
+    strip-bom "^3.0.0"
 
 locate-path@^3.0.0:
   version "3.0.0"
@@ -3849,175 +2912,60 @@
     p-locate "^3.0.0"
     path-exists "^3.0.0"
 
-lodash._baseassign@^3.0.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz#8c38a099500f215ad09e59f1722fd0c52bfe0a4e"
-  integrity sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=
-  dependencies:
-    lodash._basecopy "^3.0.0"
-    lodash.keys "^3.0.0"
-
-lodash._basecopy@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36"
-  integrity sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=
-
-lodash._basecreate@^3.0.0:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/lodash._basecreate/-/lodash._basecreate-3.0.3.tgz#1bc661614daa7fc311b7d03bf16806a0213cf821"
-  integrity sha1-G8ZhYU2qf8MRt9A78WgGoCE8+CE=
-
-lodash._getnative@^3.0.0:
-  version "3.9.1"
-  resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5"
-  integrity sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=
-
-lodash._isiterateecall@^3.0.0:
-  version "3.0.9"
-  resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c"
-  integrity sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=
-
-lodash._reinterpolate@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
-  integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
-
 lodash.camelcase@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
   integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY=
 
-lodash.create@3.1.1:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/lodash.create/-/lodash.create-3.1.1.tgz#d7f2849f0dbda7e04682bb8cd72ab022461debe7"
-  integrity sha1-1/KEnw29p+BGgruM1yqwIkYd6+c=
-  dependencies:
-    lodash._baseassign "^3.0.0"
-    lodash._basecreate "^3.0.0"
-    lodash._isiterateecall "^3.0.0"
+lodash.get@^4.4.2:
+  version "4.4.2"
+  resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
+  integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
 
-lodash.defaults@^4.2.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
-  integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
-
-lodash.difference@^4.5.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c"
-  integrity sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw=
-
-lodash.flatten@^4.4.0:
-  version "4.4.0"
-  resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
-  integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
-
-lodash.isarguments@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
-  integrity sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=
-
-lodash.isarray@^3.0.0:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55"
-  integrity sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=
-
-lodash.isequal@^4.0.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
-  integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
-
-lodash.isplainobject@^4.0.6:
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
-  integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
-
-lodash.keys@^3.0.0:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a"
-  integrity sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=
-  dependencies:
-    lodash._getnative "^3.0.0"
-    lodash.isarguments "^3.0.0"
-    lodash.isarray "^3.0.0"
-
-lodash.padend@^4.6.1:
-  version "4.6.1"
-  resolved "https://registry.yarnpkg.com/lodash.padend/-/lodash.padend-4.6.1.tgz#53ccba047d06e158d311f45da625f4e49e6f166e"
-  integrity sha1-U8y6BH0G4VjTEfRdpiX05J5vFm4=
+lodash.memoize@^4.1.2:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
+  integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
 
 lodash.sortby@^4.7.0:
   version "4.7.0"
   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
   integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
 
-lodash.template@^4.4.0:
+lodash.uniq@^4.5.0:
   version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"
-  integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==
-  dependencies:
-    lodash._reinterpolate "^3.0.0"
-    lodash.templatesettings "^4.0.0"
+  resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
+  integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
 
-lodash.templatesettings@^4.0.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33"
-  integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==
-  dependencies:
-    lodash._reinterpolate "^3.0.0"
-
-lodash.union@^4.6.0:
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
-  integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=
-
-lodash@^3.0.0, lodash@^3.10.1:
-  version "3.10.1"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
-  integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=
-
-lodash@^4.0.0, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.2, lodash@^4.17.4:
+lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15:
   version "4.17.15"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
   integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
 
-log-symbols@2.2.0:
+log-symbols@3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4"
+  integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==
+  dependencies:
+    chalk "^2.4.2"
+
+log-symbols@^2.1.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
   integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==
   dependencies:
     chalk "^2.0.1"
 
-logform@^1.9.1:
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/logform/-/logform-1.10.0.tgz#c9d5598714c92b546e23f4e78147c40f1e02012e"
-  integrity sha512-em5ojIhU18fIMOw/333mD+ZLE2fis0EzXl1ZwHx4iQzmpQi6odNiY/t+ITNr33JZhT9/KEaH+UPIipr6a9EjWg==
+log4js@^4.0.0:
+  version "4.5.1"
+  resolved "https://registry.yarnpkg.com/log4js/-/log4js-4.5.1.tgz#e543625e97d9e6f3e6e7c9fc196dd6ab2cae30b5"
+  integrity sha512-EEEgFcE9bLgaYUKuozyFfytQM2wDHtXn4tAN41pkaxpNjAykv11GVdeI4tHtmPWW4Xrgh9R/2d7XYghDVjbKKw==
   dependencies:
-    colors "^1.2.1"
-    fast-safe-stringify "^2.0.4"
-    fecha "^2.3.3"
-    ms "^2.1.1"
-    triple-beam "^1.2.0"
-
-logform@^2.1.1:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/logform/-/logform-2.1.2.tgz#957155ebeb67a13164069825ce67ddb5bb2dd360"
-  integrity sha512-+lZh4OpERDBLqjiwDLpAWNQu6KMjnlXH2ByZwCuSqVPJletw0kTWJf5CgSNAUKn1KUkv3m2cUz/LK8zyEy7wzQ==
-  dependencies:
-    colors "^1.2.1"
-    fast-safe-stringify "^2.0.4"
-    fecha "^2.3.3"
-    ms "^2.1.1"
-    triple-beam "^1.3.0"
-
-lolex@1.3.2:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.3.2.tgz#7c3da62ffcb30f0f5a80a2566ca24e45d8a01f31"
-  integrity sha1-fD2mL/yzDw9agKJWbKJORdigHzE=
-
-lolex@^1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.6.0.tgz#3a9a0283452a47d7439e72731b9e07d7386e49f6"
-  integrity sha1-OpoCg0UqR9dDnnJzG54H1zhuSfY=
+    date-format "^2.0.0"
+    debug "^4.1.1"
+    flatted "^2.0.0"
+    rfdc "^1.1.4"
+    streamroller "^1.0.6"
 
 loose-envify@^1.0.0:
   version "1.4.0"
@@ -4026,25 +2974,12 @@
   dependencies:
     js-tokens "^3.0.0 || ^4.0.0"
 
-loud-rejection@^1.0.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f"
-  integrity sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=
-  dependencies:
-    currently-unhandled "^0.4.1"
-    signal-exit "^3.0.0"
-
 lower-case@^1.1.1:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac"
   integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw=
 
-lowercase-keys@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
-  integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==
-
-lru-cache@^4.0.1, lru-cache@^4.0.2:
+lru-cache@4.1.x:
   version "4.1.5"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
   integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
@@ -4052,235 +2987,79 @@
     pseudomap "^1.0.2"
     yallist "^2.1.2"
 
-magic-string@^0.22.4:
-  version "0.22.5"
-  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e"
-  integrity sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==
+lru-cache@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
+  integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==
   dependencies:
-    vlq "^0.2.2"
-
-make-dir@^1.0.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c"
-  integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==
-  dependencies:
-    pify "^3.0.0"
-
-map-cache@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
-  integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=
-
-map-obj@^1.0.0, map-obj@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
-  integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=
-
-map-visit@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f"
-  integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=
-  dependencies:
-    object-visit "^1.0.0"
-
-matcher@^1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/matcher/-/matcher-1.1.1.tgz#51d8301e138f840982b338b116bb0c09af62c1c2"
-  integrity sha512-+BmqxWIubKTRKNWx/ahnCkk3mG8m7OturVlqq6HiojGJTd5hVYbgZm6WzcYPCoB+KBT4Vd6R7WSRG2OADNaCjg==
-  dependencies:
-    escape-string-regexp "^1.0.4"
-
-math-random@^1.0.1:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c"
-  integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==
-
-md5@^2.2.1:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9"
-  integrity sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=
-  dependencies:
-    charenc "~0.0.1"
-    crypt "~0.0.1"
-    is-buffer "~1.1.1"
+    yallist "^3.0.2"
 
 media-typer@0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
   integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
 
-meow@^3.7.0:
-  version "3.7.0"
-  resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
-  integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=
-  dependencies:
-    camelcase-keys "^2.0.0"
-    decamelize "^1.1.2"
-    loud-rejection "^1.0.0"
-    map-obj "^1.0.1"
-    minimist "^1.1.3"
-    normalize-package-data "^2.3.4"
-    object-assign "^4.0.1"
-    read-pkg-up "^1.0.1"
-    redent "^1.0.0"
-    trim-newlines "^1.0.0"
-
-merge-descriptors@1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
-  integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
-
-merge-stream@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-1.0.1.tgz#4041202d508a342ba00174008df0c251b8c135e1"
-  integrity sha1-QEEgLVCKNCugAXQAjfDCUbjBNeE=
-  dependencies:
-    readable-stream "^2.0.1"
-
-methods@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
-  integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
-
-micromatch@^2.3.11, micromatch@^2.3.7:
-  version "2.3.11"
-  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565"
-  integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=
-  dependencies:
-    arr-diff "^2.0.0"
-    array-unique "^0.2.1"
-    braces "^1.8.2"
-    expand-brackets "^0.1.4"
-    extglob "^0.3.1"
-    filename-regex "^2.0.0"
-    is-extglob "^1.0.0"
-    is-glob "^2.0.1"
-    kind-of "^3.0.2"
-    normalize-path "^2.0.1"
-    object.omit "^2.0.0"
-    parse-glob "^3.0.4"
-    regex-cache "^0.4.2"
-
-micromatch@^3.0.4:
-  version "3.1.10"
-  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
-  integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==
-  dependencies:
-    arr-diff "^4.0.0"
-    array-unique "^0.3.2"
-    braces "^2.3.1"
-    define-property "^2.0.2"
-    extend-shallow "^3.0.2"
-    extglob "^2.0.4"
-    fragment-cache "^0.2.1"
-    kind-of "^6.0.2"
-    nanomatch "^1.2.9"
-    object.pick "^1.3.0"
-    regex-not "^1.0.0"
-    snapdragon "^0.8.1"
-    to-regex "^3.0.2"
-
-mime-db@1.43.0, "mime-db@>= 1.43.0 < 2":
+mime-db@1.43.0:
   version "1.43.0"
   resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.43.0.tgz#0a12e0502650e473d735535050e7c8f4eb4fae58"
   integrity sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==
 
-mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24:
+mime-db@1.44.0, "mime-db@>= 1.43.0 < 2":
+  version "1.44.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92"
+  integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==
+
+mime-types@^2.1.12, mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.19:
+  version "2.1.27"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f"
+  integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==
+  dependencies:
+    mime-db "1.44.0"
+
+mime-types@~2.1.24:
   version "2.1.26"
   resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.26.tgz#9c921fc09b7e149a65dfdc0da4d20997200b0a06"
   integrity sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==
   dependencies:
     mime-db "1.43.0"
 
-mime@1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
-  integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==
-
-mime@1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
-  integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
-
 mime@^2.3.1:
   version "2.4.4"
   resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5"
   integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==
 
-minimalistic-assert@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
-  integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
-
-minimatch-all@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/minimatch-all/-/minimatch-all-1.1.0.tgz#40c496a27a2e128d19bf758e76bb01a0c7145787"
-  integrity sha1-QMSWonouEo0Zv3WOdrsBoMcUV4c=
-  dependencies:
-    minimatch "^3.0.2"
-
-"minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4:
+minimatch@3.0.4, minimatch@^3.0.2, minimatch@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
   dependencies:
     brace-expansion "^1.1.7"
 
-minimist@0.0.8:
-  version "0.0.8"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
-  integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
-
-minimist@^1.1.3, minimist@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
-  integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=
+minimist@^1.2.3, minimist@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
+  integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
 minimist@~0.0.1:
   version "0.0.10"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
   integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=
 
-mixin-deep@^1.2.0:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
-  integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
+mkdirp@0.5.5, mkdirp@^0.5.1:
+  version "0.5.5"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
+  integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
   dependencies:
-    for-in "^1.0.2"
-    is-extendable "^1.0.1"
+    minimist "^1.2.5"
 
-mkdirp@0.5.1, mkdirp@^0.5.0, mkdirp@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
-  integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=
-  dependencies:
-    minimist "0.0.8"
-
-mocha@^3.4.2:
-  version "3.5.3"
-  resolved "https://registry.yarnpkg.com/mocha/-/mocha-3.5.3.tgz#1e0480fe36d2da5858d1eb6acc38418b26eaa20d"
-  integrity sha512-/6na001MJWEtYxHOV1WLfsmR4YIynkUEhBwzsb+fk2qmQ3iqsi258l/Q2MWHJMImAcNpZ8DEdYAK72NHoIQ9Eg==
-  dependencies:
-    browser-stdout "1.3.0"
-    commander "2.9.0"
-    debug "2.6.8"
-    diff "3.2.0"
-    escape-string-regexp "1.0.5"
-    glob "7.1.1"
-    growl "1.9.2"
-    he "1.1.1"
-    json3 "3.3.2"
-    lodash.create "3.1.1"
-    mkdirp "0.5.1"
-    supports-color "3.1.2"
-
-mocha@^6.2.2:
-  version "6.2.2"
-  resolved "https://registry.yarnpkg.com/mocha/-/mocha-6.2.2.tgz#5d8987e28940caf8957a7d7664b910dc5b2fea20"
-  integrity sha512-FgDS9Re79yU1xz5d+C4rv1G7QagNGHZ+iXF81hO8zY35YZZcLEsJVfFolfsqKFWunATEvNzMK0r/CwWd/szO9A==
+mocha@7.2.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.2.0.tgz#01cc227b00d875ab1eed03a75106689cfed5a604"
+  integrity sha512-O9CIypScywTVpNaRrCAgoUnJgozpIofjKUYmJhiCIJMiuYnLI6otcb1/kpW9/n/tJODHGZ7i8aLQoDVsMtOKQQ==
   dependencies:
     ansi-colors "3.2.3"
     browser-stdout "1.3.1"
+    chokidar "3.3.0"
     debug "3.2.6"
     diff "3.5.0"
     escape-string-regexp "1.0.5"
@@ -4289,25 +3068,20 @@
     growl "1.10.5"
     he "1.2.0"
     js-yaml "3.13.1"
-    log-symbols "2.2.0"
+    log-symbols "3.0.0"
     minimatch "3.0.4"
-    mkdirp "0.5.1"
+    mkdirp "0.5.5"
     ms "2.1.1"
-    node-environment-flags "1.0.5"
+    node-environment-flags "1.0.6"
     object.assign "4.1.0"
     strip-json-comments "2.0.1"
     supports-color "6.0.0"
     which "1.3.1"
     wide-align "1.1.3"
-    yargs "13.3.0"
-    yargs-parser "13.1.1"
+    yargs "13.3.2"
+    yargs-parser "13.1.2"
     yargs-unparser "1.6.0"
 
-mout@^1.0.0:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/mout/-/mout-1.2.2.tgz#c9b718a499806a0632cede178e80f436259e777d"
-  integrity sha512-w0OUxFEla6z3d7sVpMZGBCpQvYh8PHS1wZ6Wu9GNKHMpAHWJ0if0LsQZh3DlOqw55HlhJEOMLpFnwtxp99Y5GA==
-
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@@ -4323,29 +3097,7 @@
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
   integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
 
-multer@^1.3.0:
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.2.tgz#2f1f4d12dbaeeba74cb37e623f234bf4d3d2057a"
-  integrity sha512-xY8pX7V+ybyUpbYMxtjM9KAiD9ixtg5/JkeKUTD6xilfDv0vzzOFcCp4Ljb1UU3tSOM3VTZtKo63OmzOrGi3Cg==
-  dependencies:
-    append-field "^1.0.0"
-    busboy "^0.2.11"
-    concat-stream "^1.5.2"
-    mkdirp "^0.5.1"
-    object-assign "^4.1.1"
-    on-finished "^2.3.0"
-    type-is "^1.6.4"
-    xtend "^4.0.0"
-
-multipipe@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/multipipe/-/multipipe-1.0.2.tgz#cc13efd833c9cda99f224f868461b8e1a3fd939d"
-  integrity sha1-zBPv2DPJzamfIk+GhGG44aP9k50=
-  dependencies:
-    duplexer2 "^0.1.2"
-    object-assign "^4.1.0"
-
-mz@^2.4.0, mz@^2.6.0:
+mz@^2.1.0, mz@^2.7.0:
   version "2.7.0"
   resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
   integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
@@ -4354,37 +3106,21 @@
     object-assign "^4.0.1"
     thenify-all "^1.0.0"
 
-nanomatch@^1.2.9:
-  version "1.2.13"
-  resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
-  integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==
-  dependencies:
-    arr-diff "^4.0.0"
-    array-unique "^0.3.2"
-    define-property "^2.0.2"
-    extend-shallow "^3.0.2"
-    fragment-cache "^0.2.1"
-    is-windows "^1.0.2"
-    kind-of "^6.0.2"
-    object.pick "^1.3.0"
-    regex-not "^1.0.0"
-    snapdragon "^0.8.1"
-    to-regex "^3.0.1"
-
-native-promise-only@^0.8.1:
-  version "0.8.1"
-  resolved "https://registry.yarnpkg.com/native-promise-only/-/native-promise-only-0.8.1.tgz#20a318c30cb45f71fe7adfbf7b21c99c1472ef11"
-  integrity sha1-IKMYwwy0X3H+et+/eyHJnBRy7xE=
-
 negotiator@0.6.2:
   version "0.6.2"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
   integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
 
-nice-try@^1.0.4:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
-  integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
+nise@^4.0.1:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-4.0.4.tgz#d73dea3e5731e6561992b8f570be9e363c4512dd"
+  integrity sha512-bTTRUNlemx6deJa+ZyoCUTRvH3liK5+N6VQZ4NIw90AgDXY6iPnsqplNFf6STcj+ePk0H/xqxnP75Lr0J0Fq3A==
+  dependencies:
+    "@sinonjs/commons" "^1.7.0"
+    "@sinonjs/fake-timers" "^6.0.0"
+    "@sinonjs/text-encoding" "^0.7.1"
+    just-extend "^4.0.2"
+    path-to-regexp "^1.7.0"
 
 no-case@^2.2.0:
   version "2.3.2"
@@ -4393,23 +3129,25 @@
   dependencies:
     lower-case "^1.1.1"
 
-node-environment-flags@1.0.5:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.5.tgz#fa930275f5bf5dae188d6192b24b4c8bbac3d76a"
-  integrity sha512-VNYPRfGfmZLx0Ye20jWzHUjyTW/c+6Wq+iLhDzUI4XmhrDd9l/FozXV3F2xOaXjvp0co0+v1YSR3CMP6g+VvLQ==
+node-environment-flags@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088"
+  integrity sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw==
   dependencies:
     object.getownpropertydescriptors "^2.0.3"
     semver "^5.7.0"
 
-nomnom@^1.8.1:
-  version "1.8.1"
-  resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.8.1.tgz#2151f722472ba79e50a76fc125bb8c8f2e4dc2a7"
-  integrity sha1-IVH3Ikcrp55Qp2/BJbuMjy5Nwqc=
-  dependencies:
-    chalk "~0.4.0"
-    underscore "~1.6.0"
+node-fetch@^2.6.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
+  integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
 
-normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
+node-releases@^1.1.58:
+  version "1.1.58"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.58.tgz#8ee20eef30fa60e52755fcc0942def5a734fe935"
+  integrity sha512-NxBudgVKiRh/2aPWMgPR7bPTX0VPmGx5QBwCtdHitnqFE5/O8DeBXuIMH1nwNnw/aMo6AjOrpsHzfY3UbUJ7yg==
+
+normalize-package-data@^2.3.2:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
   integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
@@ -4419,36 +3157,17 @@
     semver "2 || 3 || 4 || 5"
     validate-npm-package-license "^3.0.1"
 
-normalize-path@^2.0.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9"
-  integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=
-  dependencies:
-    remove-trailing-separator "^1.0.1"
-
-normalize-path@^3.0.0:
+normalize-path@^3.0.0, normalize-path@~3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
   integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
 
-npm-run-path@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
-  integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=
-  dependencies:
-    path-key "^2.0.0"
-
-number-is-nan@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
-  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
-
 oauth-sign@~0.9.0:
   version "0.9.0"
   resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
   integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
 
-object-assign@^4, object-assign@^4.0.0, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
+object-assign@^4.0.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
   integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
@@ -4458,32 +3177,16 @@
   resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291"
   integrity sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=
 
-object-copy@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
-  integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw=
-  dependencies:
-    copy-descriptor "^0.1.0"
-    define-property "^0.2.5"
-    kind-of "^3.0.3"
-
 object-inspect@^1.7.0:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67"
-  integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0"
+  integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==
 
 object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
   integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
 
-object-visit@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
-  integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=
-  dependencies:
-    isobject "^3.0.0"
-
 object.assign@4.1.0, object.assign@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da"
@@ -4494,16 +3197,6 @@
     has-symbols "^1.0.0"
     object-keys "^1.0.11"
 
-object.entries@^1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.1.tgz#ee1cf04153de02bb093fec33683900f57ce5399b"
-  integrity sha512-ilqR7BgdyZetJutmDPfXCDffGa0/Yzl2ivVNpbx/g4UeWrCdRnFDUBrKJGLhGieRHDATnyZXWBeCb29k9CJysQ==
-  dependencies:
-    define-properties "^1.1.3"
-    es-abstract "^1.17.0-next.1"
-    function-bind "^1.1.1"
-    has "^1.0.3"
-
 object.getownpropertydescriptors@^2.0.3:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649"
@@ -4512,26 +3205,6 @@
     define-properties "^1.1.3"
     es-abstract "^1.17.0-next.1"
 
-object.omit@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
-  integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=
-  dependencies:
-    for-own "^0.1.4"
-    is-extendable "^0.1.1"
-
-object.pick@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747"
-  integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=
-  dependencies:
-    isobject "^3.0.1"
-
-obuf@^1.0.0, obuf@^1.1.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
-  integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
-
 on-finished@^2.3.0, on-finished@~2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@@ -4539,29 +3212,25 @@
   dependencies:
     ee-first "1.1.1"
 
-on-headers@~1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
-  integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
-
-once@^1.3.0, once@^1.4.0:
+once@^1.3.0, once@^1.3.1, once@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
   integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
   dependencies:
     wrappy "1"
 
-one-time@0.0.4:
-  version "0.0.4"
-  resolved "https://registry.yarnpkg.com/one-time/-/one-time-0.0.4.tgz#f8cdf77884826fe4dff93e3a9cc37b1e4480742e"
-  integrity sha1-+M33eISCb+Tf+T46nMN7HkSAdC4=
+only@~0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4"
+  integrity sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=
 
-opn@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/opn/-/opn-3.0.3.tgz#b6d99e7399f78d65c3baaffef1fb288e9b85243a"
-  integrity sha1-ttmec5n3jWXDuq/+8fsojpuFJDo=
+open@^7.0.3:
+  version "7.0.4"
+  resolved "https://registry.yarnpkg.com/open/-/open-7.0.4.tgz#c28a9d315e5c98340bf979fdcb2e58664aa10d83"
+  integrity sha512-brSA+/yq+b08Hsr4c8fsEW2CRzk1BmfN3SAK/5VCHQ9bdoZJ4qa/+AfR0xHjlbbZUyPkUHs1b8x1RqdyZdkVqQ==
   dependencies:
-    object-assign "^4.0.1"
+    is-docker "^2.0.0"
+    is-wsl "^2.1.1"
 
 optimist@^0.6.1:
   version "0.6.1"
@@ -4571,37 +3240,11 @@
     minimist "~0.0.1"
     wordwrap "~0.0.2"
 
-ordered-read-streams@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-0.3.0.tgz#7137e69b3298bb342247a1bbee3881c80e2fd78b"
-  integrity sha1-cTfmmzKYuzQiR6G77jiByA4v14s=
-  dependencies:
-    is-stream "^1.0.1"
-    readable-stream "^2.0.1"
-
-os-homedir@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
-  integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
-
-os-tmpdir@^1.0.0, os-tmpdir@^1.0.1:
+os-tmpdir@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
   integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
 
-osenv@^0.1.3:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
-  integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
-  dependencies:
-    os-homedir "^1.0.0"
-    os-tmpdir "^1.0.0"
-
-p-finally@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
-  integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=
-
 p-limit@^2.0.0:
   version "2.2.2"
   resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.2.tgz#61279b67721f5287aa1c13a9a7fbbc48c9291b1e"
@@ -4621,49 +3264,25 @@
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
   integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
 
-package-json@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed"
-  integrity sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=
-  dependencies:
-    got "^6.7.1"
-    registry-auth-token "^3.0.1"
-    registry-url "^3.0.3"
-    semver "^5.1.0"
-
-param-case@2.1.x:
+param-case@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247"
   integrity sha1-35T9jPZTHs915r75oIWPvHK+Ikc=
   dependencies:
     no-case "^2.2.0"
 
-parse-glob@^3.0.4:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
-  integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw=
-  dependencies:
-    glob-base "^0.3.0"
-    is-dotfile "^1.0.0"
-    is-extglob "^1.0.0"
-    is-glob "^2.0.0"
-
-parse-json@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
-  integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=
-  dependencies:
-    error-ex "^1.2.0"
-
-parse-passwd@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
-  integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=
-
-parse5@^4.0.0:
+parse-json@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
-  integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
+  integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=
+  dependencies:
+    error-ex "^1.3.1"
+    json-parse-better-errors "^1.0.1"
+
+parse5@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
+  integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
 
 parseqs@0.0.5:
   version "0.0.5"
@@ -4679,395 +3298,123 @@
   dependencies:
     better-assert "~1.0.0"
 
-parseurl@~1.3.3:
+parseurl@^1.3.2, parseurl@~1.3.3:
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
   integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
 
-pascalcase@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
-  integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=
-
-path-dirname@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"
-  integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=
-
-path-exists@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b"
-  integrity sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=
-  dependencies:
-    pinkie-promise "^2.0.0"
-
 path-exists@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
   integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
 
-path-is-absolute@^1.0.0:
+path-is-absolute@1.0.1, path-is-absolute@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
   integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
 
-path-is-inside@^1.0.1, path-is-inside@^1.0.2:
+path-is-inside@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
   integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=
 
-path-key@^2.0.0, path-key@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
-  integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
-
 path-parse@^1.0.6:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
   integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
 
-path-to-regexp@0.1.7:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
-  integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
-
-path-to-regexp@^1.0.1, path-to-regexp@^1.7.0:
+path-to-regexp@^1.7.0:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
   integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
   dependencies:
     isarray "0.0.1"
 
-path-type@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"
-  integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=
+path-type@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f"
+  integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==
   dependencies:
-    graceful-fs "^4.1.2"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
+    pify "^3.0.0"
 
 pathval@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0"
   integrity sha1-uULm1L3mUwBe9rcTYd74cn0GReA=
 
-pem@^1.8.3:
-  version "1.14.3"
-  resolved "https://registry.yarnpkg.com/pem/-/pem-1.14.3.tgz#347e5a5c194a5f7612b88083e45042fcc4fb4901"
-  integrity sha512-Q+AMVMD3fzeVvZs5PHeI+pVt0hgZY2fjhkliBW43qyONLgCXPVk1ryim43F9eupHlNGLJNT5T/NNrzhUdiC5Zg==
-  dependencies:
-    es6-promisify "^6.0.0"
-    md5 "^2.2.1"
-    os-tmpdir "^1.0.1"
-    which "^1.3.1"
-
-pend@~1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
-  integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA=
-
 performance-now@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
   integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
 
-pify@^2.0.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
-  integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
+picomatch@^2.0.4, picomatch@^2.0.7, picomatch@^2.2.2:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
+  integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
 
 pify@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
   integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
 
-pinkie-promise@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
-  integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o=
+polyfills-loader@^1.6.1:
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/polyfills-loader/-/polyfills-loader-1.6.1.tgz#134ab74b9a6160efb4d72066a5150bfb2228fad3"
+  integrity sha512-GK3jZGLy9nApfRYfHrrO4RYkBkpjiXUVWVdp169g4Y8HV+ZazrGQX46tNpbwP0dtrgHgADyJvZYPfdFuooHy5Q==
   dependencies:
-    pinkie "^2.0.0"
+    "@babel/core" "^7.9.0"
+    "@open-wc/building-utils" "^2.18.0"
+    "@webcomponents/webcomponentsjs" "^2.4.0"
+    abortcontroller-polyfill "^1.4.0"
+    core-js-bundle "^3.6.0"
+    deepmerge "^4.2.2"
+    dynamic-import-polyfill "^0.1.1"
+    es-module-shims "^0.4.6"
+    html-minifier "^4.0.0"
+    intersection-observer "^0.7.0"
+    parse5 "^5.1.1"
+    regenerator-runtime "^0.13.3"
+    resize-observer-polyfill "^1.5.1"
+    systemjs "^6.3.1"
+    terser "^4.6.7"
+    whatwg-fetch "^3.0.0"
 
-pinkie@^2.0.0:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
-  integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA=
-
-plist@^2.0.1:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/plist/-/plist-2.1.0.tgz#57ccdb7a0821df21831217a3cad54e3e146a1025"
-  integrity sha1-V8zbeggh3yGDEhejytVOPhRqECU=
+portfinder@^1.0.21:
+  version "1.0.26"
+  resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.26.tgz#475658d56ca30bed72ac7f1378ed350bd1b64e70"
+  integrity sha512-Xi7mKxJHHMI3rIUrnm/jjUgwhbYMkp/XKEcZX3aG4BrumLpq3nmoQMX+ClYnDZnZ/New7IatC1no5RX0zo1vXQ==
   dependencies:
-    base64-js "1.2.0"
-    xmlbuilder "8.2.2"
-    xmldom "0.1.x"
-
-plylog@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/plylog/-/plylog-1.1.0.tgz#f6f354e2ae0b01f6db4ed111f4b3855da9c37417"
-  integrity sha512-/QnY5aSVaP54va6hruzNtAj02HpsLlAt7V5EndMrtq6ZUTZJKUja43rgiUtGXqm95yrSJjbZoPW0yQQQwLpoJA==
-  dependencies:
-    logform "^1.9.1"
-    winston "^3.0.0"
-    winston-transport "^4.2.0"
-
-polymer-analyzer@^3.1.3, polymer-analyzer@^3.2.2:
-  version "3.2.4"
-  resolved "https://registry.yarnpkg.com/polymer-analyzer/-/polymer-analyzer-3.2.4.tgz#7d76356620a2328e8bc9e30e47069f9729260ca1"
-  integrity sha512-JmxUhMajTuC18tLXbTtu2+aN2x9bTX+4MvCD4IZKJ0rtAL8jWi1iRLfogpHJB4Ig9Dc8EEEuEYipLuzPFl3vqA==
-  dependencies:
-    "@babel/generator" "^7.0.0-beta.42"
-    "@babel/traverse" "^7.0.0-beta.42"
-    "@babel/types" "^7.0.0-beta.42"
-    "@types/babel-generator" "^6.25.1"
-    "@types/babel-traverse" "^6.25.2"
-    "@types/babel-types" "^6.25.1"
-    "@types/babylon" "^6.16.2"
-    "@types/chai-subset" "^1.3.0"
-    "@types/chalk" "^0.4.30"
-    "@types/clone" "^0.1.30"
-    "@types/cssbeautify" "^0.3.1"
-    "@types/doctrine" "^0.0.1"
-    "@types/is-windows" "^0.2.0"
-    "@types/minimatch" "^3.0.1"
-    "@types/parse5" "^2.2.34"
-    "@types/path-is-inside" "^1.0.0"
-    "@types/resolve" "0.0.6"
-    "@types/whatwg-url" "^6.4.0"
-    babylon "^7.0.0-beta.42"
-    cancel-token "^0.1.1"
-    chalk "^1.1.3"
-    clone "^2.0.0"
-    cssbeautify "^0.3.1"
-    doctrine "^2.0.2"
-    dom5 "^3.0.0"
-    indent "0.0.2"
-    is-windows "^1.0.2"
-    jsonschema "^1.1.0"
-    minimatch "^3.0.4"
-    parse5 "^4.0.0"
-    path-is-inside "^1.0.2"
-    resolve "^1.5.0"
-    shady-css-parser "^0.1.0"
-    stable "^0.1.6"
-    strip-indent "^2.0.0"
-    vscode-uri "=1.0.6"
-    whatwg-url "^6.4.0"
-
-polymer-build@^3.1.0:
-  version "3.1.4"
-  resolved "https://registry.yarnpkg.com/polymer-build/-/polymer-build-3.1.4.tgz#ab539f1a13d803518b13b73ffd09198431d98142"
-  integrity sha512-OhTOPG5Y/tK2HqGZ5XA/CVDh+TuOaDv7wTZWXDCg6hxeMgNKuljDMn2coyGU5NLM0pLbS+gwFAc2ZJ5cWHCHNg==
-  dependencies:
-    "@babel/core" "^7.0.0"
-    "@babel/plugin-external-helpers" "^7.0.0"
-    "@babel/plugin-proposal-async-generator-functions" "^7.0.0"
-    "@babel/plugin-proposal-object-rest-spread" "^7.0.0"
-    "@babel/plugin-syntax-async-generators" "^7.0.0"
-    "@babel/plugin-syntax-dynamic-import" "^7.0.0"
-    "@babel/plugin-syntax-import-meta" "^7.0.0"
-    "@babel/plugin-syntax-object-rest-spread" "^7.0.0"
-    "@babel/plugin-transform-arrow-functions" "^7.0.0"
-    "@babel/plugin-transform-async-to-generator" "^7.0.0"
-    "@babel/plugin-transform-block-scoped-functions" "^7.0.0"
-    "@babel/plugin-transform-block-scoping" "^7.0.0"
-    "@babel/plugin-transform-classes" "^7.0.0"
-    "@babel/plugin-transform-computed-properties" "^7.0.0"
-    "@babel/plugin-transform-destructuring" "^7.0.0"
-    "@babel/plugin-transform-duplicate-keys" "^7.0.0"
-    "@babel/plugin-transform-exponentiation-operator" "^7.0.0"
-    "@babel/plugin-transform-for-of" "^7.0.0"
-    "@babel/plugin-transform-function-name" "^7.0.0"
-    "@babel/plugin-transform-instanceof" "^7.0.0"
-    "@babel/plugin-transform-literals" "^7.0.0"
-    "@babel/plugin-transform-modules-amd" "^7.0.0"
-    "@babel/plugin-transform-object-super" "^7.0.0"
-    "@babel/plugin-transform-parameters" "^7.0.0"
-    "@babel/plugin-transform-regenerator" "^7.0.0"
-    "@babel/plugin-transform-shorthand-properties" "^7.0.0"
-    "@babel/plugin-transform-spread" "^7.0.0"
-    "@babel/plugin-transform-sticky-regex" "^7.0.0"
-    "@babel/plugin-transform-template-literals" "^7.0.0"
-    "@babel/plugin-transform-typeof-symbol" "^7.0.0"
-    "@babel/plugin-transform-unicode-regex" "^7.0.0"
-    "@babel/traverse" "^7.0.0"
-    "@polymer/esm-amd-loader" "^1.0.0"
-    "@types/babel-types" "^6.25.1"
-    "@types/babylon" "^6.16.2"
-    "@types/gulp-if" "0.0.33"
-    "@types/html-minifier" "^3.5.1"
-    "@types/is-windows" "^0.2.0"
-    "@types/mz" "0.0.31"
-    "@types/parse5" "^2.2.34"
-    "@types/resolve" "0.0.7"
-    "@types/uuid" "^3.4.3"
-    "@types/vinyl" "^2.0.0"
-    "@types/vinyl-fs" "^2.4.8"
-    babel-plugin-minify-guarded-expressions "^0.4.3"
-    babel-preset-minify "^0.5.0"
-    babylon "^7.0.0-beta.42"
-    css-slam "^2.1.2"
-    dom5 "^3.0.0"
-    gulp-if "^2.0.2"
-    html-minifier "^3.5.10"
-    matcher "^1.1.0"
-    multipipe "^1.0.2"
-    mz "^2.6.0"
-    parse5 "^4.0.0"
-    plylog "^1.0.0"
-    polymer-analyzer "^3.1.3"
-    polymer-bundler "^4.0.9"
-    polymer-project-config "^4.0.3"
-    regenerator-runtime "^0.11.1"
-    stream "0.0.2"
-    sw-precache "^5.1.1"
-    uuid "^3.2.1"
-    vinyl "^1.2.0"
-    vinyl-fs "^2.4.4"
-
-polymer-bundler@^4.0.9:
-  version "4.0.10"
-  resolved "https://registry.yarnpkg.com/polymer-bundler/-/polymer-bundler-4.0.10.tgz#abc8d33977652f031068d034c8104841e80d4cbb"
-  integrity sha512-nwlN3LQlQDqbZ2sUH3394C/dHZUDHq8tpdS5HARvPDb0Q9IXWD+znOR1cr7wSjF0EZN4LiUH5hWyUoV4QSjhpQ==
-  dependencies:
-    "@types/babel-generator" "^6.25.1"
-    "@types/babel-traverse" "^6.25.3"
-    babel-generator "^6.26.1"
-    babel-traverse "^6.26.0"
-    babel-types "^6.26.0"
-    clone "^2.1.0"
-    command-line-args "^5.0.2"
-    command-line-usage "^5.0.5"
-    dom5 "^3.0.0"
-    espree "^3.5.2"
-    magic-string "^0.22.4"
+    async "^2.6.2"
+    debug "^3.1.1"
     mkdirp "^0.5.1"
-    parse5 "^4.0.0"
-    polymer-analyzer "^3.2.2"
-    rollup "^1.3.0"
-    source-map "^0.5.6"
-    vscode-uri "=1.0.6"
-
-polymer-project-config@^4.0.0, polymer-project-config@^4.0.3:
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/polymer-project-config/-/polymer-project-config-4.0.3.tgz#ef0c1a676ce4809907986c8e910745660de8024f"
-  integrity sha512-Drr+Imq+znhBC8XSt9pMlmPixoGnIOmleV5SD6mto1zOGC5oCDbSNsQL2v89DWOk+9aSUO79vnWwOmEPDSvYfw==
-  dependencies:
-    "@types/parse5" "^2.2.34"
-    browser-capabilities "^1.0.0"
-    jsonschema "^1.1.1"
-    minimatch-all "^1.1.0"
-    plylog "^1.0.0"
-    winston "^3.0.0"
-
-polyserve@^0.27.13:
-  version "0.27.15"
-  resolved "https://registry.yarnpkg.com/polyserve/-/polyserve-0.27.15.tgz#261fa5a0873c8d95fd7068598f44c9dac20cf9c4"
-  integrity sha512-AaFgANt+tUUVgHLw+BnaVYcn649JiwL1ru0TOZUKj1gGGn/Bq2S16gxql+1muGpRaAsgFu13Zu7k5XkwatwwSg==
-  dependencies:
-    "@types/compression" "^0.0.33"
-    "@types/content-type" "^1.1.0"
-    "@types/escape-html" "0.0.20"
-    "@types/express" "^4.0.36"
-    "@types/mime" "^2.0.0"
-    "@types/mz" "0.0.29"
-    "@types/opn" "^3.0.28"
-    "@types/parse5" "^2.2.34"
-    "@types/pem" "^1.8.1"
-    "@types/resolve" "0.0.6"
-    "@types/serve-static" "^1.7.31"
-    "@types/spdy" "^3.4.1"
-    bower-config "^1.4.1"
-    browser-capabilities "^1.0.0"
-    command-line-args "^5.0.2"
-    command-line-usage "^5.0.5"
-    compression "^1.6.2"
-    content-type "^1.0.2"
-    cors "^2.8.4"
-    escape-html "^1.0.3"
-    express "^4.8.5"
-    find-port "^1.0.1"
-    http-proxy-middleware "^0.17.2"
-    lru-cache "^4.0.2"
-    mime "^2.3.1"
-    mz "^2.4.0"
-    opn "^3.0.2"
-    pem "^1.8.3"
-    polymer-build "^3.1.0"
-    polymer-project-config "^4.0.0"
-    requirejs "^2.3.4"
-    resolve "^1.5.0"
-    send "^0.16.2"
-    spdy "^3.3.3"
-
-posix-character-classes@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
-  integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=
-
-prepend-http@^1.0.1:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
-  integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
-
-preserve@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
-  integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=
-
-pretty-bytes@^4.0.2:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-4.0.2.tgz#b2bf82e7350d65c6c33aa95aaa5a4f6327f61cd9"
-  integrity sha1-sr+C5zUNZcbDOqlaqlpPYyf2HNk=
-
-private@^0.1.6:
-  version "0.1.8"
-  resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
-  integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==
-
-process-nextick-args@~2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
-  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
-
-progress@2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
-  integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
-
-proxy-addr@~2.0.5:
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34"
-  integrity sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==
-  dependencies:
-    forwarded "~0.1.2"
-    ipaddr.js "1.9.0"
 
 pseudomap@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
   integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
 
-psl@^1.1.24:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/psl/-/psl-1.7.0.tgz#f1c4c47a8ef97167dea5d6bbf4816d736e884a3c"
-  integrity sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ==
+psl@^1.1.28:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
+  integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==
 
-punycode@^1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
-  integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
+pump@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
+  integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
 
-punycode@^2.1.0:
+punycode@^2.1.0, punycode@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
-q@^1.4.1, q@^1.5.1:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
-  integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
+qjobs@^1.1.4:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071"
+  integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==
 
 qs@6.7.0:
   version "6.7.0"
@@ -5079,16 +3426,7 @@
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
   integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
 
-randomatic@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed"
-  integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==
-  dependencies:
-    is-number "^4.0.0"
-    kind-of "^6.0.0"
-    math-random "^1.0.1"
-
-range-parser@~1.2.0, range-parser@~1.2.1:
+range-parser@^1.2.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
   integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
@@ -5103,202 +3441,99 @@
     iconv-lite "0.4.24"
     unpipe "1.0.0"
 
-rc@^1.0.1, rc@^1.1.6:
-  version "1.2.8"
-  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
-  integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
+read-pkg-up@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-4.0.0.tgz#1b221c6088ba7799601c808f91161c66e58f8978"
+  integrity sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA==
   dependencies:
-    deep-extend "^0.6.0"
-    ini "~1.3.0"
-    minimist "^1.2.0"
-    strip-json-comments "~2.0.1"
+    find-up "^3.0.0"
+    read-pkg "^3.0.0"
 
-read-pkg-up@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02"
-  integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=
+read-pkg@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389"
+  integrity sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=
   dependencies:
-    find-up "^1.0.0"
-    read-pkg "^1.0.0"
-
-read-pkg@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
-  integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=
-  dependencies:
-    load-json-file "^1.0.0"
+    load-json-file "^4.0.0"
     normalize-package-data "^2.3.2"
-    path-type "^1.0.0"
+    path-type "^3.0.0"
 
-readable-stream@1.1.x:
-  version "1.1.14"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
-  integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
+readdirp@~3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.2.0.tgz#c30c33352b12c96dfb4b895421a49fd5a9593839"
+  integrity sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ==
   dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.1"
-    isarray "0.0.1"
-    string_decoder "~0.10.x"
+    picomatch "^2.0.4"
 
-"readable-stream@>=1.0.33-1 <1.1.0-0":
-  version "1.0.34"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
-  integrity sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=
+readdirp@~3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.3.0.tgz#984458d13a1e42e2e9f5841b129e162f369aff17"
+  integrity sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ==
   dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.1"
-    isarray "0.0.1"
-    string_decoder "~0.10.x"
+    picomatch "^2.0.7"
 
-readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
-  version "2.3.7"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
-  integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
-  dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.3"
-    isarray "~1.0.0"
-    process-nextick-args "~2.0.0"
-    safe-buffer "~5.1.1"
-    string_decoder "~1.1.1"
-    util-deprecate "~1.0.1"
+reduce-flatten@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27"
+  integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==
 
-readable-stream@^3.0.1, readable-stream@^3.1.1, readable-stream@^3.4.0:
-  version "3.5.0"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.5.0.tgz#465d70e6d1087f6162d079cd0b5db7fbebfd1606"
-  integrity sha512-gSz026xs2LfxBPudDuI41V1lka8cxg64E66SGe78zJlsUofOg/yqwezdIcdfwik6B4h8LFmWPA9ef9X3FiNFLA==
-  dependencies:
-    inherits "^2.0.3"
-    string_decoder "^1.1.1"
-    util-deprecate "^1.0.1"
-
-redent@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
-  integrity sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=
-  dependencies:
-    indent-string "^2.1.0"
-    strip-indent "^1.0.1"
-
-reduce-flatten@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-1.0.1.tgz#258c78efd153ddf93cb561237f61184f3696e327"
-  integrity sha1-JYx479FT3fk8tWEjf2EYTzaW4yc=
-
-regenerate-unicode-properties@^8.1.0:
-  version "8.1.0"
-  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e"
-  integrity sha512-LGZzkgtLY79GeXLm8Dp0BVLdQlWICzBnJz/ipWUgo59qBaZ+BHtq51P2q1uVZlppMuUAT37SDk39qUbjTWB7bA==
+regenerate-unicode-properties@^8.2.0:
+  version "8.2.0"
+  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec"
+  integrity sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==
   dependencies:
     regenerate "^1.4.0"
 
 regenerate@^1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11"
-  integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.1.tgz#cad92ad8e6b591773485fbe05a485caf4f457e6f"
+  integrity sha512-j2+C8+NtXQgEKWk49MMP5P/u2GhnahTtVkRIHr5R5lVRlbKvmQ+oS+A5aLKWp2ma5VkT8sh6v+v4hbH0YHR66A==
 
-regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1:
-  version "0.11.1"
-  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
-  integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
+regenerator-runtime@^0.13.3, regenerator-runtime@^0.13.4:
+  version "0.13.5"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697"
+  integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==
 
-regenerator-transform@^0.14.0:
-  version "0.14.1"
-  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.1.tgz#3b2fce4e1ab7732c08f665dfdb314749c7ddd2fb"
-  integrity sha512-flVuee02C3FKRISbxhXl9mGzdbWUVHubl1SMaknjxkFB1/iqpJhArQUvRxOOPEc/9tAiX0BaQ28FJH10E4isSQ==
+regenerator-transform@^0.14.2:
+  version "0.14.5"
+  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4"
+  integrity sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==
   dependencies:
-    private "^0.1.6"
+    "@babel/runtime" "^7.8.4"
 
-regex-cache@^0.4.2:
-  version "0.4.4"
-  resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd"
-  integrity sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==
-  dependencies:
-    is-equal-shallow "^0.1.3"
-
-regex-not@^1.0.0, regex-not@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"
-  integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==
-  dependencies:
-    extend-shallow "^3.0.2"
-    safe-regex "^1.1.0"
-
-regexpu-core@^4.6.0:
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.6.0.tgz#2037c18b327cfce8a6fea2a4ec441f2432afb8b6"
-  integrity sha512-YlVaefl8P5BnFYOITTNzDvan1ulLOiXJzCNZxduTIosN17b87h3bvG9yHMoHaRuo88H4mQ06Aodj5VtYGGGiTg==
+regexpu-core@^4.7.0:
+  version "4.7.0"
+  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.0.tgz#fcbf458c50431b0bb7b45d6967b8192d91f3d938"
+  integrity sha512-TQ4KXRnIn6tz6tjnrXEkD/sshygKH/j5KzK86X8MkeHyZ8qst/LZ89j3X4/8HEIfHANTFIP/AbXakeRhWIl5YQ==
   dependencies:
     regenerate "^1.4.0"
-    regenerate-unicode-properties "^8.1.0"
-    regjsgen "^0.5.0"
-    regjsparser "^0.6.0"
+    regenerate-unicode-properties "^8.2.0"
+    regjsgen "^0.5.1"
+    regjsparser "^0.6.4"
     unicode-match-property-ecmascript "^1.0.4"
-    unicode-match-property-value-ecmascript "^1.1.0"
+    unicode-match-property-value-ecmascript "^1.2.0"
 
-registry-auth-token@^3.0.1:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.4.0.tgz#d7446815433f5d5ed6431cd5dca21048f66b397e"
-  integrity sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A==
-  dependencies:
-    rc "^1.1.6"
-    safe-buffer "^5.0.1"
+regjsgen@^0.5.1:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733"
+  integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==
 
-registry-url@^3.0.3:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
-  integrity sha1-PU74cPc93h138M+aOBQyRE4XSUI=
-  dependencies:
-    rc "^1.0.1"
-
-regjsgen@^0.5.0:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.1.tgz#48f0bf1a5ea205196929c0d9798b42d1ed98443c"
-  integrity sha512-5qxzGZjDs9w4tzT3TPhCJqWdCc3RLYwy9J2NB0nm5Lz+S273lvWcpjaTGHsT1dc6Hhfq41uSEOw8wBmxrKOuyg==
-
-regjsparser@^0.6.0:
-  version "0.6.2"
-  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.2.tgz#fd62c753991467d9d1ffe0a9f67f27a529024b96"
-  integrity sha512-E9ghzUtoLwDekPT0DYCp+c4h+bvuUpe6rRHCTYn6eGoqj1LgKXxT6I0Il4WbjhQkOghzi/V+y03bPKvbllL93Q==
+regjsparser@^0.6.4:
+  version "0.6.4"
+  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.4.tgz#a769f8684308401a66e9b529d2436ff4d0666272"
+  integrity sha512-64O87/dPDgfk8/RQqC4gkZoGyyWFIEUTTh80CU6CWuK5vkCGyekIx+oKcEIYtP/RAxSQltCZHCNu/mdd7fqlJw==
   dependencies:
     jsesc "~0.5.0"
 
-relateurl@0.2.x:
+relateurl@^0.2.7:
   version "0.2.7"
   resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
   integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
 
-remove-trailing-separator@^1.0.1:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
-  integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8=
-
-repeat-element@^1.1.2:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce"
-  integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==
-
-repeat-string@^1.5.2, repeat-string@^1.6.1:
-  version "1.6.1"
-  resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
-  integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
-
-repeating@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
-  integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=
-  dependencies:
-    is-finite "^1.0.0"
-
-replace-ext@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924"
-  integrity sha1-KbvZIHinOfC8zitO5B6DeVNSKSQ=
-
-request@2.88.0, request@^2.85.0:
-  version "2.88.0"
-  resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
-  integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
+request@^2.88.0:
+  version "2.88.2"
+  resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
+  integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
   dependencies:
     aws-sign2 "~0.7.0"
     aws4 "^1.8.0"
@@ -5307,7 +3542,7 @@
     extend "~3.0.2"
     forever-agent "~0.6.1"
     form-data "~2.3.2"
-    har-validator "~5.1.0"
+    har-validator "~5.1.3"
     http-signature "~1.2.0"
     is-typedarray "~1.0.0"
     isstream "~0.1.2"
@@ -5317,7 +3552,7 @@
     performance-now "^2.1.0"
     qs "~6.5.2"
     safe-buffer "^5.1.2"
-    tough-cookie "~2.4.3"
+    tough-cookie "~2.5.0"
     tunnel-agent "^0.6.0"
     uuid "^3.3.2"
 
@@ -5331,228 +3566,102 @@
   resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
   integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
 
-requirejs@^2.3.4:
-  version "2.3.6"
-  resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.6.tgz#e5093d9601c2829251258c0b9445d4d19fa9e7c9"
-  integrity sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==
-
 requires-port@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
   integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
 
-resolve-dir@^1.0.0, resolve-dir@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43"
-  integrity sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=
+resize-observer-polyfill@^1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
+  integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
+
+resolve-path@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7"
+  integrity sha1-xL2p9e+y/OZSR4c6s2u02DT+Fvc=
   dependencies:
-    expand-tilde "^2.0.0"
-    global-modules "^1.0.0"
+    http-errors "~1.6.2"
+    path-is-absolute "1.0.1"
 
-resolve-url@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
-  integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=
-
-resolve@^1.10.0, resolve@^1.3.2, resolve@^1.5.0:
-  version "1.15.0"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.0.tgz#1b7ca96073ebb52e741ffd799f6b39ea462c67f5"
-  integrity sha512-+hTmAldEGE80U2wJJDC1lebb5jWqvTYAfm3YZ1ckk1gBr0MnCqUKlwK1e+anaFljIl+F5tR5IoZcm4ZDA1zMQw==
+resolve@^1.10.0, resolve@^1.11.1, resolve@^1.14.2, resolve@^1.3.2:
+  version "1.17.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444"
+  integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==
   dependencies:
     path-parse "^1.0.6"
 
-ret@~0.1.10:
-  version "0.1.15"
-  resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
-  integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
+rfdc@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.1.4.tgz#ba72cc1367a0ccd9cf81a870b3b58bd3ad07f8c2"
+  integrity sha512-5C9HXdzK8EAqN7JDif30jqsBzavB7wLpaubisuQIGHWf2gUXSpzy6ArX/+Da8RjFpagWsCn+pIgxTMAmKw9Zug==
 
-rimraf@^2.5.4:
+rimraf@^2.6.0:
   version "2.7.1"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
   integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
   dependencies:
     glob "^7.1.3"
 
-rimraf@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.0.tgz#614176d4b3010b75e5c390eb0ee96f6dc0cebb9b"
-  integrity sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==
+rimraf@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
   dependencies:
     glob "^7.1.3"
 
-rimraf@~2.6.2:
-  version "2.6.3"
-  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
-  integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
-  dependencies:
-    glob "^7.1.3"
-
-rollup@^1.3.0:
-  version "1.29.1"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.29.1.tgz#8715d0a4ca439be3079f8095989ec8aa60f637bc"
-  integrity sha512-dGQ+b9d1FOX/gluiggTAVnTvzQZUEkCi/TwZcax7ujugVRHs0nkYJlV9U4hsifGEMojnO+jvEML2CJQ6qXgbHA==
-  dependencies:
-    "@types/estree" "*"
-    "@types/node" "*"
-    acorn "^7.1.0"
+rollup@^2.7.2:
+  version "2.18.2"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.18.2.tgz#886ac6e4549e493df106c3e2580c89aeb997be25"
+  integrity sha512-+mzyZhL9ZyLB3eHBISxRNTep9Z2qCuwXzAYkUbFyz7yNKaKH03MFKeiGOS1nv2uvPgDb4ASKv+FiS5mC4h5IFQ==
+  optionalDependencies:
+    fsevents "~2.1.2"
 
 safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
 
-safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
+safe-buffer@^5.0.1:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519"
   integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==
 
-safe-regex@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"
-  integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4=
-  dependencies:
-    ret "~0.1.10"
+safe-buffer@^5.1.2:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
+  integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
 
 "safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
-samsam@1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.2.tgz#bec11fdc83a9fda063401210e40176c3024d1567"
-  integrity sha1-vsEf3IOp/aBjQBIQ5AF2wwJNFWc=
-
-samsam@1.x, samsam@^1.1.3:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50"
-  integrity sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==
-
-samsam@~1.1:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.3.tgz#9f5087419b4d091f232571e7fa52e90b0f552621"
-  integrity sha1-n1CHQZtNCR8jJXHn+lLpCw9VJiE=
-
-sauce-connect-launcher@^1.0.0:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.3.1.tgz#31137f57b0f7176e1c0525b7fb09c6da746647cf"
-  integrity sha512-vIf9qDol3q2FlYzrKt0dr3kvec6LSjX2WS+/mVnAJIhqh1evSkPKCR2AzcJrnSmx9Xt9PtV0tLY7jYh0wsQi8A==
-  dependencies:
-    adm-zip "~0.4.3"
-    async "^2.1.2"
-    https-proxy-agent "^3.0.0"
-    lodash "^4.16.6"
-    rimraf "^2.5.4"
-
-select-hose@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
-  integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=
-
-selenium-standalone@^6.7.0:
-  version "6.17.0"
-  resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.17.0.tgz#0f24b691836205ee9bc3d7a6f207ebcb28170cd9"
-  integrity sha512-5PSnDHwMiq+OCiAGlhwQ8BM9xuwFfvBOZ7Tfbw+ifkTnOy0PWbZmI1B9gPGuyGHpbQ/3J3CzIK7BYwrQ7EjtWQ==
-  dependencies:
-    async "^2.6.2"
-    commander "^2.19.0"
-    cross-spawn "^6.0.5"
-    debug "^4.1.1"
-    lodash "^4.17.11"
-    minimist "^1.2.0"
-    mkdirp "^0.5.1"
-    progress "2.0.3"
-    request "2.88.0"
-    tar-stream "2.0.0"
-    urijs "^1.19.1"
-    which "^1.3.1"
-    yauzl "^2.10.0"
-
-semver-diff@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36"
-  integrity sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY=
-  dependencies:
-    semver "^5.0.3"
-
-"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.7.0:
+"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.7.0:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
   integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
 
-send@0.17.1:
-  version "0.17.1"
-  resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
-  integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==
-  dependencies:
-    debug "2.6.9"
-    depd "~1.1.2"
-    destroy "~1.0.4"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    fresh "0.5.2"
-    http-errors "~1.7.2"
-    mime "1.6.0"
-    ms "2.1.1"
-    on-finished "~2.3.0"
-    range-parser "~1.2.1"
-    statuses "~1.5.0"
+semver@7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
+  integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
 
-send@^0.16.1, send@^0.16.2:
-  version "0.16.2"
-  resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1"
-  integrity sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==
-  dependencies:
-    debug "2.6.9"
-    depd "~1.1.2"
-    destroy "~1.0.4"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    fresh "0.5.2"
-    http-errors "~1.6.2"
-    mime "1.4.1"
-    ms "2.0.0"
-    on-finished "~2.3.0"
-    range-parser "~1.2.0"
-    statuses "~1.4.0"
+semver@^6.0.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
+  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
-serve-static@1.14.1:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
-  integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
-  dependencies:
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    parseurl "~1.3.3"
-    send "0.17.1"
-
-server-destroy@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/server-destroy/-/server-destroy-1.0.1.tgz#f13bf928e42b9c3e79383e61cc3998b5d14e6cdd"
-  integrity sha1-8Tv5KOQrnD55OD5hzDmYtdFObN0=
-
-serviceworker-cache-polyfill@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/serviceworker-cache-polyfill/-/serviceworker-cache-polyfill-4.0.0.tgz#de19ee73bef21ab3c0740a37b33db62464babdeb"
-  integrity sha1-3hnuc77yGrPAdAo3sz22JGS6ves=
+semver@^7.3.2:
+  version "7.3.2"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938"
+  integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==
 
 set-blocking@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
   integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
 
-set-value@^2.0.0, set-value@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
-  integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==
-  dependencies:
-    extend-shallow "^2.0.1"
-    is-extendable "^0.1.1"
-    is-plain-object "^2.0.3"
-    split-string "^3.0.1"
-
 setprototypeof@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
@@ -5563,192 +3672,110 @@
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
   integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
 
-shady-css-parser@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/shady-css-parser/-/shady-css-parser-0.1.0.tgz#534dc79c8ca5884c5ed92a4e5a13d6d863bca428"
-  integrity sha512-irfJUUkEuDlNHKZNAp2r7zOyMlmbfVJ+kWSfjlCYYUx/7dJnANLCyTzQZsuxy5NJkvtNwSxY5Gj8MOlqXUQPyA==
-
-shebang-command@^1.2.0:
+setprototypeof@1.2.0:
   version "1.2.0"
-  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
-  integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
+  integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
+
+shady-css-scoped-element@^0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/shady-css-scoped-element/-/shady-css-scoped-element-0.0.2.tgz#c538fcfe2317e979cd02dfec533898b95b4ea8fe"
+  integrity sha512-Dqfl70x6JiwYDujd33ZTbtCK0t52E7+H2swdWQNSTzfsolSa6LJHnTpN4T9OpJJEq4bxuzHRLFO9RBcy/UfrMQ==
+
+sinon@^9.0.2:
+  version "9.0.2"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-9.0.2.tgz#b9017e24633f4b1c98dfb6e784a5f0509f5fd85d"
+  integrity sha512-0uF8Q/QHkizNUmbK3LRFqx5cpTttEVXudywY9Uwzy8bTfZUhljZ7ARzSxnRHWYWtVTeh4Cw+tTb3iU21FQVO9A==
   dependencies:
-    shebang-regex "^1.0.0"
-
-shebang-regex@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
-  integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
-
-signal-exit@^3.0.0, signal-exit@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
-  integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
-
-simple-swizzle@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
-  integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
-  dependencies:
-    is-arrayish "^0.3.1"
-
-sinon-chai@^2.10.0:
-  version "2.14.0"
-  resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-2.14.0.tgz#da7dd4cc83cd6a260b67cca0f7a9fdae26a1205d"
-  integrity sha512-9stIF1utB0ywNHNT7RgiXbdmen8QDCRsrTjw+G9TgKt1Yexjiv8TOWZ6WHsTPz57Yky3DIswZvEqX8fpuHNDtQ==
-
-sinon@^1.17.1:
-  version "1.17.7"
-  resolved "https://registry.yarnpkg.com/sinon/-/sinon-1.17.7.tgz#4542a4f49ba0c45c05eb2e9dd9d203e2b8efe0bf"
-  integrity sha1-RUKk9JugxFwF6y6d2dID4rjv4L8=
-  dependencies:
-    formatio "1.1.1"
-    lolex "1.3.2"
-    samsam "1.1.2"
-    util ">=0.10.3 <1"
-
-sinon@^2.3.5:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/sinon/-/sinon-2.4.1.tgz#021fd64b54cb77d9d2fb0d43cdedfae7629c3a36"
-  integrity sha512-vFTrO9Wt0ECffDYIPSP/E5bBugt0UjcBQOfQUMh66xzkyPEnhl/vM2LRZi2ajuTdkH07sA6DzrM6KvdvGIH8xw==
-  dependencies:
-    diff "^3.1.0"
-    formatio "1.2.0"
-    lolex "^1.6.0"
-    native-promise-only "^0.8.1"
-    path-to-regexp "^1.7.0"
-    samsam "^1.1.3"
-    text-encoding "0.6.4"
-    type-detect "^4.0.0"
-
-snapdragon-node@^2.0.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
-  integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==
-  dependencies:
-    define-property "^1.0.0"
-    isobject "^3.0.0"
-    snapdragon-util "^3.0.1"
-
-snapdragon-util@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2"
-  integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==
-  dependencies:
-    kind-of "^3.2.0"
-
-snapdragon@^0.8.1:
-  version "0.8.2"
-  resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d"
-  integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==
-  dependencies:
-    base "^0.11.1"
-    debug "^2.2.0"
-    define-property "^0.2.5"
-    extend-shallow "^2.0.1"
-    map-cache "^0.2.2"
-    source-map "^0.5.6"
-    source-map-resolve "^0.5.0"
-    use "^3.1.0"
+    "@sinonjs/commons" "^1.7.2"
+    "@sinonjs/fake-timers" "^6.0.1"
+    "@sinonjs/formatio" "^5.0.1"
+    "@sinonjs/samsam" "^5.0.3"
+    diff "^4.0.2"
+    nise "^4.0.1"
+    supports-color "^7.1.0"
 
 socket.io-adapter@~1.1.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz#ab3f0d6f66b8fc7fca3959ab5991f82221789be9"
   integrity sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==
 
-socket.io-client@2.3.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.3.0.tgz#14d5ba2e00b9bcd145ae443ab96b3f86cbcc1bb4"
-  integrity sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==
+socket.io-client@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.1.1.tgz#dcb38103436ab4578ddb026638ae2f21b623671f"
+  integrity sha512-jxnFyhAuFxYfjqIgduQlhzqTcOEQSn+OHKVfAxWaNWa7ecP7xSNk2Dx/3UEsDcY7NcFafxvNvKPmmO7HTwTxGQ==
   dependencies:
     backo2 "1.0.2"
     base64-arraybuffer "0.1.5"
     component-bind "1.0.0"
     component-emitter "1.2.1"
-    debug "~4.1.0"
-    engine.io-client "~3.4.0"
+    debug "~3.1.0"
+    engine.io-client "~3.2.0"
     has-binary2 "~1.0.2"
     has-cors "1.1.0"
     indexof "0.0.1"
     object-component "0.0.3"
     parseqs "0.0.5"
     parseuri "0.0.5"
-    socket.io-parser "~3.3.0"
+    socket.io-parser "~3.2.0"
     to-array "0.1.4"
 
-socket.io-parser@~3.3.0:
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
-  integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==
+socket.io-parser@~3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.2.0.tgz#e7c6228b6aa1f814e6148aea325b51aa9499e077"
+  integrity sha512-FYiBx7rc/KORMJlgsXysflWx/RIvtqZbyGLlHZvjfmPTPeuD/I8MaW7cfFrj5tRltICJdgwflhfZ3NVVbVLFQA==
   dependencies:
     component-emitter "1.2.1"
     debug "~3.1.0"
     isarray "2.0.1"
 
-socket.io-parser@~3.4.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.4.0.tgz#370bb4a151df2f77ce3345ff55a7072cc6e9565a"
-  integrity sha512-/G/VOI+3DBp0+DJKW4KesGnQkQPFmUCbA/oO2QGT6CWxU7hLGWqU3tyuzeSK/dqcyeHsQg1vTe9jiZI8GU9SCQ==
+socket.io@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.1.1.tgz#a069c5feabee3e6b214a75b40ce0652e1cfb9980"
+  integrity sha512-rORqq9c+7W0DAK3cleWNSyfv/qKXV99hV4tZe+gGLfBECw3XEhBy7x85F3wypA9688LKjtwO9pX9L33/xQI8yA==
   dependencies:
-    component-emitter "1.2.1"
-    debug "~4.1.0"
-    isarray "2.0.1"
-
-socket.io@^2.0.3:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.3.0.tgz#cd762ed6a4faeca59bc1f3e243c0969311eb73fb"
-  integrity sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg==
-  dependencies:
-    debug "~4.1.0"
-    engine.io "~3.4.0"
+    debug "~3.1.0"
+    engine.io "~3.2.0"
     has-binary2 "~1.0.2"
     socket.io-adapter "~1.1.0"
-    socket.io-client "2.3.0"
-    socket.io-parser "~3.4.0"
+    socket.io-client "2.1.1"
+    socket.io-parser "~3.2.0"
 
-source-map-resolve@^0.5.0:
-  version "0.5.3"
-  resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"
-  integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==
+source-map-support@~0.5.12:
+  version "0.5.19"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
+  integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
   dependencies:
-    atob "^2.1.2"
-    decode-uri-component "^0.2.0"
-    resolve-url "^0.2.1"
-    source-map-url "^0.4.0"
-    urix "^0.1.0"
+    buffer-from "^1.0.0"
+    source-map "^0.6.0"
 
-source-map-url@^0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3"
-  integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=
-
-source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7:
+source-map@^0.5.0:
   version "0.5.7"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
   integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
 
-source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
+source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
   integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
 
 spdx-correct@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4"
-  integrity sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
+  integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==
   dependencies:
     spdx-expression-parse "^3.0.0"
     spdx-license-ids "^3.0.0"
 
 spdx-exceptions@^2.1.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977"
-  integrity sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d"
+  integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==
 
 spdx-expression-parse@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0"
-  integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679"
+  integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==
   dependencies:
     spdx-exceptions "^2.1.0"
     spdx-license-ids "^3.0.0"
@@ -5758,38 +3785,6 @@
   resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654"
   integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==
 
-spdy-transport@^2.0.18:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-2.1.1.tgz#c54815d73858aadd06ce63001e7d25fa6441623b"
-  integrity sha512-q7D8c148escoB3Z7ySCASadkegMmUZW8Wb/Q1u0/XBgDKMO880rLQDj8Twiew/tYi7ghemKUi/whSYOwE17f5Q==
-  dependencies:
-    debug "^2.6.8"
-    detect-node "^2.0.3"
-    hpack.js "^2.1.6"
-    obuf "^1.1.1"
-    readable-stream "^2.2.9"
-    safe-buffer "^5.0.1"
-    wbuf "^1.7.2"
-
-spdy@^3.3.3:
-  version "3.4.7"
-  resolved "https://registry.yarnpkg.com/spdy/-/spdy-3.4.7.tgz#42ff41ece5cc0f99a3a6c28aabb73f5c3b03acbc"
-  integrity sha1-Qv9B7OXMD5mjpsKKq7c/XDsDrLw=
-  dependencies:
-    debug "^2.6.8"
-    handle-thing "^1.2.5"
-    http-deceiver "^1.2.7"
-    safe-buffer "^5.0.1"
-    select-hose "^2.0.0"
-    spdy-transport "^2.0.18"
-
-split-string@^3.0.1, split-string@^3.0.2:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
-  integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==
-  dependencies:
-    extend-shallow "^3.0.0"
-
 sprintf-js@~1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
@@ -5810,60 +3805,23 @@
     safer-buffer "^2.0.2"
     tweetnacl "~0.14.0"
 
-stable@^0.1.6:
-  version "0.1.8"
-  resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
-  integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==
-
-stack-trace@0.0.x:
-  version "0.0.10"
-  resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
-  integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=
-
-stacky@^1.3.1:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/stacky/-/stacky-1.3.1.tgz#3f117e5187b9a73d23f876d69f05c85b11804a12"
-  integrity sha1-PxF+UYe5pz0j+HbWnwXIWxGAShI=
-  dependencies:
-    chalk "^1.1.1"
-    lodash "^3.0.0"
-
-static-extend@^0.1.1:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
-  integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=
-  dependencies:
-    define-property "^0.2.5"
-    object-copy "^0.1.0"
-
-"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0:
+"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.0.0, statuses@^1.5.0, statuses@~1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
   integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
 
-statuses@~1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
-  integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==
-
-stream-shift@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d"
-  integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==
-
-stream@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/stream/-/stream-0.0.2.tgz#7f5363f057f6592c5595f00bc80a27f5cec1f0ef"
-  integrity sha1-f1Nj8Ff2WSxVlfALyAon9c7B8O8=
+streamroller@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-1.0.6.tgz#8167d8496ed9f19f05ee4b158d9611321b8cacd9"
+  integrity sha512-3QC47Mhv3/aZNFpDDVO44qQb9gwB9QggMEE0sQmkTAwBVYdBRWISdsywlkfm5II1Q5y/pmrHflti/IgmIzdDBg==
   dependencies:
-    emitter-component "^1.1.1"
+    async "^2.6.2"
+    date-format "^2.0.0"
+    debug "^3.2.6"
+    fs-extra "^7.0.1"
+    lodash "^4.17.14"
 
-streamsearch@0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"
-  integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
-
-"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.1:
+"string-width@^1.0.2 || 2":
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
   integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
@@ -5880,47 +3838,21 @@
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^5.1.0"
 
-string.prototype.trimleft@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74"
-  integrity sha512-iu2AGd3PuP5Rp7x2kEZCrB2Nf41ehzh+goo8TV7z8/XDBbsvc6HQIlUl9RjkZ4oyrW1XM5UwlGl1oVEaDjg6Ag==
+string.prototype.trimend@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913"
+  integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==
   dependencies:
     define-properties "^1.1.3"
-    function-bind "^1.1.1"
+    es-abstract "^1.17.5"
 
-string.prototype.trimright@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string.prototype.trimright/-/string.prototype.trimright-2.1.1.tgz#440314b15996c866ce8a0341894d45186200c5d9"
-  integrity sha512-qFvWL3/+QIgZXVmJBfpHmxLB7xsUXz6HsUmP8+5dRaC3Q7oKUv9Vo6aMCRZC1smrtyECFsIT30PqBJ1gTjAs+g==
+string.prototype.trimstart@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54"
+  integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==
   dependencies:
     define-properties "^1.1.3"
-    function-bind "^1.1.1"
-
-string_decoder@^1.1.1:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
-  integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
-  dependencies:
-    safe-buffer "~5.2.0"
-
-string_decoder@~0.10.x:
-  version "0.10.31"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
-  integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
-
-string_decoder@~1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
-  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
-  dependencies:
-    safe-buffer "~5.1.0"
-
-strip-ansi@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
-  integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
-  dependencies:
-    ansi-regex "^2.0.0"
+    es-abstract "^1.17.5"
 
 strip-ansi@^4.0.0:
   version "4.0.0"
@@ -5936,55 +3868,16 @@
   dependencies:
     ansi-regex "^4.1.0"
 
-strip-ansi@~0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-0.1.1.tgz#39e8a98d044d150660abe4a6808acf70bb7bc991"
-  integrity sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=
+strip-bom@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
+  integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=
 
-strip-bom-stream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-1.0.0.tgz#e7144398577d51a6bed0fa1994fa05f43fd988ee"
-  integrity sha1-5xRDmFd9Uaa+0PoZlPoF9D/ZiO4=
-  dependencies:
-    first-chunk-stream "^1.0.0"
-    strip-bom "^2.0.0"
-
-strip-bom@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
-  integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=
-  dependencies:
-    is-utf8 "^0.2.0"
-
-strip-eof@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
-  integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=
-
-strip-indent@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
-  integrity sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=
-  dependencies:
-    get-stdin "^4.0.1"
-
-strip-indent@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68"
-  integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=
-
-strip-json-comments@2.0.1, strip-json-comments@~2.0.1:
+strip-json-comments@2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
   integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
 
-supports-color@3.1.2:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.1.2.tgz#72a262894d9d408b956ca05ff37b2ed8a6e2a2d5"
-  integrity sha1-cqJiiU2dQIuVbKBf83su2KbiotU=
-  dependencies:
-    has-flag "^1.0.0"
-
 supports-color@6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.0.0.tgz#76cfe742cf1f41bb9b1c29ad03068c05b4c0e40a"
@@ -5992,11 +3885,6 @@
   dependencies:
     has-flag "^3.0.0"
 
-supports-color@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
-  integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=
-
 supports-color@^5.3.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
@@ -6004,96 +3892,46 @@
   dependencies:
     has-flag "^3.0.0"
 
-sw-precache@^5.1.1:
-  version "5.2.1"
-  resolved "https://registry.yarnpkg.com/sw-precache/-/sw-precache-5.2.1.tgz#06134f319eec68f3b9583ce9a7036b1c119f7179"
-  integrity sha512-8FAy+BP/FXE+ILfiVTt+GQJ6UEf4CVHD9OfhzH0JX+3zoy2uFk7Vn9EfXASOtVmmIVbL3jE/W8Z66VgPSZcMhw==
+supports-color@^7.1.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1"
+  integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==
   dependencies:
-    dom-urls "^1.1.0"
-    es6-promise "^4.0.5"
-    glob "^7.1.1"
-    lodash.defaults "^4.2.0"
-    lodash.template "^4.4.0"
-    meow "^3.7.0"
-    mkdirp "^0.5.1"
-    pretty-bytes "^4.0.2"
-    sw-toolbox "^3.4.0"
-    update-notifier "^2.3.0"
+    has-flag "^4.0.0"
 
-sw-toolbox@^3.4.0:
-  version "3.6.0"
-  resolved "https://registry.yarnpkg.com/sw-toolbox/-/sw-toolbox-3.6.0.tgz#26df1d1c70348658e4dea2884319149b7b3183b5"
-  integrity sha1-Jt8dHHA0hljk3qKIQxkUm3sxg7U=
-  dependencies:
-    path-to-regexp "^1.0.1"
-    serviceworker-cache-polyfill "^4.0.0"
+systemjs@^6.3.1:
+  version "6.3.3"
+  resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.3.3.tgz#c0f2bec5cc72d0b36a8b971b1fa32bfc828b50d4"
+  integrity sha512-djQ6mZ4/cWKnVnhAWvr/4+5r7QHnC7WiA8sS9VuYRdEv3wYZYTIIQv8zPT79PdDSUwfX3bgvu5mZ8eTyLm2YQA==
 
-table-layout@^0.4.3:
-  version "0.4.5"
-  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-0.4.5.tgz#d906de6a25fa09c0c90d1d08ecd833ecedcb7378"
-  integrity sha512-zTvf0mcggrGeTe/2jJ6ECkJHAQPIYEwDoqsiqBjI24mvRmQbInK5jq33fyypaCBxX08hMkfmdOqj6haT33EqWw==
+table-layout@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.1.tgz#8411181ee951278ad0638aea2f779a9ce42894f9"
+  integrity sha512-dEquqYNJiGwY7iPfZ3wbXDI944iqanTSchrACLL2nOB+1r+h1Nzu2eH+DuPPvWvm5Ry7iAPeFlgEtP5bIp5U7Q==
   dependencies:
-    array-back "^2.0.0"
+    array-back "^4.0.1"
     deep-extend "~0.6.0"
-    lodash.padend "^4.6.1"
-    typical "^2.6.1"
-    wordwrapjs "^3.0.0"
+    typical "^5.2.0"
+    wordwrapjs "^4.0.0"
 
-tar-stream@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.0.0.tgz#8829bbf83067bc0288a9089db49c56be395b6aea"
-  integrity sha512-n2vtsWshZOVr/SY4KtslPoUlyNh06I2SGgAOCZmquCEjlbV/LjY2CY80rDtdQRHFOYXNlgBDo6Fr3ww2CWPOtA==
+terser@^4.6.7:
+  version "4.8.0"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
+  integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==
   dependencies:
-    bl "^2.2.0"
-    end-of-stream "^1.4.1"
-    fs-constants "^1.0.0"
-    inherits "^2.0.3"
-    readable-stream "^3.1.1"
+    commander "^2.20.0"
+    source-map "~0.6.1"
+    source-map-support "~0.5.12"
 
-tar-stream@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.0.tgz#d1aaa3661f05b38b5acc9b7020efdca5179a2cc3"
-  integrity sha512-+DAn4Nb4+gz6WZigRzKEZl1QuJVOLtAwwF+WUxy1fJ6X63CaGaUAxJRD2KEn1OMfcbCjySTYpNC6WmfQoIEOdw==
+test-exclude@^5.2.3:
+  version "5.2.3"
+  resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.2.3.tgz#c3d3e1e311eb7ee405e092dac10aefd09091eac0"
+  integrity sha512-M+oxtseCFO3EDtAaGH7iiej3CBkzXqFMbzqYAACdzKui4eZA+pq3tZEwChvOdNfa7xxy8BfbmgJSIr43cC/+2g==
   dependencies:
-    bl "^3.0.0"
-    end-of-stream "^1.4.1"
-    fs-constants "^1.0.0"
-    inherits "^2.0.3"
-    readable-stream "^3.1.1"
-
-temp@^0.8.1:
-  version "0.8.4"
-  resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.4.tgz#8c97a33a4770072e0a05f919396c7665a7dd59f2"
-  integrity sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==
-  dependencies:
-    rimraf "~2.6.2"
-
-term-size@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69"
-  integrity sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk=
-  dependencies:
-    execa "^0.7.0"
-
-ternary-stream@^2.0.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ternary-stream/-/ternary-stream-2.1.1.tgz#4ad64b98668d796a085af2c493885a435a8a8bfc"
-  integrity sha512-j6ei9hxSoyGlqTmoMjOm+QNvUKDOIY6bNl4Uh1lhBvl6yjPW2iLqxDUYyfDPZknQ4KdRziFl+ec99iT4l7g0cw==
-  dependencies:
-    duplexify "^3.5.0"
-    fork-stream "^0.0.4"
-    merge-stream "^1.0.0"
-    through2 "^2.0.1"
-
-text-encoding@0.6.4:
-  version "0.6.4"
-  resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"
-  integrity sha1-45mpgiV6J22uQou5KEXLcb3CbRk=
-
-text-hex@1.0.x:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5"
-  integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==
+    glob "^7.1.3"
+    minimatch "^3.0.4"
+    read-pkg-up "^4.0.0"
+    require-main-filename "^2.0.0"
 
 thenify-all@^1.0.0:
   version "1.6.0"
@@ -6103,108 +3941,48 @@
     thenify ">= 3.1.0 < 4"
 
 "thenify@>= 3.1.0 < 4":
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.0.tgz#e69e38a1babe969b0108207978b9f62b88604839"
-  integrity sha1-5p44obq+lpsBCCB5eLn2K4hgSDk=
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f"
+  integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
   dependencies:
     any-promise "^1.0.0"
 
-through2-filter@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-2.0.0.tgz#60bc55a0dacb76085db1f9dae99ab43f83d622ec"
-  integrity sha1-YLxVoNrLdghdsfna6Zq0P4PWIuw=
+tmp@0.0.33, tmp@0.0.x:
+  version "0.0.33"
+  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
+  integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
   dependencies:
-    through2 "~2.0.0"
-    xtend "~4.0.0"
-
-through2-filter@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254"
-  integrity sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==
-  dependencies:
-    through2 "~2.0.0"
-    xtend "~4.0.0"
-
-through2@^0.6.0:
-  version "0.6.5"
-  resolved "https://registry.yarnpkg.com/through2/-/through2-0.6.5.tgz#41ab9c67b29d57209071410e1d7a7a968cd3ad48"
-  integrity sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=
-  dependencies:
-    readable-stream ">=1.0.33-1 <1.1.0-0"
-    xtend ">=4.0.0 <4.1.0-0"
-
-through2@^2.0.0, through2@^2.0.1, through2@~2.0.0:
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
-  integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==
-  dependencies:
-    readable-stream "~2.3.6"
-    xtend "~4.0.1"
-
-timed-out@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
-  integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=
-
-to-absolute-glob@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-0.1.1.tgz#1cdfa472a9ef50c239ee66999b662ca0eb39937f"
-  integrity sha1-HN+kcqnvUMI57maZm2YsoOs5k38=
-  dependencies:
-    extend-shallow "^2.0.1"
+    os-tmpdir "~1.0.2"
 
 to-array@0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
   integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA=
 
-to-fast-properties@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"
-  integrity sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=
-
 to-fast-properties@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
   integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
 
-to-object-path@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"
-  integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=
+to-regex-range@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+  integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
   dependencies:
-    kind-of "^3.0.2"
-
-to-regex-range@^2.1.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38"
-  integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=
-  dependencies:
-    is-number "^3.0.0"
-    repeat-string "^1.6.1"
-
-to-regex@^3.0.1, to-regex@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce"
-  integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==
-  dependencies:
-    define-property "^2.0.2"
-    extend-shallow "^3.0.2"
-    regex-not "^1.0.2"
-    safe-regex "^1.1.0"
+    is-number "^7.0.0"
 
 toidentifier@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
   integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
 
-tough-cookie@~2.4.3:
-  version "2.4.3"
-  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
-  integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==
+tough-cookie@~2.5.0:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
+  integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
   dependencies:
-    psl "^1.1.24"
-    punycode "^1.4.1"
+    psl "^1.1.28"
+    punycode "^2.1.1"
 
 tr46@^1.0.1:
   version "1.0.1"
@@ -6213,20 +3991,15 @@
   dependencies:
     punycode "^2.1.0"
 
-trim-newlines@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
-  integrity sha1-WIeWa7WCpFA6QetST301ARgVphM=
+tslib@^1.11.1:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
+  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
 
-trim-right@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
-  integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=
-
-triple-beam@^1.2.0, triple-beam@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
-  integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
+tsscmp@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
+  integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
 
 tunnel-agent@^0.6.0:
   version "0.6.0"
@@ -6240,22 +4013,12 @@
   resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
   integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
 
-type-detect@0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-0.1.1.tgz#0ba5ec2a885640e470ea4e8505971900dac58822"
-  integrity sha1-C6XsKohWQORw6k6FBZcZANrFiCI=
-
-type-detect@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-1.0.0.tgz#762217cc06db258ec48908a1298e8b95121e8ea2"
-  integrity sha1-diIXzAbbJY7EiQihKY6LlRIejqI=
-
-type-detect@^4.0.0, type-detect@^4.0.5:
+type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
   integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
 
-type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
+type-is@^1.6.16, type-is@~1.6.17:
   version "1.6.18"
   resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
   integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
@@ -6263,43 +4026,25 @@
     media-typer "0.3.0"
     mime-types "~2.1.24"
 
-typedarray@^0.0.6:
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
-  integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
-
-typical@^2.6.1:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/typical/-/typical-2.6.1.tgz#5c080e5d661cbbe38259d2e70a3c7253e873881d"
-  integrity sha1-XAgOXWYcu+OCWdLnCjxyU+hziB0=
-
 typical@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
   integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
 
-ua-parser-js@^0.7.15:
-  version "0.7.21"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777"
-  integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ==
+typical@^5.0.0, typical@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066"
+  integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
 
-uglify-js@3.4.x:
-  version "3.4.10"
-  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.10.tgz#9ad9563d8eb3acdfb8d38597d2af1d815f6a755f"
-  integrity sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw==
-  dependencies:
-    commander "~2.19.0"
-    source-map "~0.6.1"
+uglify-js@^3.5.1:
+  version "3.10.0"
+  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.10.0.tgz#397a7e6e31ce820bfd1cb55b804ee140c587a9e7"
+  integrity sha512-Esj5HG5WAyrLIdYU74Z3JdG2PxdIusvj6IWHMtlyESxc7kcDz7zYlYjpnSokn1UbpV0d/QX9fan7gkCNd/9BQA==
 
-underscore@^1.8.3:
-  version "1.9.2"
-  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.2.tgz#0c8d6f536d6f378a5af264a72f7bec50feb7cf2f"
-  integrity sha512-D39qtimx0c1fI3ya1Lnhk3E9nONswSKhnffBI0gME9C99fYOkNi04xs8K6pePLhvl1frbDemkaBQ5ikWllR2HQ==
-
-underscore@~1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.6.0.tgz#8b38b10cacdef63337b8b24e4ff86d45aea529a8"
-  integrity sha1-izixDKze9jM3uLJOT/htRa6lKag=
+ultron@~1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c"
+  integrity sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==
 
 unicode-canonical-property-names-ecmascript@^1.0.4:
   version "1.0.4"
@@ -6314,82 +4059,26 @@
     unicode-canonical-property-names-ecmascript "^1.0.4"
     unicode-property-aliases-ecmascript "^1.0.4"
 
-unicode-match-property-value-ecmascript@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.1.0.tgz#5b4b426e08d13a80365e0d657ac7a6c1ec46a277"
-  integrity sha512-hDTHvaBk3RmFzvSl0UVrUmC3PuW9wKVnpoUDYH0JDkSIovzw+J5viQmeYHxVSBptubnr7PbH2e0fnpDRQnQl5g==
+unicode-match-property-value-ecmascript@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz#0d91f600eeeb3096aa962b1d6fc88876e64ea531"
+  integrity sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==
 
 unicode-property-aliases-ecmascript@^1.0.4:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.5.tgz#a9cc6cc7ce63a0a3023fc99e341b94431d405a57"
-  integrity sha512-L5RAqCfXqAwR3RriF8pM0lU0w4Ryf/GgzONwi6KnL1taJQa7x1TCxdJnILX59WIGOwR57IVxn7Nej0fz1Ny6fw==
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4"
+  integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==
 
-union-value@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
-  integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==
-  dependencies:
-    arr-union "^3.1.0"
-    get-value "^2.0.6"
-    is-extendable "^0.1.1"
-    set-value "^2.0.1"
-
-unique-stream@^2.0.2:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-2.3.1.tgz#c65d110e9a4adf9a6c5948b28053d9a8d04cbeac"
-  integrity sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A==
-  dependencies:
-    json-stable-stringify-without-jsonify "^1.0.1"
-    through2-filter "^3.0.0"
-
-unique-string@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a"
-  integrity sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=
-  dependencies:
-    crypto-random-string "^1.0.0"
+universalify@^0.1.0:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
+  integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
 
 unpipe@1.0.0, unpipe@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
   integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
 
-unset-value@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559"
-  integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=
-  dependencies:
-    has-value "^0.3.1"
-    isobject "^3.0.0"
-
-untildify@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/untildify/-/untildify-2.1.0.tgz#17eb2807987f76952e9c0485fc311d06a826a2e0"
-  integrity sha1-F+soB5h/dpUunASF/DEdBqgmouA=
-  dependencies:
-    os-homedir "^1.0.0"
-
-unzip-response@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
-  integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=
-
-update-notifier@^2.2.0, update-notifier@^2.3.0:
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6"
-  integrity sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw==
-  dependencies:
-    boxen "^1.2.1"
-    chalk "^2.0.1"
-    configstore "^3.0.0"
-    import-lazy "^2.1.0"
-    is-ci "^1.0.10"
-    is-installed-globally "^0.1.0"
-    is-npm "^1.0.0"
-    latest-version "^3.0.0"
-    semver-diff "^2.0.0"
-    xdg-basedir "^3.0.0"
-
 upper-case@^1.1.1:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598"
@@ -6402,58 +4091,28 @@
   dependencies:
     punycode "^2.1.0"
 
-urijs@^1.16.1, urijs@^1.19.1:
-  version "1.19.2"
-  resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.2.tgz#f9be09f00c4c5134b7cb3cf475c1dd394526265a"
-  integrity sha512-s/UIq9ap4JPZ7H1EB5ULo/aOUbWqfDi7FKzMC2Nz+0Si8GiT1rIEaprt8hy3Vy2Ex2aJPpOQv4P4DuOZ+K1c6w==
-
-urix@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
-  integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
-
-url-parse-lax@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73"
-  integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=
+useragent@2.3.0, useragent@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/useragent/-/useragent-2.3.0.tgz#217f943ad540cb2128658ab23fc960f6a88c9972"
+  integrity sha512-4AoH4pxuSvHCjqLO04sU6U/uE65BYza8l/KKBS0b0hnUPWi+cQ2BpeTEwejCSx9SPV5/U03nniDTrWx5NrmKdw==
   dependencies:
-    prepend-http "^1.0.1"
-
-use@^3.1.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
-  integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
-
-util-deprecate@^1.0.1, util-deprecate@~1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
-  integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
-
-"util@>=0.10.3 <1":
-  version "0.12.1"
-  resolved "https://registry.yarnpkg.com/util/-/util-0.12.1.tgz#f908e7b633e7396c764e694dd14e716256ce8ade"
-  integrity sha512-MREAtYOp+GTt9/+kwf00IYoHZyjM8VU4aVrkzUlejyqaIjd2GztVl5V9hGXKlvBKE3gENn/FMfHE5v6hElXGcQ==
-  dependencies:
-    inherits "^2.0.3"
-    is-arguments "^1.0.4"
-    is-generator-function "^1.0.7"
-    object.entries "^1.1.0"
-    safe-buffer "^5.1.2"
+    lru-cache "4.1.x"
+    tmp "0.0.x"
 
 utils-merge@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
   integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
 
-uuid@^3.2.1, uuid@^3.3.2:
+uuid@^3.3.2:
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
 
-vali-date@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/vali-date/-/vali-date-1.0.0.tgz#1b904a59609fb328ef078138420934f6b86709a6"
-  integrity sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=
+valid-url@^1.0.9:
+  version "1.0.9"
+  resolved "https://registry.yarnpkg.com/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200"
+  integrity sha1-HBRHm0DxOXp1eC8RXkCGRHQzogA=
 
 validate-npm-package-license@^3.0.1:
   version "3.0.4"
@@ -6463,12 +4122,7 @@
     spdx-correct "^3.0.0"
     spdx-expression-parse "^3.0.0"
 
-vargs@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/vargs/-/vargs-0.1.0.tgz#6b6184da6520cc3204ce1b407cac26d92609ebff"
-  integrity sha1-a2GE2mUgzDIEzhtAfKwm2SYJ6/8=
-
-vary@^1, vary@~1.1.2:
+vary@^1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
   integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
@@ -6482,159 +4136,25 @@
     core-util-is "1.0.2"
     extsprintf "^1.2.0"
 
-vinyl-fs@^2.4.4:
-  version "2.4.4"
-  resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-2.4.4.tgz#be6ff3270cb55dfd7d3063640de81f25d7532239"
-  integrity sha1-vm/zJwy1Xf19MGNkDegfJddTIjk=
-  dependencies:
-    duplexify "^3.2.0"
-    glob-stream "^5.3.2"
-    graceful-fs "^4.0.0"
-    gulp-sourcemaps "1.6.0"
-    is-valid-glob "^0.3.0"
-    lazystream "^1.0.0"
-    lodash.isequal "^4.0.0"
-    merge-stream "^1.0.0"
-    mkdirp "^0.5.0"
-    object-assign "^4.0.0"
-    readable-stream "^2.0.4"
-    strip-bom "^2.0.0"
-    strip-bom-stream "^1.0.0"
-    through2 "^2.0.0"
-    through2-filter "^2.0.0"
-    vali-date "^1.0.0"
-    vinyl "^1.0.0"
-
-vinyl@^1.0.0, vinyl@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-1.2.0.tgz#5c88036cf565e5df05558bfc911f8656df218884"
-  integrity sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=
-  dependencies:
-    clone "^1.0.0"
-    clone-stats "^0.0.1"
-    replace-ext "0.0.1"
-
-vlq@^0.2.2:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"
-  integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==
-
-vscode-uri@=1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-1.0.6.tgz#6b8f141b0bbc44ad7b07e94f82f168ac7608ad4d"
-  integrity sha512-sLI2L0uGov3wKVb9EB+vIQBl9tVP90nqRvxSoJ35vI3NjxE8jfsE5DSOhWgSunHSZmKS4OCi2jrtfxK7uyp2ww==
-
-wbuf@^1.1.0, wbuf@^1.7.2:
-  version "1.7.3"
-  resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df"
-  integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==
-  dependencies:
-    minimalistic-assert "^1.0.0"
-
-wct-browser-legacy@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/wct-browser-legacy/-/wct-browser-legacy-1.0.2.tgz#6be39174bd37e2903028d3dbd2292f9c4ec59767"
-  integrity sha512-23rbZwBh/DxWU36htJN9lsyBq3NxgVbuyMUq7fgFP6ZVTel+uFWO6LPXPoZQ6VyvXvlUYLE5PxY+ZdJ88a4COw==
-  dependencies:
-    "@polymer/polymer" "^3.0.0"
-    "@polymer/sinonjs" "^1.14.1"
-    "@polymer/test-fixture" "^3.0.0-pre.1"
-    "@webcomponents/webcomponentsjs" "^2.0.0"
-    accessibility-developer-tools "^2.12.0"
-    async "^1.5.2"
-    chai "^3.5.0"
-    lodash "^3.10.1"
-    mocha "^3.4.2"
-    sinon "^1.17.1"
-    sinon-chai "^2.10.0"
-    stacky "^1.3.1"
-
-wct-local@^2.1.1:
-  version "2.1.5"
-  resolved "https://registry.yarnpkg.com/wct-local/-/wct-local-2.1.5.tgz#f7986753e3ad9a35d39178a9989350523561fff1"
-  integrity sha512-eqoZhjGy4Xq2tY0uB46Grkw/ztq+/rC0ImbYKl62unFHXtOgal+kkvnxR3SLRFNM8ty9+ItgycPeH0IpTqVL+w==
-  dependencies:
-    "@types/express" "^4.0.30"
-    "@types/freeport" "^1.0.19"
-    "@types/launchpad" "^0.6.0"
-    "@types/which" "^1.3.1"
-    chalk "^2.3.0"
-    cleankill "^2.0.0"
-    freeport "^1.0.4"
-    launchpad "^0.7.0"
-    selenium-standalone "^6.7.0"
-    which "^1.0.8"
-
-wct-sauce@^2.0.2:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/wct-sauce/-/wct-sauce-2.1.0.tgz#67d0be346aabbbc28384e8d143b8d3ca7ba774c0"
-  integrity sha512-c3R4PJcbpS7Gxv2vZ4HDAqpXV6cT9peslAWMU7hHH9PMhKDPbn8RNa6E4DVL0tOmZznB+3cRmtZ6+vJ/aDwu1A==
-  dependencies:
-    chalk "^2.4.1"
-    cleankill "^2.0.0"
-    lodash "^4.17.10"
-    request "^2.85.0"
-    sauce-connect-launcher "^1.0.0"
-    temp "^0.8.1"
-    uuid "^3.2.1"
-
-wd@^1.2.0:
-  version "1.12.1"
-  resolved "https://registry.yarnpkg.com/wd/-/wd-1.12.1.tgz#067eb3674db00eeb9e506701f9314657c44d5a89"
-  integrity sha512-O99X8OnOgkqfmsPyLIRzG9LmZ+rjmdGFBCyhGpnsSL4MB4xzHoeWmSVcumDiQ5QqPZcwGkszTgeJvjk2VjtiNw==
-  dependencies:
-    archiver "^3.0.0"
-    async "^2.0.0"
-    lodash "^4.0.0"
-    mkdirp "^0.5.1"
-    q "^1.5.1"
-    request "2.88.0"
-    vargs "^0.1.0"
-
-web-component-tester@^6.9.2:
-  version "6.9.2"
-  resolved "https://registry.yarnpkg.com/web-component-tester/-/web-component-tester-6.9.2.tgz#40a7b824f2cf3cbc4305552bdfc3357977ded48a"
-  integrity sha512-s2kB/+IE8XWcnxY1fqSpqTiiHEGHWgUWariAbiRlxmAvWSuvaCVNALHYebsZrLCNCLHKcJR8/sGv/bw0MWMvjw==
-  dependencies:
-    "@polymer/sinonjs" "^1.14.1"
-    "@polymer/test-fixture" "^0.0.3"
-    "@webcomponents/webcomponentsjs" "^1.0.7"
-    accessibility-developer-tools "^2.12.0"
-    async "^2.4.1"
-    body-parser "^1.17.2"
-    bower-config "^1.4.0"
-    chalk "^1.1.3"
-    cleankill "^2.0.0"
-    express "^4.15.3"
-    findup-sync "^2.0.0"
-    glob "^7.1.2"
-    lodash "^3.10.1"
-    multer "^1.3.0"
-    nomnom "^1.8.1"
-    polyserve "^0.27.13"
-    resolve "^1.5.0"
-    semver "^5.3.0"
-    send "^0.16.1"
-    server-destroy "^1.0.1"
-    sinon "^2.3.5"
-    sinon-chai "^2.10.0"
-    socket.io "^2.0.3"
-    stacky "^1.3.1"
-    wd "^1.2.0"
-  optionalDependencies:
-    update-notifier "^2.2.0"
-    wct-local "^2.1.1"
-    wct-sauce "^2.0.2"
+void-elements@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
+  integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=
 
 webidl-conversions@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
   integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
 
-whatwg-url@^6.4.0:
-  version "6.5.0"
-  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8"
-  integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==
+whatwg-fetch@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.1.0.tgz#49d630cdfa308dba7f2819d49d09364f540dbcc6"
+  integrity sha512-pgmbsVWKpH9GxLXZmtdowDIqtb/rvPyjjQv3z9wLcmgWKFHilKnZD3ldgrOlwJoPGOUluQsRPWd52yVkPfmI1A==
+
+whatwg-url@^7.0.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06"
+  integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==
   dependencies:
     lodash.sortby "^4.7.0"
     tr46 "^1.0.1"
@@ -6645,7 +4165,7 @@
   resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
   integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
 
-which@1.3.1, which@^1.0.8, which@^1.2.14, which@^1.2.9, which@^1.3.1:
+which@1.3.1, which@^1.2.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
   integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
@@ -6659,48 +4179,18 @@
   dependencies:
     string-width "^1.0.2 || 2"
 
-widest-line@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc"
-  integrity sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA==
-  dependencies:
-    string-width "^2.1.1"
-
-winston-transport@^4.2.0, winston-transport@^4.3.0:
-  version "4.3.0"
-  resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.3.0.tgz#df68c0c202482c448d9b47313c07304c2d7c2c66"
-  integrity sha512-B2wPuwUi3vhzn/51Uukcao4dIduEiPOcOt9HJ3QeaXgkJ5Z7UwpBzxS4ZGNHtrxrUvTwemsQiSys0ihOf8Mp1A==
-  dependencies:
-    readable-stream "^2.3.6"
-    triple-beam "^1.2.0"
-
-winston@^3.0.0:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/winston/-/winston-3.2.1.tgz#63061377976c73584028be2490a1846055f77f07"
-  integrity sha512-zU6vgnS9dAWCEKg/QYigd6cgMVVNwyTzKs81XZtTFuRwJOcDdBg7AU0mXVyNbs7O5RH2zdv+BdNZUlx7mXPuOw==
-  dependencies:
-    async "^2.6.1"
-    diagnostics "^1.1.1"
-    is-stream "^1.1.0"
-    logform "^2.1.1"
-    one-time "0.0.4"
-    readable-stream "^3.1.1"
-    stack-trace "0.0.x"
-    triple-beam "^1.3.0"
-    winston-transport "^4.3.0"
-
 wordwrap@~0.0.2:
   version "0.0.3"
   resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
   integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc=
 
-wordwrapjs@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-3.0.0.tgz#c94c372894cadc6feb1a66bff64e1d9af92c5d1e"
-  integrity sha512-mO8XtqyPvykVCsrwj5MlOVWvSnCdT+C+QVbm6blradR7JExAhbkZ7hZ9A+9NUtwzSqrlUo9a67ws0EiILrvRpw==
+wordwrapjs@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.0.tgz#9aa9394155993476e831ba8e59fb5795ebde6800"
+  integrity sha512-Svqw723a3R34KvsMgpjFBYCgNOSdcW3mQFK4wIfhGQhtaFVOJmdYoXgi63ne3dTlWgatVcUc7t4HtQ/+bUVIzQ==
   dependencies:
-    reduce-flatten "^1.0.1"
-    typical "^2.6.1"
+    reduce-flatten "^2.0.0"
+    typical "^5.0.0"
 
 wrap-ansi@^5.1.0:
   version "5.1.0"
@@ -6716,52 +4206,20 @@
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
-write-file-atomic@^2.0.0:
-  version "2.4.3"
-  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.3.tgz#1fd2e9ae1df3e75b8d8c367443c692d4ca81f481"
-  integrity sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==
-  dependencies:
-    graceful-fs "^4.1.11"
-    imurmurhash "^0.1.4"
-    signal-exit "^3.0.2"
-
-ws@^7.1.2:
-  version "7.2.1"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.1.tgz#03ed52423cd744084b2cf42ed197c8b65a936b8e"
-  integrity sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A==
-
-ws@~6.1.0:
-  version "6.1.4"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.4.tgz#5b5c8800afab925e94ccb29d153c8d02c1776ef9"
-  integrity sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==
+ws@~3.3.1:
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-3.3.3.tgz#f1cf84fe2d5e901ebce94efaece785f187a228f2"
+  integrity sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==
   dependencies:
     async-limiter "~1.0.0"
-
-xdg-basedir@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
-  integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=
-
-xmlbuilder@8.2.2:
-  version "8.2.2"
-  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773"
-  integrity sha1-aSSGc0ELS6QuGmE2VR0pIjNap3M=
-
-xmldom@0.1.x:
-  version "0.1.31"
-  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
-  integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
+    safe-buffer "~5.1.0"
+    ultron "~1.1.0"
 
 xmlhttprequest-ssl@~1.5.4:
   version "1.5.5"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
   integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
 
-"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
-  integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
-
 y18n@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
@@ -6772,10 +4230,15 @@
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
   integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
 
-yargs-parser@13.1.1, yargs-parser@^13.1.1:
-  version "13.1.1"
-  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.1.tgz#d26058532aa06d365fe091f6a1fc06b2f7e5eca0"
-  integrity sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==
+yallist@^3.0.2:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
+  integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
+
+yargs-parser@13.1.2, yargs-parser@^13.1.2:
+  version "13.1.2"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38"
+  integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==
   dependencies:
     camelcase "^5.0.0"
     decamelize "^1.2.0"
@@ -6789,10 +4252,10 @@
     lodash "^4.17.15"
     yargs "^13.3.0"
 
-yargs@13.3.0, yargs@^13.3.0:
-  version "13.3.0"
-  resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83"
-  integrity sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA==
+yargs@13.3.2, yargs@^13.3.0:
+  version "13.3.2"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd"
+  integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==
   dependencies:
     cliui "^5.0.0"
     find-up "^3.0.0"
@@ -6803,26 +4266,14 @@
     string-width "^3.0.0"
     which-module "^2.0.0"
     y18n "^4.0.0"
-    yargs-parser "^13.1.1"
-
-yauzl@^2.10.0:
-  version "2.10.0"
-  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
-  integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=
-  dependencies:
-    buffer-crc32 "~0.2.3"
-    fd-slicer "~1.1.0"
+    yargs-parser "^13.1.2"
 
 yeast@0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
   integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
 
-zip-stream@^2.1.2:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-2.1.3.tgz#26cc4bdb93641a8590dd07112e1f77af1758865b"
-  integrity sha512-EkXc2JGcKhO5N5aZ7TmuNo45budRaFGHOmz24wtJR7znbNqDPmdZtUauKX6et8KAVseAMBOyWJqEpXcHTBsh7Q==
-  dependencies:
-    archiver-utils "^2.1.0"
-    compress-commons "^2.1.1"
-    readable-stream "^3.4.0"
+ylru@^1.2.0:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"
+  integrity sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==
diff --git a/prolog/gerrit_common.pl b/prolog/gerrit_common.pl
index e2857d0..407e5d6 100644
--- a/prolog/gerrit_common.pl
+++ b/prolog/gerrit_common.pl
@@ -429,3 +429,19 @@
 commit_message_matches(Pattern) :-
   commit_message(Msg),
   regex_matches(Pattern, Msg).
+
+
+%% member/2:
+%%
+:- public member/2.
+%%
+member(X,[X|_]).
+member(X,[Y|T]) :- member(X,T).
+
+%% includes_file/1:
+%%
+:- public includes_file/1.
+%%
+includes_file(File) :-
+  files(List),
+  member(File, List).
\ No newline at end of file
diff --git a/proto/cache.proto b/proto/cache.proto
index e157608..573bdf4 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -323,3 +323,169 @@
   repeated ProjectWatchProto project_watch_proto = 2;
   string user_preferences = 3;
 }
+
+// Serialized form of com.google.gerrit.entities.Project.
+// Next ID: 11
+message ProjectProto {
+  string name = 1;
+  string description = 2;
+  map<string, string> boolean_configs = 3;
+  string submit_type = 4; // ENUM as String
+  string state = 5; // ENUM as String
+  string parent = 6;
+  string max_object_size_limit = 7;
+  string default_dashboard = 8;
+  string local_default_dashboard = 9;
+  string config_ref_state = 10;
+}
+
+// Serialized form of com.google.gerrit.common.data.GroupReference.
+// Next ID: 3
+message GroupReferenceProto {
+  string uuid = 1;
+  string name = 2;
+}
+
+// Serialized form of com.google.gerrit.common.data.PermissionRule.
+// Next ID: 6
+message PermissionRuleProto {
+  string action = 1; // ENUM as String
+  bool force = 2;
+  int32 min = 3;
+  int32 max = 4;
+  GroupReferenceProto group = 5;
+}
+
+// Serialized form of com.google.gerrit.common.data.Permission.
+// Next ID: 4
+message PermissionProto {
+  string name = 1;
+  bool exclusive_group = 2;
+  repeated PermissionRuleProto rules = 3;
+}
+
+// Serialized form of com.google.gerrit.common.data.AccessSection.
+// Next ID: 3
+message AccessSectionProto {
+  string name = 1;
+  repeated PermissionProto permissions = 2;
+}
+
+// Serialized form of com.google.gerrit.server.git.BranchOrderSection.
+// Next ID: 2
+message BranchOrderSectionProto {
+  repeated string branches_in_order = 1;
+}
+
+// Serialized form of com.google.gerrit.common.data.ContributorAgreement.
+// Next ID: 8
+message ContributorAgreementProto {
+  string name = 1;
+  string description = 2;
+  repeated PermissionRuleProto accepted = 3;
+  GroupReferenceProto auto_verify = 4;
+  string url = 5;
+  repeated string exclude_regular_expressions = 6;
+  repeated string match_regular_expressions = 7;
+}
+
+// Serialized form of com.google.gerrit.entities.Address.
+// Next ID: 3
+message AddressProto {
+  string name = 1;
+  string email = 2;
+}
+
+// Serialized form of com.google.gerrit.entities.NotifyConfig.
+// Next ID: 7
+message NotifyConfigProto {
+  string name = 1;
+  repeated string type = 2; // ENUM as String
+  string filter = 3;
+  string header = 4; // ENUM as String
+  repeated GroupReferenceProto groups = 5;
+  repeated AddressProto addresses = 6;
+}
+
+// Serialized form of com.google.gerrit.entities.LabelValue.
+// Next ID: 3
+message LabelValueProto {
+  string text = 1;
+  int32 value = 2;
+}
+
+// Serialized form of com.google.gerrit.common.data.LabelType.
+// Next ID: 19
+message LabelTypeProto {
+  string name = 1;
+  string function = 2; // ENUM as String
+  bool copy_any_score = 3;
+  bool copy_min_score = 4;
+  bool copy_max_score = 5;
+  bool copy_all_scores_on_merge_first_parent_update = 6;
+  bool copy_all_scores_on_trivial_rebase = 7;
+  bool copy_all_scores_if_no_code_change = 8;
+  bool copy_all_scores_if_no_change = 9;
+  repeated int32 copy_values = 10;
+  bool allow_post_submit = 11;
+  bool ignore_self_approval = 12;
+  int32 default_value = 13;
+  repeated LabelValueProto values = 14;
+  int32 max_negative = 15;
+  int32 max_positive = 16;
+  bool can_override = 17;
+  repeated string ref_patterns = 18;
+}
+
+// Serialized form of com.google.gerrit.server.project.ConfiguredMimeTypes.
+// Next ID: 4
+message ConfiguredMimeTypeProto {
+  string type = 1;
+  string pattern = 2;
+  bool is_regular_expression = 3;
+}
+
+// Serialized form of com.google.gerrit.common.data.SubscribeSection.
+// Next ID: 4
+message SubscribeSectionProto {
+  string project_name = 1;
+  repeated string multi_match_ref_specs = 2;
+  repeated string matching_ref_specs = 3;
+}
+
+// Serialized form of com.google.gerrit.entities.StoredCommentLinkInfo.
+// Next ID: 7
+message StoredCommentLinkInfoProto {
+  string name = 1;
+  string match = 2;
+  string link = 3;
+  string html = 4;
+  bool enabled = 5;
+  bool override_only = 6;
+}
+
+// Serialized form of com.google.gerrit.entities.CachedProjectConfigProto.
+// Next ID: 17
+message CachedProjectConfigProto {
+  ProjectProto project = 1;
+  repeated GroupReferenceProto group_list = 2;
+  repeated PermissionRuleProto accounts_section = 3;
+  repeated AccessSectionProto access_sections = 4;
+  BranchOrderSectionProto branch_order_section = 5;
+  repeated ContributorAgreementProto contributor_agreements = 6;
+  repeated NotifyConfigProto notify_configs = 7;
+  repeated LabelTypeProto label_sections = 8;
+  repeated ConfiguredMimeTypeProto mime_types = 9;
+  repeated SubscribeSectionProto subscribe_sections = 10;
+  repeated StoredCommentLinkInfoProto comment_links = 11;
+  bytes rules_id = 12;
+  bytes revision = 13;
+  int64 max_object_size_limit = 14;
+  bool check_received_objects = 15;
+  map<string, ExtensionPanelSectionProto> extension_panels = 16;
+
+  // Next ID: 2
+  message ExtensionPanelSectionProto {
+    repeated string section = 1;
+  }
+}
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index d162714..93d9461 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -102,6 +102,7 @@
     {if $userIsAuthenticated}
       <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     {/if}
+    <link rel="preload" href="{$staticResourcePath}/bower_components/highlightjs/highlight.min.js" as="script"/>
   {/if}
 
   {if $useGoogleFonts}
@@ -139,7 +140,7 @@
   // Content between webcomponents-lite and the load of the main app element
   // run before polymer-resin is installed so may have security consequences.
   // Contact your local security engineer if you have any questions, and
-  // CC them on any changes that load content before gr-app.html.
+  // CC them on any changes that load content before gr-app.js.
   //
   // github.com/Polymer/polymer-resin/blob/master/getting-started.md#integrating
   {if $assetsPath and $assetsBundle}
diff --git a/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy b/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
new file mode 100644
index 0000000..5ea41b2
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
@@ -0,0 +1,46 @@
+/**
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .AddToAttentionSet template will determine the contents of the email related to a
+ * user being added to the attention set.
+ */
+{template .AddToAttentionSet kind="text"}
+  {@param change: ?}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {@param attentionSetUser: ?}
+  {@param reason: ?}
+  {if $fromName == $attentionSetUser}
+  {$fromName} added themselves to the attention set of this change.
+  {else}
+  {$fromName} requires the attention of {$attentionSetUser} to this change.
+  {/if}
+  {\n} The reason is: {$reason}.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy b/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
new file mode 100644
index 0000000..bac180a
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
@@ -0,0 +1,43 @@
+/**
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .AddToAttentionSetHtml}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {@param attentionSetUser: ?}
+  {@param reason: ?}
+  <p>
+    {if $fromName == $attentionSetUser}
+      {$fromName} added themselves to the attention set of this change.
+    {else}
+      {$fromName} requires the attention of {$attentionSetUser} to this change.
+    {/if}
+    {\n} The reason is: {$reason}.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    <div style="white-space:pre-wrap">{$coverLetter}</div>
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 1eb016b..fc92b31 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -41,7 +41,7 @@
   {for $group in $commentFiles}
     // Insert a space before the newline so that Gmail does not mistakenly link
     // the following line with the file link. See issue 9201.
-    {$group.link}{sp}{\n}
+    {if $group.link}{$group.link}{sp}{/if}{\n}
     {$group.title}:{\n}
     {\n}
 
diff --git a/resources/com/google/gerrit/server/mail/CommentHtml.soy b/resources/com/google/gerrit/server/mail/CommentHtml.soy
index 534cbdb..617c8d17 100644
--- a/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -111,7 +111,9 @@
     {for $group in $commentFiles}
       <li style="{$fileLiStyle}">
         <p>
-          <a href="{$group.link}">{$group.title}:</a>
+          {if $group.link}<a href="{$group.link}">{/if}
+          {$group.title}:
+          {if $group.link}</a>{/if}
         </p>
 
         <ul style="{$ulStyle}">
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
new file mode 100644
index 0000000..033d145
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
@@ -0,0 +1,48 @@
+/**
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .RegisterNewEmailHtml}
+  {@param email: ?}
+  <p>
+    Welcome to Gerrit Code Review at {$email.gerritHost}.
+    To add a verified email address to your user account, please
+    click on the following link
+  </p>
+  {if $email.userNameEmail}
+    <p>
+        {sp}while signed in as {$email.userNameEmail}
+    </p>
+  {/if}:
+
+  <p>
+
+    {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}
+  </p>
+  <p>
+    If you have received this mail in error, you do not need to take any
+    action to cancel the account.  The address will not be activated, and
+    you will not receive any further emails.
+  </p>
+  <p>
+    If clicking the link above does not work, copy and paste the URL in a
+    new browser window instead.
+
+    This is a send-only email address.  Replies to this message will not
+    be read or answered.
+  </p>
+{/template}
\ No newline at end of file
diff --git a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
new file mode 100644
index 0000000..f116adb
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
@@ -0,0 +1,46 @@
+/**
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .RemoveFromAttentionSet template will determine the contents of the email related to a
+ * user being added to the attention set.
+ */
+{template .RemoveFromAttentionSet kind="text"}
+  {@param change: ?}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {@param attentionSetUser: ?}
+  {@param reason: ?}
+  {if $fromName == $attentionSetUser}
+  {$fromName} removed themselves from the attention set of this change.
+  {else}
+  {$fromName} doesn't require the attention of {$attentionSetUser} to this change.
+  {/if}
+  {\n} The reason is: {$reason}.
+  {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {\n}
+  Change subject: {$change.subject}{\n}
+  ......................................................................{\n}
+  {if $coverLetter}
+    {\n}
+    {\n}
+    {$coverLetter}
+    {\n}
+  {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
new file mode 100644
index 0000000..55eef13
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
@@ -0,0 +1,43 @@
+/**
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .RemoveFromAttentionSetHtml}
+  {@param coverLetter: ?}
+  {@param email: ?}
+  {@param fromName: ?}
+  {@param attentionSetUser: ?}
+  {@param reason: ?}
+  <p>
+    {if $fromName == $attentionSetUser}
+      {$fromName} removed themselves from the attention set of this change.
+    {else}
+      {$fromName} doesn't require the attention of {$attentionSetUser} to this change.
+    {/if}
+    {\n} The reason is: {$reason}.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  {if $coverLetter}
+    <div style="white-space:pre-wrap">{$coverLetter}</div>
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index 5440b88..bbb1432 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -1,4 +1,4 @@
-load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_binary", "closure_js_library")
+load("@npm//@bazel/terser:index.bzl", "terser_minified")
 load("//lib/js:npm.bzl", "NPM_SHA1S", "NPM_VERSIONS")
 
 NPMJS = "NPMJS"
@@ -439,7 +439,7 @@
     """Combine html, js, css files and optionally split into js and html bundles."""
     _bundle_rule(pkg = native.package_name(), *args, **kwargs)
 
-def polygerrit_plugin(name, app, srcs = [], deps = [], externs = [], assets = None, plugin_name = None, **kwargs):
+def polygerrit_plugin(name, app, srcs = [], deps = [], assets = None, plugin_name = None, **kwargs):
     """Bundles plugin dependencies for deployment.
 
     This rule bundles all Polymer elements and JS dependencies into .html and .js files.
@@ -449,7 +449,6 @@
     Args:
       name: String, rule name.
       app: String, the main or root source file.
-      externs: Fileset, external definitions that should not be bundled.
       assets: Fileset, additional files to be used by plugin in runtime, exported to "plugins/${name}/static".
       plugin_name: String, plugin name. ${name} is used if not provided.
     """
@@ -473,29 +472,15 @@
     else:
         js_srcs = srcs
 
-    closure_js_library(
-        name = name + "_closure_lib",
-        srcs = js_srcs + externs,
-        convention = "GOOGLE",
-        no_closure_library = True,
-        deps = [
-            "//lib/polymer_externs:polymer_closure",
-            "//polygerrit-ui/app/externs:plugin",
-        ],
+    native.filegroup(
+        name = name + "-src-fg",
+        srcs = js_srcs,
     )
 
-    closure_js_binary(
-        name = name + "_bin",
-        compilation_level = "WHITESPACE_ONLY",
-        defs = [
-            "--polymer_version=2",
-            "--language_out=ECMASCRIPT_2017",
-            "--rewrite_polyfills=false",
-        ],
-        deps = [
-            name + "_closure_lib",
-        ],
-        dependency_mode = "PRUNE_LEGACY",
+    terser_minified(
+        name = name + ".min",
+        sourcemap = False,
+        src = name + "-src-fg",
     )
 
     if html_plugin:
@@ -519,7 +504,7 @@
 
     native.genrule(
         name = name + "_rename_js",
-        srcs = [name + "_bin.js"],
+        srcs = [name + ".min"],
         outs = [plugin_name + ".js"],
         cmd = "cp $< $@",
         output_to_bindir = True,
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 7534501..d445be2 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -2,6 +2,8 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//:version.bzl", "GERRIT_VERSION")
 
+IN_TREE_BUILD_MODE = True
+
 PLUGIN_DEPS = ["//plugins:plugin-lib"]
 
 PLUGIN_DEPS_NEVERLINK = ["//plugins:plugin-lib-neverlink"]
diff --git a/tools/dev-hooks/pre-commit b/tools/dev-hooks/pre-commit
deleted file mode 100755
index af87b7e..0000000
--- a/tools/dev-hooks/pre-commit
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/bin/sh
-#
-# Part of Gerrit Code Review (https://www.gerritcodereview.com/)
-#
-# Copyright (C) 2019 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-
-# To enable this hook:
-# - copy this file or content to ".git/hooks/pre-commit"
-# - (optional if you copied this file) make it executable: `chmod +x .git/hooks/pre-commit`
-
-set -ue
-
-# gitroot, default to .
-gitroot=$(git rev-parse --show-cdup)
-gitroot=${gitroot:-.};
-
-# eslint
-eslint=${gitroot}/node_modules/eslint/bin/eslint.js
-
-# Run eslint over changed frontend code
-CHANGED_UI_FILES=$(git diff --cached --name-only --diff-filter=ACM -- '*.js' '*.html' | grep 'polygerrit-ui') && true
-if [ "${CHANGED_UI_FILES}" ]; then
-  if $eslint --fix ${CHANGED_UI_FILES}; then
-    # Add again in case lint fix modified some files
-    git add ${CHANGED_UI_FILES}
-    exit 0
-  else
-    echo "Failed to fix all linter issues.";
-    exit 1
-  fi
-else
-  echo "No UI files changed"
-  exit 0
-fi
\ No newline at end of file
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index f360fa5..b1d5242 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -240,7 +240,8 @@
             # Exceptions: both source and lib
             if p.endswith('libquery_parser.jar') or \
                p.endswith('libgerrit-prolog-common.jar') or \
-               p.endswith('com_google_protobuf/libprotobuf_java.jar') or \
+               p.endswith('external/com_google_protobuf/java/core/libcore.jar') or \
+               p.endswith('external/com_google_protobuf/java/core/liblite.jar') or \
                p.endswith('lucene-core-and-backward-codecs-merged_deploy.jar'):
                 lib.add(p)
             if proto_library.match(p) :
diff --git a/tools/js/eslint-rules/BUILD b/tools/js/eslint-rules/BUILD
new file mode 100644
index 0000000..476c4ff
--- /dev/null
+++ b/tools/js/eslint-rules/BUILD
@@ -0,0 +1,11 @@
+package(default_visibility = ["//visibility:public"])
+
+# To load eslint rules from a directory, we must pass a directory
+# name to it. We can't get the directory name in bazel, but we can calculate
+# use a file from this directory. We are using README.md for it.
+exports_files(["README.md"])
+
+filegroup(
+    name = "eslint-rules-srcs",
+    srcs = glob(["**/*.js"]),
+)
diff --git a/tools/js/eslint-rules/README.md b/tools/js/eslint-rules/README.md
new file mode 100644
index 0000000..b425d74
--- /dev/null
+++ b/tools/js/eslint-rules/README.md
@@ -0,0 +1,74 @@
+# Eslint rules for polygerrit
+This directory contains custom eslint rules for polygerrit.
+
+## ts-imports-js
+This rule must be used only for `.ts` files.
+The rule ensures that:
+* All import paths either a relative paths or module imports.
+```typescript
+// Correct imports
+import './file1'; // relative path
+import '../abc/file2'; // relative path
+import 'module_name/xyz'; // import from the module_name
+
+// Incorrect imports
+import '/usr/home/file3'; // absolute path
+```
+* All *relative* import paths has a short form (i.e. don't include extension):
+```typescript
+// Correct imports
+import './file1'; // relative path without extension
+import data from 'module_name/file2.json'; // file in a module, can be anything
+
+// Incorrect imports
+import './file1.js'; // relative path with extension
+```
+
+* Imported `.js` and `.d.ts` files both exists (only for a relative import path):
+
+Example:
+```
+polygerrit-ui/app
+ |- ex.ts
+ |- abc
+     |- correct_ts.ts
+     |- correct_js.js
+     |- correct_js.d.ts
+     |- incorrect_1.js
+     |- incorrect_2.d.ts
+```
+```typescript
+// The ex.ts file:
+// Correct imports
+import {x} from './abc/correct_js'; // correct_js.js and correct_js.d.ts exist
+import {x} from './abc/correct_ts'; // import from .ts - d.ts is not required
+
+// Incorrect imports
+import {x} from './abc/incorrect_1'; // incorrect_1.d.ts doesn't exist
+import {x} from './abc/incorrect_2'; // incorrect_2.js doesn't exist
+```
+
+To fix the last two imports 2 files must be added: `incorrect_1.d.ts` and
+`incorrect_2.js`.
+
+## goog-module-id
+Enforce correct usage of goog.declareModuleId:
+* The goog.declareModuleId must be used only in `.js` files which have
+associated `.d.ts` files.
+* The module name is correct. The correct module name is constructed from the
+file path using the folowing rules
+rules:
+  1. Get part of the path after the '/polygerrit-ui/app/':
+    `/usr/home/gerrit/polygerrit-ui/app/elements/shared/x/y.js` ->
+    `elements/shared/x/y.js`
+  2. Discard `.js` extension and replace all `/` with `.`:
+    `elements/shared/x/y.js` -> `elements.shared.x.y`
+  3. Add `polygerrit.` prefix:
+    `elements.shared.x.y` -> `polygerrit.elements.shared.x.y`
+    The last string is a module name.
+
+Example:
+```javascript
+// polygerrit-ui/app/elements/shared/x/y.js
+goog.declareModuleId('polygerrit.elements.shared.x.y');
+```
diff --git a/tools/js/eslint-rules/goog-module-id.js b/tools/js/eslint-rules/goog-module-id.js
new file mode 100644
index 0000000..56cd645
--- /dev/null
+++ b/tools/js/eslint-rules/goog-module-id.js
@@ -0,0 +1,160 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const jsExt = '.js';
+
+class NonJsValidator {
+  onProgramEnd(context, node) {
+  }
+  onGoogDeclareModuleId(context, node) {
+    context.report({
+      message: 'goog.declareModuleId is allowed only in .js files',
+      node: node,
+    });
+  }
+}
+
+class JsOnlyValidator {
+  onProgramEnd(context, node) {
+  }
+  onGoogDeclareModuleId(context, node) {
+    context.report({
+      message: 'goog.declareModuleId present, but .d.ts file doesn\'t exist. '
+        + 'Either remove goog.declareModuleId or add the .d.ts file.',
+      node: node,
+    });
+  }
+}
+
+class JsWithDtsValidator {
+  constructor() {
+    this._googDeclareModuleIdExists = false;
+  }
+  onProgramEnd(context, node) {
+    if(!this._googDeclareModuleIdExists) {
+      context.report({
+        message: 'goog.declareModuleId(...) is missed. ' +
+            'Either add it or remove the associated .d.ts file.',
+        node: node,
+      })
+    }
+  }
+  onGoogDeclareModuleId(context, node) {
+    if(this._googDeclareModuleIdExists) {
+      context.report({
+        message: 'Duplicated goog.declareModuleId.',
+        node: node,
+      });
+      return;
+    }
+
+    const filename = context.getFilename();
+    this._googDeclareModuleIdExists = true;
+
+    const scope = context.getScope();
+    if(scope.type !== 'global' && scope.type !== 'module') {
+      context.report({
+        message: 'goog.declareModuleId is allowed only at the root level.',
+        node: node,
+      });
+      // no return - other problems are possible
+    }
+    if(node.arguments.length !== 1) {
+      context.report({
+        message: 'goog.declareModuleId must have exactly one parameter.',
+        node: node,
+      });
+      if(node.arguments.length === 0) {
+        return;
+      }
+    }
+
+    const argument = node.arguments[0];
+    if(argument.type !== 'Literal') {
+      context.report({
+        message: 'The argument for the declareModuleId method '
+            + 'must be a string literal.',
+        node: argument,
+      });
+      return;
+    }
+    const pathStart = '/polygerrit-ui/app/';
+    const index = filename.lastIndexOf(pathStart);
+    if(index < 0) {
+      context.report({
+        message: 'The file located outside of polygerrit-ui/app directory. ' +
+          'Please check eslint config.',
+        node: argument,
+      });
+      return;
+    }
+    const expectedName = 'polygerrit.' +
+        filename.slice(index + pathStart.length, -jsExt.length)
+            .replace(/\//g, '.') // Replace all occurrences of '/' with '.'
+            .replace(/-/g, '$2d'); // Replace all occurrences of '-' with '$2d'
+    if(argument.value !== expectedName) {
+      context.report({
+        message: `Invalid module id. It must be '${expectedName}'.`,
+        node: argument,
+        fix: function(fixer) {
+          return fixer.replaceText(argument, `'${expectedName}'`);
+        },
+      });
+    }
+  }
+}
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    docs: {
+      description: 'Check that goog.declareModuleId is valid',
+      category: 'TS imports JS errors',
+      recommended: false,
+    },
+    fixable: "code",
+    schema: [],
+  },
+  create: function (context) {
+    let fileValidator;
+    return {
+      Program: function(node) {
+        const filename = context.getFilename();
+        if(filename.endsWith(jsExt)) {
+          const dtsFilename = filename.slice(0, -jsExt.length) + ".d.ts";
+          if(fs.existsSync(dtsFilename)) {
+            fileValidator = new JsWithDtsValidator();
+          } else {
+            fileValidator = new JsOnlyValidator();
+          }
+        }
+        else {
+          fileValidator = new NonJsValidator();
+        }
+      },
+      "Program:exit": function(node) {
+        fileValidator.onProgramEnd(context, node);
+        fileValidator = null;
+      },
+      'ExpressionStatement > CallExpression[callee.property.name="declareModuleId"][callee.object.name="goog"]': function(node) {
+        fileValidator.onGoogDeclareModuleId(context, node);
+      }
+    };
+  },
+};
diff --git a/tools/js/eslint-rules/report-ts-error.js b/tools/js/eslint-rules/report-ts-error.js
new file mode 100644
index 0000000..48dddf4
--- /dev/null
+++ b/tools/js/eslint-rules/report-ts-error.js
@@ -0,0 +1,101 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// While we are migrating to typescript, gerrit can have .d.ts files.
+// The option "skipLibCheck" is set to true  In the tsconfig.json.
+// This is required, because we want to skip type checking in node_modules
+// directory - some .d.ts files in 3rd-party modules are incorrect.
+// Unfortunately, this options also excludes our own .d.ts files from type
+// checking. This rule reports all .ts errors in a file as tslint errors.
+
+function getMassageTextFromChain(chainNode, prefix) {
+  let nestedMessages = prefix + chainNode.messageText;
+  if (chainNode.next && chainNode.next.length > 0) {
+    nestedMessages += "\n";
+    for (const node of chainNode.next) {
+      nestedMessages +=
+          getMassageTextFromChain(node, prefix + " ");
+      if(!nestedMessages.endsWith('\n')) {
+        nestedMessages += "\n";
+      }
+    }
+  }
+  return nestedMessages;
+}
+
+function getMessageText(diagnostic) {
+  if (typeof diagnostic.messageText === 'string') {
+    return diagnostic.messageText;
+  }
+  return getMassageTextFromChain(diagnostic.messageText, "");
+}
+
+function getDiagnosticStartAndEnd(diagnostic) {
+  if(diagnostic.start) {
+    const file = diagnostic.file;
+    const start = file.getLineAndCharacterOfPosition(diagnostic.start);
+    const length = diagnostic.length ? diagnostic.length : 0;
+    return {
+      start,
+      end: file.getLineAndCharacterOfPosition(diagnostic.start + length),
+    };
+  }
+  return {
+    start: {line:0, character: 0},
+    end: {line:0, character: 0},
+  }
+}
+
+module.exports = {
+  meta: {
+    type: "problem",
+    docs: {
+      description: "Reports all typescript problems as linter problems",
+      category: ".d.ts",
+      recommended: false
+    },
+    schema: [],
+  },
+  create: function (context) {
+    const program = context.parserServices.program;
+    return {
+      Program: function(node) {
+        const sourceFile =
+            context.parserServices.esTreeNodeToTSNodeMap.get(node);
+        const allDiagnostics = [
+            ...program.getDeclarationDiagnostics(sourceFile),
+            ...program.getSemanticDiagnostics(sourceFile)];
+        for(const diagnostic of allDiagnostics) {
+          const {start, end } = getDiagnosticStartAndEnd(diagnostic);
+          context.report({
+            message: getMessageText(diagnostic),
+            loc: {
+              start: {
+                line: start.line + 1,
+                column: start.character,
+              },
+              end: {
+                line: end.line + 1,
+                column: end.character,
+              }
+            }
+          });
+        }
+      },
+    };
+  }
+};
diff --git a/tools/js/eslint-rules/ts-imports-js.js b/tools/js/eslint-rules/ts-imports-js.js
new file mode 100644
index 0000000..69155ea
--- /dev/null
+++ b/tools/js/eslint-rules/ts-imports-js.js
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const path = require('path');
+const fs = require('fs');
+
+function checkImportValid(context, node) {
+  const file = context.getFilename();
+  const importSource = node.source.value;
+
+  if(importSource.startsWith('/')) {
+    return {
+      message: 'Do not use absolute path for import.',
+    };
+  }
+  if(!importSource.startsWith('./') && !importSource.startsWith('../')) {
+    // Import from node_modules - nothing to check
+    return null;
+  }
+
+  const targetFile = path.resolve(path.dirname(file), importSource);
+  if(path.extname(targetFile) !== '') {
+    return {
+      message: 'Do not specify extensions for import path.'
+    };
+  }
+
+  if(fs.existsSync(targetFile + ".ts")) {
+    // .ts file exists - nothing to check
+    return null;
+  }
+
+  const jsFileExists = fs.existsSync(targetFile + '.js');
+  const dtsFileExists = fs.existsSync(targetFile + '.d.ts');
+
+  if(jsFileExists && !dtsFileExists) {
+    return {
+      message: `The '${importSource}.d.ts' file doesn't exist.`
+    };
+  }
+
+  if(!jsFileExists && dtsFileExists) {
+    return {
+      message: `The '${importSource}.js' file doesn't exist.`
+    };
+  }
+  // If both files (.js and .d.ts) don't exist, the error is reported by
+  // the typescript compiler. Do not report anything from the rule.
+  return null;
+}
+
+module.exports = {
+  meta: {
+    type: "problem",
+    docs: {
+      description: "Check that TS file can import specific JS file",
+      category: "TS imports JS errors",
+      recommended: false
+    },
+    schema: [],
+  },
+  create: function (context) {
+    return {
+      Program: function(node) {
+        const filename = context.getFilename();
+        if(filename.endsWith('.ts') && !filename.endsWith('.d.ts')) {
+          return;
+        }
+        context.report({
+          message: 'The rule must be used only with .ts files. ' +
+              'Check eslint settings.',
+          node: node,
+        });
+      },
+      ImportDeclaration: function (node) {
+        const importProblem = checkImportValid(context, node);
+        if(importProblem) {
+          context.report({
+            message: importProblem.message,
+            node: node.source,
+          });
+        }
+      }
+    };
+  }
+};
diff --git a/tools/js/eslint.bzl b/tools/js/eslint.bzl
index bd2bc32..586b1c5 100644
--- a/tools/js/eslint.bzl
+++ b/tools/js/eslint.bzl
@@ -40,10 +40,24 @@
             bazel run {name}_test -- --fix $(pwd)/polygerrit-ui/app
     """
     entry_point = "@npm//:node_modules/eslint/bin/eslint.js"
+
+    # There are custom eslint rules in eslint-rules directory. Eslint loads
+    # custom rules from a directory specified with the --rulesdir argument.
+    # When bazel runs eslint, it places the eslint-rules directory into
+    # some location in the filesystem, and the location is not known in advance.
+    # It is not possible to get the directory location in bazel directly.
+    # Instead, we can use dirname to get a directory for a file in the
+    # eslint-rules directory.
+    # README.md is the most "stable" file in the eslint-rules directory
+    # (i.e. it is unlikely will be removed), and we are using it to calculate
+    # exact directory path in bazel.
+    eslint_rules_toplevel_file = "//tools/js/eslint-rules:README.md"
     bin_data = [
         "@npm//eslint:eslint",
         config,
         ignore,
+        "//tools/js/eslint-rules:eslint-rules-srcs",
+        eslint_rules_toplevel_file,
     ] + plugins + data
     common_templated_args = [
         "--ext",
@@ -55,6 +69,9 @@
         "$$(rlocation $(rootpath {}))".format(config),
         "--ignore-path",
         "$$(rlocation $(rootpath {}))".format(ignore),
+        # Load custom rules from eslint-rules directory
+        "--rulesdir",
+        "$$(dirname $$(rlocation $(rootpath {})))".format(eslint_rules_toplevel_file),
     ]
     nodejs_test(
         name = name + "_test",
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index e9362e6..14c726e 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.2.4-SNAPSHOT</version>
+  <version>3.3.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index 3566043..bd323ba 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.2.4-SNAPSHOT</version>
+  <version>3.3.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 009f627e..3b059e5 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.2.4-SNAPSHOT</version>
+  <version>3.3.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index e67b4a6..b8fa132 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.2.4-SNAPSHOT</version>
+  <version>3.3.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/node_tools/BUILD b/tools/node_tools/BUILD
index 4019542..03e3a13 100644
--- a/tools/node_tools/BUILD
+++ b/tools/node_tools/BUILD
@@ -36,3 +36,12 @@
     # ts service in background). It works without any workaround.
     entry_point = "@tools_npm//:node_modules/@bazel/typescript/internal/tsc_wrapped/tsc_wrapped.js",
 )
+
+# Wrap a typescript into a tsc-bin binary.
+# The tsc-bin can be used as a tool to compile typescript code.
+nodejs_binary(
+    name = "tsc-bin",
+    # Point bazel to your node_modules to find the entry point
+    data = ["@tools_npm//:node_modules"],
+    entry_point = "@tools_npm//:node_modules/typescript/lib/tsc.js",
+)
diff --git a/tools/node_tools/node_modules_licenses/BUILD b/tools/node_tools/node_modules_licenses/BUILD
index bd7e854..581b3a9 100644
--- a/tools/node_tools/node_modules_licenses/BUILD
+++ b/tools/node_tools/node_modules_licenses/BUILD
@@ -1,6 +1,6 @@
-load("@npm_bazel_typescript//:index.bzl", "ts_library")
 load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
-load("@npm_bazel_rollup//:index.bzl", "rollup_bundle")
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
 
 package(default_visibility = ["//visibility:public"])
 
diff --git a/tools/node_tools/node_modules_licenses/tsconfig.json b/tools/node_tools/node_modules_licenses/tsconfig.json
index 2854857..2046c394 100644
--- a/tools/node_tools/node_modules_licenses/tsconfig.json
+++ b/tools/node_tools/node_modules_licenses/tsconfig.json
@@ -6,7 +6,7 @@
     "esModuleInterop": true,
     "strict": true,
     "moduleResolution": "node",
-    "outDir": "out",
+    "outDir": "../../../.ts-out/tools/node_modules_licenses", // Not used in bazel,
     "types": ["node"]
   },
   "include": ["*.ts"]
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index 2af06b4..67a85a4 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -3,8 +3,8 @@
   "description": "Gerrit Build Tools",
   "browser": false,
   "dependencies": {
-    "@bazel/rollup": "^0.41.0",
-    "@bazel/typescript": "^1.0.1",
+    "@bazel/rollup": "^2.0.0",
+    "@bazel/typescript": "^2.0.0",
     "@types/node": "^10.17.12",
     "@types/parse5": "^4.0.0",
     "@types/parse5-html-rewriting-stream": "^5.1.2",
@@ -16,7 +16,7 @@
     "rollup": "^1.27.5",
     "rollup-plugin-node-resolve": "^5.2.0",
     "rollup-plugin-terser": "^5.1.3",
-    "typescript": "^3.7.4"
+    "typescript": "3.8.2"
   },
   "devDependencies": {},
   "license": "Apache-2.0",
diff --git a/tools/node_tools/polygerrit_app_preprocessor/BUILD b/tools/node_tools/polygerrit_app_preprocessor/BUILD
index b031293..b5ee34f 100644
--- a/tools/node_tools/polygerrit_app_preprocessor/BUILD
+++ b/tools/node_tools/polygerrit_app_preprocessor/BUILD
@@ -1,6 +1,6 @@
-load("@npm_bazel_typescript//:index.bzl", "ts_library")
 load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
-load("@npm_bazel_rollup//:index.bzl", "rollup_bundle")
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
 
 package(default_visibility = ["//visibility:public"])
 
diff --git a/tools/node_tools/utils/BUILD b/tools/node_tools/utils/BUILD
index fca3c12..5c407ca 100644
--- a/tools/node_tools/utils/BUILD
+++ b/tools/node_tools/utils/BUILD
@@ -1,4 +1,4 @@
-load("@npm_bazel_typescript//:index.bzl", "ts_library")
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
 
 package(default_visibility = ["//visibility:public"])
 
diff --git a/tools/node_tools/utils/tsconfig.json b/tools/node_tools/utils/tsconfig.json
index 34ffb2f..56ab91b 100644
--- a/tools/node_tools/utils/tsconfig.json
+++ b/tools/node_tools/utils/tsconfig.json
@@ -6,7 +6,7 @@
     "esModuleInterop": true,
     "strict": true,
     "moduleResolution": "node",
-    "outDir": "out"
+    "outDir": "../../../.ts-out/tools/utils" // Not used in bazel
   },
   "include": ["*.ts"]
 }
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 0648c8d..a3ac4af 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -492,15 +492,15 @@
     lodash "^4.17.13"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^0.41.0":
-  version "0.41.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-0.41.0.tgz#8dfaccc239f3efbae1c816b0ce2aeb6069d23582"
-  integrity sha512-M+ybGfcxTXnAS1QiaijLEfUznNYLA0cqeGXnYHSRrOhq2U7yesfavxbBtfLSKtg32ktmlHts5te8Zg82BS4DPQ==
+"@bazel/rollup@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-2.0.0.tgz#1980cb3f6922227659260bfdca99c457889a5bc1"
+  integrity sha512-mifUfCZbD1RIhfowh4N8E4881ag3FChz7F4z35wxMOP52g1q3+6Bvh5wv9iysFQopxGmS5jNEj3Dq/CWtSoOnw==
 
-"@bazel/typescript@^1.0.1":
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-1.1.0.tgz#b57ac6c6d627577f394a60fb540fbbdf53bcff0d"
-  integrity sha512-QnTdb6rwZUR+KfUuAdyazpkA7BOvrWRe7tkPDdyIZHJdBPYdpJW+AapnFSfxvXEIP0Nwesl5KP6Saau0GPiBLg==
+"@bazel/typescript@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-2.0.0.tgz#2ff5615f09c733cc681ba2ada92b11c356b694cd"
+  integrity sha512-5FPkxULWIjAKLG5J1XvpXpY1/4IK39dAoWA/Hhg+16gXTES32fT8w42k96pb6BTaNnyBuYgIHBpELEAJ40OOAQ==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"
@@ -949,10 +949,15 @@
   resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.118.tgz#8014a9b1dee0b72b4d7cd142563f1af21241c3a2"
   integrity sha512-N33cKXGSqhOYaPiT4xUGsYlPPDwFtQM/6QxJxuMXA/7BcySW+lkn2yigWP7vfs4daiL/7NJNU6DMCqg5N4B+xQ==
 
-"@types/node@^10.1.0", "@types/node@^10.17.12":
-  version "10.17.13"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.13.tgz#ccebcdb990bd6139cd16e84c39dc2fb1023ca90c"
-  integrity sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg==
+"@types/node@^10.1.0":
+  version "10.17.27"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.27.tgz#391cb391c75646c8ad2a7b6ed3bbcee52d1bdf19"
+  integrity sha512-J0oqm9ZfAXaPdwNXMMgAhylw5fhmXkToJd06vuDUSAgEDZ/n/69/69UmyBZbc+zT34UnShuDSBqvim3SPnozJg==
+
+"@types/node@^10.17.12":
+  version "10.17.24"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.24.tgz#c57511e3a19c4b5e9692bb2995c40a3a52167944"
+  integrity sha512-5SCfvCxV74kzR3uWgTYiGxrd69TbT1I6+cMx1A5kEly/IVveJBimtAMlXiEyVFn5DvUFewQWxOOiJhlxeQwxgA==
 
 "@types/node@^4.0.30":
   version "4.9.4"
@@ -7829,7 +7834,12 @@
   resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
   integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
 
-tslib@^1.8.1, tslib@^1.9.0:
+tslib@^1.8.1:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
+  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
+
+tslib@^1.9.0:
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
   integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
@@ -7871,10 +7881,10 @@
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
-typescript@^3.7.4:
-  version "3.7.5"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae"
-  integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw==
+typescript@3.8.2:
+  version "3.8.2"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.2.tgz#91d6868aaead7da74f493c553aeff76c0c0b1d5a"
+  integrity sha512-EgOVgL/4xfVrCMbhYKUQTdF37SQn4Iw73H5BgCrF1Abdun7Kwy/QZsE/ssAy0y4LxBbvua3PIbFsbRczWWnDdQ==
 
 typical@^2.6.0, typical@^2.6.1:
   version "2.6.1"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 7995388..6f340f3 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -109,24 +109,24 @@
     # Google internal dependencies: these are developed at Google, so there is
     # no concern about version skew.
 
-    FLOGGER_VERS = "0.4"
+    FLOGGER_VERS = "0.5.1"
 
     maven_jar(
         name = "flogger",
         artifact = "com.google.flogger:flogger:" + FLOGGER_VERS,
-        sha1 = "9c8863dcc913b56291c0c88e6d4ca9715b43df98",
+        sha1 = "71d1e2cef9cc604800825583df56b8ef5c053f14",
     )
 
     maven_jar(
         name = "flogger-log4j-backend",
         artifact = "com.google.flogger:flogger-log4j-backend:" + FLOGGER_VERS,
-        sha1 = "17aa5e31daa1354187e14b6978597d630391c028",
+        sha1 = "5e2794b75c88223f263f1c1a9d7ea51e2dc45732",
     )
 
     maven_jar(
         name = "flogger-system-backend",
         artifact = "com.google.flogger:flogger-system-backend:" + FLOGGER_VERS,
-        sha1 = "287b569d76abcd82f9de87fe41829fbc7ebd8ac9",
+        sha1 = "b66d3bedb14da604828a8693bb24fd78e36b0e9e",
     )
 
     # Test-only dependencies below.
diff --git a/tools/workspace_status.py b/tools/workspace_status.py
index 86df519..443c2f0 100644
--- a/tools/workspace_status.py
+++ b/tools/workspace_status.py
@@ -36,9 +36,11 @@
 
 
 print("STABLE_BUILD_GERRIT_LABEL %s" % revision(ROOT, ROOT))
-for d in os.listdir(os.path.join(ROOT, 'plugins')):
-    p = os.path.join('plugins', d)
-    if os.path.isdir(p):
-        v = revision(p, ROOT)
-        print('STABLE_BUILD_%s_LABEL %s' % (os.path.basename(p).upper(),
-                                            v if v else 'unknown'))
+for kind in ['modules', 'plugins']:
+    kind_dir = os.path.join(ROOT, kind)
+    for d in os.listdir(kind_dir):
+        p = os.path.join(kind_dir, d)
+        if os.path.isdir(p):
+            v = revision(p, ROOT)
+            print('STABLE_BUILD_%s_LABEL %s' % (os.path.basename(p).upper(),
+                                                v if v else 'unknown'))
diff --git a/version.bzl b/version.bzl
index e41f0aa..78b286b 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.2.4-SNAPSHOT"
+GERRIT_VERSION = "3.3.0-SNAPSHOT"
diff --git a/yarn.lock b/yarn.lock
index 820cca3..a20b1c7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -485,15 +485,20 @@
     lodash "^4.17.11"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^1.1.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-1.2.0.tgz#8b9569ed6f1c00d2a833567901f8ee4600a389fb"
-  integrity sha512-yrXW+AAUoqc9qN/CweD5p8OEN9bNKFjXnXPBRE4w84LxpkmaJFx+yQJ++c1F57zWMoq2o9EV4CM7y+mK8zxwUg==
+"@bazel/rollup@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-2.0.0.tgz#1980cb3f6922227659260bfdca99c457889a5bc1"
+  integrity sha512-mifUfCZbD1RIhfowh4N8E4881ag3FChz7F4z35wxMOP52g1q3+6Bvh5wv9iysFQopxGmS5jNEj3Dq/CWtSoOnw==
 
-"@bazel/typescript@^1.0.1":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-1.2.0.tgz#ab2016e1d6eb7a86b44536e887f51eaf3d75f1a7"
-  integrity sha512-hPEG8K0psyEcs6HFRiqZNQwXL/dQ8sXKdrNFWv87+rh+YUNfd58uktoynhllympOPThcbUZcZicLWBEFQOc8nA==
+"@bazel/terser@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-2.0.0.tgz#a841db8aefd7c51c216b34a26bc02a6c93d5e56a"
+  integrity sha512-6mBYcfzP6pWxycYZ8r4Lz5kgiWZ7n08bVHZBIRExFeqs7Yy92dD92LPeA9FZIzFiX00IuR9Q1Lqy23xH5q7FeQ==
+
+"@bazel/typescript@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-2.0.0.tgz#2ff5615f09c733cc681ba2ada92b11c356b694cd"
+  integrity sha512-5FPkxULWIjAKLG5J1XvpXpY1/4IK39dAoWA/Hhg+16gXTES32fT8w42k96pb6BTaNnyBuYgIHBpELEAJ40OOAQ==
   dependencies:
     protobufjs "6.8.8"
     semver "5.6.0"
@@ -631,6 +636,18 @@
   resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
   integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=
 
+"@sindresorhus/is@^0.14.0":
+  version "0.14.0"
+  resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
+  integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==
+
+"@szmarczak/http-timer@^1.1.2":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
+  integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==
+  dependencies:
+    defer-to-connect "^1.0.1"
+
 "@types/babel-generator@^6.25.1":
   version "6.25.3"
   resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.3.tgz#8f06caa12d0595a0538560abe771966d77d29286"
@@ -706,6 +723,11 @@
   resolved "https://registry.yarnpkg.com/@types/clone/-/clone-0.1.30.tgz#e7365648c1b42136a59c7d5040637b3b5c83b614"
   integrity sha1-5zZWSMG0ITalnH1QQGN7O1yDthQ=
 
+"@types/color-name@^1.1.1":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
+  integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
+
 "@types/compression@^0.0.33":
   version "0.0.33"
   resolved "https://registry.yarnpkg.com/@types/compression/-/compression-0.0.33.tgz#95dc733a2339aa846381d7f1377792d2553dc27d"
@@ -747,6 +769,11 @@
   resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-0.0.20.tgz#cae698714dd61ebee5ab3f2aeb9a34ba1011735a"
   integrity sha512-6dhZJLbA7aOwkYB2GDGdIqJ20wmHnkDzaxV9PJXe7O02I2dSFTERzRB6JrX6cWKaS+VqhhY7cQUMCbO5kloFUw==
 
+"@types/eslint-visitor-keys@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
+  integrity sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==
+
 "@types/estree@0.0.39":
   version "0.0.39"
   resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
@@ -852,6 +879,11 @@
   resolved "https://registry.yarnpkg.com/@types/is-windows/-/is-windows-0.2.0.tgz#6f24ee48731d31168ea510610d6dd15e5fc9c6ff"
   integrity sha1-byTuSHMdMRaOpRBhDW3RXl/Jxv8=
 
+"@types/json-schema@^7.0.3":
+  version "7.0.5"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd"
+  integrity sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ==
+
 "@types/launchpad@^0.6.0":
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.0.tgz#37296109b7f277f6e6c5fd7e0c0706bc918fbb51"
@@ -879,6 +911,11 @@
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
   integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
 
+"@types/minimist@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6"
+  integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=
+
 "@types/mz@0.0.29":
   version "0.0.29"
   resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.29.tgz#bc24728c649973f1c7851e9033f9ce525668c27b"
@@ -900,15 +937,20 @@
   integrity sha512-rp7La3m845mSESCgsJePNL/JQyhkOJA6G4vcwvVgkDAwHhGdq5GCumxmPjEk1MZf+8p5ZQAUE7tqgQRQTXN7uQ==
 
 "@types/node@^10.1.0":
-  version "10.17.13"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.13.tgz#ccebcdb990bd6139cd16e84c39dc2fb1023ca90c"
-  integrity sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg==
+  version "10.17.24"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.24.tgz#c57511e3a19c4b5e9692bb2995c40a3a52167944"
+  integrity sha512-5SCfvCxV74kzR3uWgTYiGxrd69TbT1I6+cMx1A5kEly/IVveJBimtAMlXiEyVFn5DvUFewQWxOOiJhlxeQwxgA==
 
 "@types/node@^4.0.30":
   version "4.9.3"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-4.9.3.tgz#a24697a8157ab517996afe0c88fa716550ae419a"
   integrity sha512-Q9eESThBvAbfEzznF1qTAKUoPbJEbK3lTSO0S3mICvmG/vUSZ+HnCtidpuB58Po7CJt5A2goKsDiYScN8d1V4A==
 
+"@types/normalize-package-data@^2.4.0":
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
+  integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==
+
 "@types/opn@^3.0.28":
   version "3.0.28"
   resolved "https://registry.yarnpkg.com/@types/opn/-/opn-3.0.28.tgz#097d0d1c9b5749573a5d96df132387bb6d02118a"
@@ -1183,6 +1225,49 @@
     "@types/events" "*"
     "@types/inquirer" "*"
 
+"@typescript-eslint/eslint-plugin@2.31.0":
+  version "2.31.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.31.0.tgz#942c921fec5e200b79593c71fafb1e3f57aa2e36"
+  integrity sha512-iIC0Pb8qDaoit+m80Ln/aaeu9zKQdOLF4SHcGLarSeY1gurW6aU4JsOPMjKQwXlw70MvWKZQc6S2NamA8SJ/gg==
+  dependencies:
+    "@typescript-eslint/experimental-utils" "2.31.0"
+    functional-red-black-tree "^1.0.1"
+    regexpp "^3.0.0"
+    tsutils "^3.17.1"
+
+"@typescript-eslint/experimental-utils@2.31.0":
+  version "2.31.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-2.31.0.tgz#a9ec514bf7fd5e5e82bc10dcb6a86d58baae9508"
+  integrity sha512-MI6IWkutLYQYTQgZ48IVnRXmLR/0Q6oAyJgiOror74arUMh7EWjJkADfirZhRsUMHeLJ85U2iySDwHTSnNi9vA==
+  dependencies:
+    "@types/json-schema" "^7.0.3"
+    "@typescript-eslint/typescript-estree" "2.31.0"
+    eslint-scope "^5.0.0"
+    eslint-utils "^2.0.0"
+
+"@typescript-eslint/parser@2.31.0":
+  version "2.31.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-2.31.0.tgz#beddd4e8efe64995108b229b2862cd5752d40d6f"
+  integrity sha512-uph+w6xUOlyV2DLSC6o+fBDzZ5i7+3/TxAsH4h3eC64tlga57oMb96vVlXoMwjR/nN+xyWlsnxtbDkB46M2EPQ==
+  dependencies:
+    "@types/eslint-visitor-keys" "^1.0.0"
+    "@typescript-eslint/experimental-utils" "2.31.0"
+    "@typescript-eslint/typescript-estree" "2.31.0"
+    eslint-visitor-keys "^1.1.0"
+
+"@typescript-eslint/typescript-estree@2.31.0":
+  version "2.31.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-2.31.0.tgz#ac536c2d46672aa1f27ba0ec2140d53670635cfd"
+  integrity sha512-vxW149bXFXXuBrAak0eKHOzbcu9cvi6iNcJDzEtOkRwGHxJG15chiAQAwhLOsk+86p9GTr/TziYvw+H9kMaIgA==
+  dependencies:
+    debug "^4.1.1"
+    eslint-visitor-keys "^1.1.0"
+    glob "^7.1.6"
+    is-glob "^4.0.1"
+    lodash "^4.17.15"
+    semver "^6.3.0"
+    tsutils "^3.17.1"
+
 "@webcomponents/webcomponentsjs@^1.0.7":
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-1.3.3.tgz#5bb82a0d3210c836bd4623e13a4a93145cb9dc27"
@@ -1289,6 +1374,13 @@
   dependencies:
     string-width "^2.0.0"
 
+ansi-align@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.0.tgz#b536b371cf687caaef236c18d3e21fe3797467cb"
+  integrity sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw==
+  dependencies:
+    string-width "^3.0.0"
+
 ansi-escapes@^1.1.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
@@ -1338,6 +1430,14 @@
   dependencies:
     color-convert "^1.9.0"
 
+ansi-styles@^4.1.0:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359"
+  integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==
+  dependencies:
+    "@types/color-name" "^1.1.1"
+    color-convert "^2.0.1"
+
 ansi-styles@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-1.0.0.tgz#cb102df1c56f5123eab8b67cd7b98027a0279178"
@@ -1508,6 +1608,11 @@
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
   integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=
 
+arrify@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
+  integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
+
 asn1@~0.2.3:
   version "0.2.4"
   resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
@@ -2031,6 +2136,20 @@
     term-size "^1.2.0"
     widest-line "^2.0.0"
 
+boxen@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/boxen/-/boxen-4.2.0.tgz#e411b62357d6d6d36587c8ac3d5d974daa070e64"
+  integrity sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ==
+  dependencies:
+    ansi-align "^3.0.0"
+    camelcase "^5.3.1"
+    chalk "^3.0.0"
+    cli-boxes "^2.2.0"
+    string-width "^4.1.0"
+    term-size "^2.1.0"
+    type-fest "^0.8.1"
+    widest-line "^3.1.0"
+
 brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -2160,6 +2279,19 @@
     union-value "^1.0.0"
     unset-value "^1.0.0"
 
+cacheable-request@^6.0.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912"
+  integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==
+  dependencies:
+    clone-response "^1.0.2"
+    get-stream "^5.1.0"
+    http-cache-semantics "^4.0.0"
+    keyv "^3.0.0"
+    lowercase-keys "^2.0.0"
+    normalize-url "^4.1.0"
+    responselike "^1.0.2"
+
 call-me-maybe@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b"
@@ -2191,6 +2323,15 @@
     camelcase "^2.0.0"
     map-obj "^1.0.0"
 
+camelcase-keys@^6.2.2:
+  version "6.2.2"
+  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-6.2.2.tgz#5e755d6ba51aa223ec7d3d52f25778210f9dc3c0"
+  integrity sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==
+  dependencies:
+    camelcase "^5.3.1"
+    map-obj "^4.0.0"
+    quick-lru "^4.0.1"
+
 camelcase@^2.0.0, camelcase@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
@@ -2201,6 +2342,16 @@
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
   integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
 
+camelcase@^5.0.0, camelcase@^5.3.1:
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
+  integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
+
+camelcase@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.0.0.tgz#5259f7c30e35e278f1bdc2a4d91230b37cad981e"
+  integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w==
+
 cancel-token@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/cancel-token/-/cancel-token-0.1.1.tgz#c18197674bb1c84c1d6933ebf15d8d5a5ce79b4f"
@@ -2238,6 +2389,22 @@
     strip-ansi "^3.0.0"
     supports-color "^2.0.0"
 
+chalk@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4"
+  integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==
+  dependencies:
+    ansi-styles "^4.1.0"
+    supports-color "^7.1.0"
+
+chalk@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a"
+  integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==
+  dependencies:
+    ansi-styles "^4.1.0"
+    supports-color "^7.1.0"
+
 chalk@~0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.4.0.tgz#5199a3ddcd0c1efe23bc08c1b027b06176e0c64f"
@@ -2288,6 +2455,11 @@
   resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497"
   integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==
 
+ci-info@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
+  integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==
+
 class-utils@^0.3.5:
   version "0.3.6"
   resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
@@ -2315,6 +2487,11 @@
   resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
   integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM=
 
+cli-boxes@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.0.tgz#538ecae8f9c6ca508e3c3c95b453fe93cb4c168d"
+  integrity sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w==
+
 cli-cursor@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
@@ -2353,6 +2530,13 @@
   resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58"
   integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg=
 
+clone-response@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b"
+  integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=
+  dependencies:
+    mimic-response "^1.0.0"
+
 clone-stats@^0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1"
@@ -2402,11 +2586,23 @@
   dependencies:
     color-name "1.1.3"
 
+color-convert@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+  integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+  dependencies:
+    color-name "~1.1.4"
+
 color-name@1.1.3, color-name@^1.0.0:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
   integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
 
+color-name@~1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+  integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
 color-string@^1.5.2:
   version "1.5.3"
   resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc"
@@ -2485,7 +2681,7 @@
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
   integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
 
-commander@^2.19.0:
+commander@^2.19.0, commander@^2.20.0:
   version "2.20.3"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
   integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
@@ -2597,6 +2793,18 @@
     write-file-atomic "^2.0.0"
     xdg-basedir "^3.0.0"
 
+configstore@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96"
+  integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==
+  dependencies:
+    dot-prop "^5.2.0"
+    graceful-fs "^4.1.2"
+    make-dir "^3.0.0"
+    unique-string "^2.0.0"
+    write-file-atomic "^3.0.0"
+    xdg-basedir "^4.0.0"
+
 console-control-strings@^1.0.0, console-control-strings@~1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
@@ -2706,6 +2914,15 @@
     shebang-command "^1.2.0"
     which "^1.2.9"
 
+cross-spawn@^7.0.0:
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
+  integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
+  dependencies:
+    path-key "^3.1.0"
+    shebang-command "^2.0.0"
+    which "^2.0.1"
+
 crypt@~0.0.1:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
@@ -2716,6 +2933,11 @@
   resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
   integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=
 
+crypto-random-string@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
+  integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
+
 css-slam@^2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/css-slam/-/css-slam-2.1.2.tgz#3d35b1922cb3e0002a45c89ab189492508c493e5"
@@ -2789,7 +3011,15 @@
   dependencies:
     ms "2.0.0"
 
-decamelize@^1.1.2:
+decamelize-keys@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9"
+  integrity sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=
+  dependencies:
+    decamelize "^1.1.0"
+    map-obj "^1.0.0"
+
+decamelize@^1.1.0, decamelize@^1.1.2, decamelize@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
   integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
@@ -2799,7 +3029,7 @@
   resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
   integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
 
-decompress-response@^3.2.0:
+decompress-response@^3.2.0, decompress-response@^3.3.0:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3"
   integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=
@@ -2826,6 +3056,11 @@
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.0.0.tgz#3e3110ca29205f120d7cb064960a39c3d2087c09"
   integrity sha512-YZ1rOP5+kHor4hMAH+HRQnBQHg+wvS1un1hAOuIcxcBy0hzcUf6Jg2a1w65kpoOUnurOfZbERwjI1TfZxNjcww==
 
+defer-to-connect@^1.0.1:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591"
+  integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==
+
 define-properties@^1.1.2, define-properties@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
@@ -3046,6 +3281,13 @@
   dependencies:
     is-obj "^1.0.0"
 
+dot-prop@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.2.0.tgz#c34ecc29556dc45f1f4c22697b6f4904e0cc4fcb"
+  integrity sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==
+  dependencies:
+    is-obj "^2.0.0"
+
 duplexer2@^0.1.2, duplexer2@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
@@ -3260,6 +3502,11 @@
   resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-6.0.2.tgz#525c23725b8510f5f1f2feb5a1fbad93a93e29b4"
   integrity sha512-eO6vFm0JvqGzjWIQA6QVKjxpmELfhWbDUWHm1rPfIbn55mhKPiAa5xpLmQWJrNa629ZIeQ8ZvMAi13kvrjK6Mg==
 
+escape-goat@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
+  integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==
+
 escape-html@^1.0.3, escape-html@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
@@ -3275,6 +3522,13 @@
   resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.13.0.tgz#e277d16d2cb25c1ffd3fd13fb0035ad7421382fe"
   integrity sha512-ELgMdOIpn0CFdsQS+FuxO+Ttu4p+aLaXHv9wA9yVnzqlUGV7oN/eRRnJekk7TCur6Cu2FXX0fqfIXRBaM14lpQ==
 
+eslint-config-prettier@^6.10.1:
+  version "6.11.0"
+  resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.11.0.tgz#f6d2238c1290d01c859a8b5c1f7d352a0b0da8b1"
+  integrity sha512-oB8cpLWSAjOVFEJhhyMZh6NOEOtBVziaqdDQ86+qhDHFbZXoRTM7pNSvFRfW/W/L/LrQ38C99J5CGuRBBzBsdA==
+  dependencies:
+    get-stdin "^6.0.0"
+
 eslint-import-resolver-node@^0.3.2:
   version "0.3.3"
   resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.3.tgz#dbaa52b6b2816b50bc6711af75422de808e98404"
@@ -3291,6 +3545,14 @@
     debug "^2.6.9"
     pkg-dir "^2.0.0"
 
+eslint-plugin-es@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz#75a7cdfdccddc0589934aeeb384175f221c57893"
+  integrity sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==
+  dependencies:
+    eslint-utils "^2.0.0"
+    regexpp "^3.0.0"
+
 eslint-plugin-html@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-6.0.0.tgz#28e5c3e71e6f612e07e73d7c215e469766628c13"
@@ -3330,6 +3592,25 @@
     semver "^6.3.0"
     spdx-expression-parse "^3.0.0"
 
+eslint-plugin-node@^11.1.0:
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz#c95544416ee4ada26740a30474eefc5402dc671d"
+  integrity sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==
+  dependencies:
+    eslint-plugin-es "^3.0.0"
+    eslint-utils "^2.0.0"
+    ignore "^5.1.1"
+    minimatch "^3.0.4"
+    resolve "^1.10.1"
+    semver "^6.1.0"
+
+eslint-plugin-prettier@^3.1.2:
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.4.tgz#168ab43154e2ea57db992a2cd097c828171f75c2"
+  integrity sha512-jZDa8z76klRqo+TdGDTFJSavwbnWK2ZpqGKNZ+VvweMW516pDUMmQ2koXvxEE4JhzNvTv+radye/bWGBmA6jmg==
+  dependencies:
+    prettier-linter-helpers "^1.0.0"
+
 eslint-plugin-prettier@^3.1.3:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.3.tgz#ae116a0fc0e598fdae48743a4430903de5b4e6ca"
@@ -3352,12 +3633,19 @@
   dependencies:
     eslint-visitor-keys "^1.1.0"
 
+eslint-utils@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27"
+  integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==
+  dependencies:
+    eslint-visitor-keys "^1.1.0"
+
 eslint-visitor-keys@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2"
   integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==
 
-eslint@^6.6.0:
+eslint@^6.6.0, eslint@^6.8.0:
   version "6.8.0"
   resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.8.0.tgz#62262d6729739f9275723824302fb227c8c93ffb"
   integrity sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig==
@@ -3482,6 +3770,21 @@
     signal-exit "^3.0.0"
     strip-eof "^1.0.0"
 
+execa@^4.0.0:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-4.0.2.tgz#ad87fb7b2d9d564f70d2b62d511bee41d5cbb240"
+  integrity sha512-QI2zLa6CjGWdiQsmSkZoGtDx2N+cQIGb3yNolGTdjSQzydzLgYYf8LRuagp7S7fPimjcrzUDSUFd/MgzELMi4Q==
+  dependencies:
+    cross-spawn "^7.0.0"
+    get-stream "^5.0.0"
+    human-signals "^1.1.1"
+    is-stream "^2.0.0"
+    merge-stream "^2.0.0"
+    npm-run-path "^4.0.0"
+    onetime "^5.1.0"
+    signal-exit "^3.0.2"
+    strip-final-newline "^2.0.0"
+
 exit-hook@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
@@ -3803,6 +4106,14 @@
   dependencies:
     locate-path "^3.0.0"
 
+find-up@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
+  dependencies:
+    locate-path "^5.0.0"
+    path-exists "^4.0.0"
+
 findup-sync@^0.4.2:
   version "0.4.3"
   resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.4.3.tgz#40043929e7bc60adf0b7f4827c4c6e75a0deca12"
@@ -3975,18 +4286,30 @@
   resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
   integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=
 
+get-stdin@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b"
+  integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==
+
 get-stream@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
   integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=
 
-get-stream@^4.0.0:
+get-stream@^4.0.0, get-stream@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
   integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==
   dependencies:
     pump "^3.0.0"
 
+get-stream@^5.0.0, get-stream@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.1.0.tgz#01203cdc92597f9b909067c3e656cc1f4d3c4dc9"
+  integrity sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==
+  dependencies:
+    pump "^3.0.0"
+
 get-value@^2.0.3, get-value@^2.0.6:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
@@ -4097,7 +4420,7 @@
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-glob@^7.1.3, glob@^7.1.4:
+glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
   version "7.1.6"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
   integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
@@ -4116,6 +4439,13 @@
   dependencies:
     ini "^1.3.4"
 
+global-dirs@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-2.0.1.tgz#acdf3bb6685bcd55cb35e8a052266569e9469201"
+  integrity sha512-5HqUqdhkEovj2Of/ms3IeS/EekcO54ytHRLV4PEY2rhRwrHXLQjeVEES0Lhka0xwNDtGYn58wyC4s5+MHsOO6A==
+  dependencies:
+    ini "^1.3.5"
+
 global-modules@^0.2.3:
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d"
@@ -4265,6 +4595,23 @@
     url-parse-lax "^1.0.0"
     url-to-options "^1.0.1"
 
+got@^9.6.0:
+  version "9.6.0"
+  resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85"
+  integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==
+  dependencies:
+    "@sindresorhus/is" "^0.14.0"
+    "@szmarczak/http-timer" "^1.1.2"
+    cacheable-request "^6.0.0"
+    decompress-response "^3.3.0"
+    duplexer3 "^0.1.4"
+    get-stream "^4.1.0"
+    lowercase-keys "^1.0.1"
+    mimic-response "^1.0.1"
+    p-cancelable "^1.0.0"
+    to-readable-stream "^1.0.0"
+    url-parse-lax "^3.0.0"
+
 graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.0.tgz#8d8fdc73977cb04104721cb53666c1ca64cd328b"
@@ -4282,6 +4629,27 @@
   dependencies:
     lodash "^4.17.2"
 
+gts@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/gts/-/gts-2.0.2.tgz#b8b28de99361b5c5c24db30a375a0f546bbc04a4"
+  integrity sha512-SLytzl2IqKXf6kGULwr07XQ9lVsvjrzFD3OAA7DEfIQYuD+lKBPt/cZ/RYGxaWerY4PTfmnXT7KdxEr9Ec8uHQ==
+  dependencies:
+    "@typescript-eslint/eslint-plugin" "2.31.0"
+    "@typescript-eslint/parser" "2.31.0"
+    chalk "^4.0.0"
+    eslint "^6.8.0"
+    eslint-config-prettier "^6.10.1"
+    eslint-plugin-node "^11.1.0"
+    eslint-plugin-prettier "^3.1.2"
+    execa "^4.0.0"
+    inquirer "^7.1.0"
+    meow "^7.0.0"
+    ncp "^2.0.0"
+    prettier "^2.0.4"
+    rimraf "^3.0.2"
+    update-notifier "^4.1.0"
+    write-file-atomic "^3.0.3"
+
 gulp-if@^2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/gulp-if/-/gulp-if-2.0.2.tgz#a497b7e7573005041caa2bc8b7dda3c80444d629"
@@ -4339,6 +4707,11 @@
     ajv "^6.5.5"
     har-schema "^2.0.0"
 
+hard-rejection@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883"
+  integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==
+
 has-ansi@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
@@ -4368,6 +4741,11 @@
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
   integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
 
+has-flag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+  integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
+
 has-symbol-support-x@^1.4.1:
   version "1.4.2"
   resolved "https://registry.yarnpkg.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz#1409f98bc00247da45da67cee0a36f282ff26455"
@@ -4426,6 +4804,11 @@
     is-number "^3.0.0"
     kind-of "^4.0.0"
 
+has-yarn@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77"
+  integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==
+
 has@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
@@ -4485,6 +4868,11 @@
     inherits "^2.0.1"
     readable-stream "^3.1.1"
 
+http-cache-semantics@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
+  integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
+
 http-deceiver@^1.2.7:
   version "1.2.7"
   resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
@@ -4555,6 +4943,11 @@
     agent-base "^4.3.0"
     debug "^3.1.0"
 
+human-signals@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
+  integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
+
 iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -4584,6 +4977,11 @@
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
   integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
 
+ignore@^5.1.1:
+  version "5.1.8"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
+  integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
+
 import-fresh@^3.0.0:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66"
@@ -4609,6 +5007,11 @@
   dependencies:
     repeating "^2.0.0"
 
+indent-string@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
+  integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
+
 indent@0.0.2:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/indent/-/indent-0.0.2.tgz#8c79f080190559b687034b84c7aefa97d5a911d9"
@@ -4637,7 +5040,7 @@
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
   integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
 
-ini@^1.3.4, ini@~1.3.0:
+ini@^1.3.4, ini@^1.3.5, ini@~1.3.0:
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
   integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
@@ -4700,6 +5103,25 @@
     strip-ansi "^5.1.0"
     through "^2.3.6"
 
+inquirer@^7.1.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.2.0.tgz#63ce99d823090de7eb420e4bb05e6f3449aa389a"
+  integrity sha512-E0c4rPwr9ByePfNlTIB8z51kK1s2n6jrHuJeEHENl/sbq2G/S1auvibgEwNR4uSyiU+PiYHqSwsgGiXjG8p5ZQ==
+  dependencies:
+    ansi-escapes "^4.2.1"
+    chalk "^3.0.0"
+    cli-cursor "^3.1.0"
+    cli-width "^2.0.0"
+    external-editor "^3.0.3"
+    figures "^3.0.0"
+    lodash "^4.17.15"
+    mute-stream "0.0.8"
+    run-async "^2.4.0"
+    rxjs "^6.5.3"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
+    through "^2.3.6"
+
 interpret@^1.0.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296"
@@ -4770,6 +5192,13 @@
   dependencies:
     ci-info "^1.5.0"
 
+is-ci@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c"
+  integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==
+  dependencies:
+    ci-info "^2.0.0"
+
 is-data-descriptor@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
@@ -4904,11 +5333,24 @@
     global-dirs "^0.1.0"
     is-path-inside "^1.0.0"
 
+is-installed-globally@^0.3.1:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141"
+  integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g==
+  dependencies:
+    global-dirs "^2.0.1"
+    is-path-inside "^3.0.1"
+
 is-npm@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
   integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ=
 
+is-npm@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d"
+  integrity sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig==
+
 is-number@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
@@ -4933,6 +5375,11 @@
   resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
   integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=
 
+is-obj@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982"
+  integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==
+
 is-object@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470"
@@ -4957,6 +5404,11 @@
   dependencies:
     path-is-inside "^1.0.1"
 
+is-path-inside@^3.0.1:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.2.tgz#f5220fc82a3e233757291dddc9c5877f2a1f3017"
+  integrity sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==
+
 is-plain-obj@^1.0.0, is-plain-obj@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
@@ -5025,6 +5477,11 @@
   resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
   integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
 
+is-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3"
+  integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==
+
 is-string@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6"
@@ -5037,7 +5494,7 @@
   dependencies:
     has-symbols "^1.0.1"
 
-is-typedarray@~1.0.0:
+is-typedarray@^1.0.0, is-typedarray@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
   integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
@@ -5062,6 +5519,11 @@
   resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
   integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==
 
+is-yarn-global@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232"
+  integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==
+
 isarray@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
@@ -5171,6 +5633,11 @@
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
   integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
 
+json-buffer@3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898"
+  integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=
+
 json-parse-better-errors@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
@@ -5218,6 +5685,13 @@
     json-schema "0.2.3"
     verror "1.10.0"
 
+keyv@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
+  integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==
+  dependencies:
+    json-buffer "3.0.0"
+
 kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
   version "3.2.2"
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
@@ -5242,6 +5716,11 @@
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051"
   integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==
 
+kind-of@^6.0.3:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
+  integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
+
 kuler@1.0.x:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/kuler/-/kuler-1.0.1.tgz#ef7c784f36c9fb6e16dd3150d152677b2b0228a6"
@@ -5263,6 +5742,13 @@
   dependencies:
     package-json "^4.0.0"
 
+latest-version@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face"
+  integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==
+  dependencies:
+    package-json "^6.3.0"
+
 launchpad@^0.7.0:
   version "0.7.4"
   resolved "https://registry.yarnpkg.com/launchpad/-/launchpad-0.7.4.tgz#08a7a38f48b963e73dc68be84f9f8f974c46c26b"
@@ -5297,6 +5783,11 @@
     prelude-ls "~1.1.2"
     type-check "~0.3.2"
 
+lines-and-columns@^1.1.6:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
+  integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
+
 load-json-file@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
@@ -5344,6 +5835,13 @@
     p-locate "^3.0.0"
     path-exists "^3.0.0"
 
+locate-path@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
+  integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
+  dependencies:
+    p-locate "^4.1.0"
+
 lodash._reinterpolate@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
@@ -5510,11 +6008,16 @@
   resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac"
   integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw=
 
-lowercase-keys@^1.0.0:
+lowercase-keys@^1.0.0, lowercase-keys@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
   integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==
 
+lowercase-keys@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479"
+  integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==
+
 lru-cache@^4.0.1, lru-cache@^4.0.2:
   version "4.1.5"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
@@ -5542,6 +6045,13 @@
   dependencies:
     pify "^3.0.0"
 
+make-dir@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
+  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
+  dependencies:
+    semver "^6.0.0"
+
 map-cache@^0.2.2:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
@@ -5552,6 +6062,11 @@
   resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
   integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=
 
+map-obj@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.1.0.tgz#b91221b542734b9f14256c0132c897c5d7256fd5"
+  integrity sha512-glc9y00wgtwcDmp7GaE/0b0OnxpNJsVf3ael/An6Fe2Q51LLwN1er6sdomLRzz5h0+yMpiYLhWYF5R7HeqVd4g==
+
 map-visit@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f"
@@ -5627,6 +6142,25 @@
     redent "^1.0.0"
     trim-newlines "^1.0.0"
 
+meow@^7.0.0:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/meow/-/meow-7.0.1.tgz#1ed4a0a50b3844b451369c48362eb0515f04c1dc"
+  integrity sha512-tBKIQqVrAHqwit0vfuFPY3LlzJYkEOFyKa3bPgxzNl6q/RtN8KQ+ALYEASYuFayzSAsjlhXj/JZ10rH85Q6TUw==
+  dependencies:
+    "@types/minimist" "^1.2.0"
+    arrify "^2.0.1"
+    camelcase "^6.0.0"
+    camelcase-keys "^6.2.2"
+    decamelize-keys "^1.1.0"
+    hard-rejection "^2.1.0"
+    minimist-options "^4.0.2"
+    normalize-package-data "^2.5.0"
+    read-pkg-up "^7.0.1"
+    redent "^3.0.0"
+    trim-newlines "^3.0.0"
+    type-fest "^0.13.1"
+    yargs-parser "^18.1.3"
+
 merge-descriptors@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
@@ -5639,6 +6173,11 @@
   dependencies:
     readable-stream "^2.0.1"
 
+merge-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
+  integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
+
 merge2@^1.2.3:
   version "1.2.3"
   resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.2.3.tgz#7ee99dbd69bb6481689253f018488a1b902b0ed5"
@@ -5736,11 +6275,16 @@
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
   integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
 
-mimic-response@^1.0.0:
+mimic-response@^1.0.0, mimic-response@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
   integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
 
+min-indent@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
+  integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
+
 minimalistic-assert@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
@@ -5760,6 +6304,15 @@
   dependencies:
     brace-expansion "^1.1.7"
 
+minimist-options@^4.0.2:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619"
+  integrity sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==
+  dependencies:
+    arrify "^1.0.1"
+    is-plain-obj "^1.1.0"
+    kind-of "^6.0.3"
+
 minimist@0.0.8, minimist@~0.0.1:
   version "0.0.8"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
@@ -5908,6 +6461,11 @@
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
   integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
 
+ncp@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
+  integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=
+
 needle@^2.2.1:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/needle/-/needle-2.3.0.tgz#ce3fea21197267bacb310705a7bbe24f2a3a3492"
@@ -5976,7 +6534,7 @@
     abbrev "1"
     osenv "^0.1.4"
 
-normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
+normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
   integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
@@ -5998,6 +6556,11 @@
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
   integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
 
+normalize-url@^4.1.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.0.tgz#453354087e6ca96957bd8f5baf753f5982142129"
+  integrity sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==
+
 npm-bundled@^1.0.1:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd"
@@ -6018,6 +6581,13 @@
   dependencies:
     path-key "^2.0.0"
 
+npm-run-path@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
+  integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==
+  dependencies:
+    path-key "^3.0.0"
+
 npmlog@^4.0.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
@@ -6238,6 +6808,11 @@
   resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa"
   integrity sha512-RVbZPLso8+jFeq1MfNvgXtCRED2raz/dKpacfTNxsx6pLEpEomM7gah6VeHSYV3+vo0OAi4MkArtQcWWXuQoyw==
 
+p-cancelable@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc"
+  integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==
+
 p-finally@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
@@ -6257,6 +6832,13 @@
   dependencies:
     p-try "^2.0.0"
 
+p-limit@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
+  integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
+  dependencies:
+    p-try "^2.0.0"
+
 p-locate@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43"
@@ -6271,6 +6853,13 @@
   dependencies:
     p-limit "^2.0.0"
 
+p-locate@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
+  integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
+  dependencies:
+    p-limit "^2.2.0"
+
 p-map@^1.1.1:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b"
@@ -6313,6 +6902,16 @@
     registry-url "^3.0.3"
     semver "^5.1.0"
 
+package-json@^6.3.0:
+  version "6.5.0"
+  resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0"
+  integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==
+  dependencies:
+    got "^9.6.0"
+    registry-auth-token "^4.0.0"
+    registry-url "^5.0.0"
+    semver "^6.2.0"
+
 pako@~0.2.0:
   version "0.2.9"
   resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
@@ -6357,6 +6956,16 @@
     error-ex "^1.3.1"
     json-parse-better-errors "^1.0.1"
 
+parse-json@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.0.0.tgz#73e5114c986d143efa3712d4ea24db9a4266f60f"
+  integrity sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==
+  dependencies:
+    "@babel/code-frame" "^7.0.0"
+    error-ex "^1.3.1"
+    json-parse-better-errors "^1.0.1"
+    lines-and-columns "^1.1.6"
+
 parse-passwd@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
@@ -6408,6 +7017,11 @@
   resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
   integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
 
+path-exists@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
+  integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
+
 path-is-absolute@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
@@ -6423,6 +7037,11 @@
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
   integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
 
+path-key@^3.0.0, path-key@^3.1.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
+  integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
+
 path-parse@^1.0.6:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
@@ -6832,6 +7451,11 @@
   resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
   integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
 
+prepend-http@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
+  integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
+
 preserve@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
@@ -6844,7 +7468,7 @@
   dependencies:
     fast-diff "^1.1.2"
 
-prettier@2.0.5:
+prettier@2.0.5, prettier@^2.0.4:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.0.5.tgz#d6d56282455243f2f92cc1716692c08aa31522d4"
   integrity sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==
@@ -6954,6 +7578,13 @@
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
+pupa@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.0.1.tgz#dbdc9ff48ffbea4a26a069b6f9f7abb051008726"
+  integrity sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA==
+  dependencies:
+    escape-goat "^2.0.0"
+
 q@^1.4.1, q@^1.5.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
@@ -6969,6 +7600,11 @@
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
   integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
 
+quick-lru@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
+  integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==
+
 randomatic@^3.0.0:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed"
@@ -6993,7 +7629,7 @@
     iconv-lite "0.4.24"
     unpipe "1.0.0"
 
-rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
+rc@^1.0.1, rc@^1.1.6, rc@^1.2.7, rc@^1.2.8:
   version "1.2.8"
   resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
   integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
@@ -7043,6 +7679,15 @@
     find-up "^3.0.0"
     read-pkg "^3.0.0"
 
+read-pkg-up@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507"
+  integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==
+  dependencies:
+    find-up "^4.1.0"
+    read-pkg "^5.2.0"
+    type-fest "^0.8.1"
+
 read-pkg@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
@@ -7070,6 +7715,16 @@
     normalize-package-data "^2.3.2"
     path-type "^3.0.0"
 
+read-pkg@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc"
+  integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==
+  dependencies:
+    "@types/normalize-package-data" "^2.4.0"
+    normalize-package-data "^2.5.0"
+    parse-json "^5.0.0"
+    type-fest "^0.6.0"
+
 readable-stream@1.1.x:
   version "1.1.14"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
@@ -7149,6 +7804,14 @@
     indent-string "^2.1.0"
     strip-indent "^1.0.1"
 
+redent@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"
+  integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==
+  dependencies:
+    indent-string "^4.0.0"
+    strip-indent "^3.0.0"
+
 reduce-flatten@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-1.0.1.tgz#258c78efd153ddf93cb561237f61184f3696e327"
@@ -7198,6 +7861,11 @@
   resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f"
   integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==
 
+regexpp@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2"
+  integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==
+
 regexpu-core@^4.5.4:
   version "4.5.4"
   resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.5.4.tgz#080d9d02289aa87fe1667a4f5136bc98a6aebaae"
@@ -7223,6 +7891,13 @@
     rc "^1.1.6"
     safe-buffer "^5.0.1"
 
+registry-auth-token@^4.0.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.1.1.tgz#40a33be1e82539460f94328b0f7f0f84c16d9479"
+  integrity sha512-9bKS7nTl9+/A1s7tnPeGrUpRcVY+LUh7bfFgzpndALdPfXQBfQV77rQVtqgUV3ti4vc/Ik81Ex8UJDWDQ12zQA==
+  dependencies:
+    rc "^1.2.8"
+
 registry-url@^3.0.3:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
@@ -7230,6 +7905,13 @@
   dependencies:
     rc "^1.0.1"
 
+registry-url@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009"
+  integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==
+  dependencies:
+    rc "^1.2.8"
+
 regjsgen@^0.5.0:
   version "0.5.0"
   resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.0.tgz#a7634dc08f89209c2049adda3525711fb97265dd"
@@ -7348,6 +8030,13 @@
   dependencies:
     path-parse "^1.0.6"
 
+resolve@^1.10.1:
+  version "1.17.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444"
+  integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==
+  dependencies:
+    path-parse "^1.0.6"
+
 resolve@^1.12.0, resolve@^1.13.1:
   version "1.15.1"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.1.tgz#27bdcdeffeaf2d6244b95bb0f9f4b4653451f3e8"
@@ -7362,6 +8051,13 @@
   dependencies:
     path-parse "^1.0.6"
 
+responselike@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7"
+  integrity sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=
+  dependencies:
+    lowercase-keys "^1.0.0"
+
 restore-cursor@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
@@ -7405,6 +8101,13 @@
   dependencies:
     glob "^7.1.3"
 
+rimraf@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
+  dependencies:
+    glob "^7.1.3"
+
 rimraf@~2.2.6:
   version "2.2.8"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.2.8.tgz#e439be2aaee327321952730f99a8929e4fc50582"
@@ -7426,6 +8129,11 @@
   dependencies:
     is-promise "^2.1.0"
 
+run-async@^2.4.0:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
+  integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==
+
 rx@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782"
@@ -7524,6 +8232,13 @@
   dependencies:
     semver "^5.0.3"
 
+semver-diff@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b"
+  integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==
+  dependencies:
+    semver "^6.3.0"
+
 "semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.6.0:
   version "5.7.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b"
@@ -7534,7 +8249,7 @@
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
   integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==
 
-semver@^6.1.2, semver@^6.3.0:
+semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0:
   version "6.3.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
@@ -7634,11 +8349,23 @@
   dependencies:
     shebang-regex "^1.0.0"
 
+shebang-command@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
+  integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
+  dependencies:
+    shebang-regex "^3.0.0"
+
 shebang-regex@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
   integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
 
+shebang-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
+  integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
+
 shelljs@^0.8.0:
   version "0.8.3"
   resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.3.tgz#a7f3319520ebf09ee81275b2368adb286659b097"
@@ -7816,6 +8543,14 @@
     buffer-from "^1.0.0"
     source-map "^0.6.0"
 
+source-map-support@~0.5.12:
+  version "0.5.19"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
+  integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
+  dependencies:
+    buffer-from "^1.0.0"
+    source-map "^0.6.0"
+
 source-map-url@^0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3"
@@ -8001,7 +8736,7 @@
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^5.1.0"
 
-string-width@^4.1.0:
+string-width@^4.0.0, string-width@^4.1.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5"
   integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==
@@ -8111,6 +8846,11 @@
   resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
   integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=
 
+strip-final-newline@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
+  integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
+
 strip-indent@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
@@ -8123,6 +8863,13 @@
   resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68"
   integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=
 
+strip-indent@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001"
+  integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==
+  dependencies:
+    min-indent "^1.0.0"
+
 strip-json-comments@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7"
@@ -8145,6 +8892,13 @@
   dependencies:
     has-flag "^3.0.0"
 
+supports-color@^7.1.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1"
+  integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==
+  dependencies:
+    has-flag "^4.0.0"
+
 sw-precache@^5.1.1:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/sw-precache/-/sw-precache-5.2.1.tgz#06134f319eec68f3b9583ce9a7036b1c119f7179"
@@ -8270,6 +9024,11 @@
   dependencies:
     execa "^0.7.0"
 
+term-size@^2.1.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.0.tgz#1f16adedfe9bdc18800e1776821734086fcc6753"
+  integrity sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw==
+
 ternary-stream@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/ternary-stream/-/ternary-stream-2.0.1.tgz#064e489b4b5bf60ba6a6b7bc7f2f5c274ecf8269"
@@ -8280,6 +9039,15 @@
     merge-stream "^1.0.0"
     through2 "^2.0.1"
 
+terser@^4.8.0:
+  version "4.8.0"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
+  integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==
+  dependencies:
+    commander "^2.20.0"
+    source-map "~0.6.1"
+    source-map-support "~0.5.12"
+
 text-encoding@0.6.4:
   version "0.6.4"
   resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"
@@ -8416,6 +9184,11 @@
   dependencies:
     kind-of "^3.0.2"
 
+to-readable-stream@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771"
+  integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==
+
 to-regex-range@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38"
@@ -8459,6 +9232,11 @@
   resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
   integrity sha1-WIeWa7WCpFA6QetST301ARgVphM=
 
+trim-newlines@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.0.tgz#79726304a6a898aa8373427298d54c2ee8b1cb30"
+  integrity sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA==
+
 trim-right@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
@@ -8469,7 +9247,12 @@
   resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
   integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
 
-tslib@^1.8.1, tslib@^1.9.0:
+tslib@^1.8.1:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
+  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
+
+tslib@^1.9.0:
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
   integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
@@ -8481,6 +9264,13 @@
   dependencies:
     tslib "^1.8.1"
 
+tsutils@^3.17.1:
+  version "3.17.1"
+  resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759"
+  integrity sha512-kzeQ5B8H3w60nFY2g8cJIuH7JDpsALXySGtwGJ0p2LSjLgay3NdIpqq5SoOBe46bKDW2iq25irHCr8wjomUS2g==
+  dependencies:
+    tslib "^1.8.1"
+
 tunnel-agent@^0.6.0:
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
@@ -8505,6 +9295,16 @@
   resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
   integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
 
+type-fest@^0.13.1:
+  version "0.13.1"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934"
+  integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==
+
+type-fest@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b"
+  integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==
+
 type-fest@^0.8.1:
   version "0.8.1"
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
@@ -8518,15 +9318,22 @@
     media-typer "0.3.0"
     mime-types "~2.1.24"
 
+typedarray-to-buffer@^3.1.5:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
+  integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==
+  dependencies:
+    is-typedarray "^1.0.0"
+
 typedarray@^0.0.6:
   version "0.0.6"
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
-typescript@^3.7.4:
-  version "3.7.5"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae"
-  integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw==
+typescript@3.8.2:
+  version "3.8.2"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.2.tgz#91d6868aaead7da74f493c553aeff76c0c0b1d5a"
+  integrity sha512-EgOVgL/4xfVrCMbhYKUQTdF37SQn4Iw73H5BgCrF1Abdun7Kwy/QZsE/ssAy0y4LxBbvua3PIbFsbRczWWnDdQ==
 
 typical@^2.6.1:
   version "2.6.1"
@@ -8609,6 +9416,13 @@
   dependencies:
     crypto-random-string "^1.0.0"
 
+unique-string@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d"
+  integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==
+  dependencies:
+    crypto-random-string "^2.0.0"
+
 universal-user-agent@^2.0.0, universal-user-agent@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-2.1.0.tgz#5abfbcc036a1ba490cb941f8fd68c46d3669e8e4"
@@ -8681,6 +9495,25 @@
     semver-diff "^2.0.0"
     xdg-basedir "^3.0.0"
 
+update-notifier@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-4.1.0.tgz#4866b98c3bc5b5473c020b1250583628f9a328f3"
+  integrity sha512-w3doE1qtI0/ZmgeoDoARmI5fjDoT93IfKgEGqm26dGUOh8oNpaSTsGNdYRN/SjOuo10jcJGwkEL3mroKzktkew==
+  dependencies:
+    boxen "^4.2.0"
+    chalk "^3.0.0"
+    configstore "^5.0.1"
+    has-yarn "^2.1.0"
+    import-lazy "^2.1.0"
+    is-ci "^2.0.0"
+    is-installed-globally "^0.3.1"
+    is-npm "^4.0.0"
+    is-yarn-global "^0.3.0"
+    latest-version "^5.0.0"
+    pupa "^2.0.1"
+    semver-diff "^3.1.1"
+    xdg-basedir "^4.0.0"
+
 upper-case@^1.1.1:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598"
@@ -8715,6 +9548,13 @@
   dependencies:
     prepend-http "^1.0.1"
 
+url-parse-lax@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c"
+  integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=
+  dependencies:
+    prepend-http "^2.0.0"
+
 url-template@^2.0.8:
   version "2.0.8"
   resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21"
@@ -8911,7 +9751,7 @@
     request "2.88.0"
     vargs "^0.1.0"
 
-web-component-tester@^6.5.1, web-component-tester@^6.9.0:
+web-component-tester@^6.9.0:
   version "6.9.2"
   resolved "https://registry.yarnpkg.com/web-component-tester/-/web-component-tester-6.9.2.tgz#40a7b824f2cf3cbc4305552bdfc3357977ded48a"
   integrity sha512-s2kB/+IE8XWcnxY1fqSpqTiiHEGHWgUWariAbiRlxmAvWSuvaCVNALHYebsZrLCNCLHKcJR8/sGv/bw0MWMvjw==
@@ -8967,6 +9807,13 @@
   dependencies:
     isexe "^2.0.0"
 
+which@^2.0.1:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
+  integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
+  dependencies:
+    isexe "^2.0.0"
+
 wide-align@^1.1.0:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
@@ -8988,6 +9835,13 @@
   dependencies:
     string-width "^2.1.1"
 
+widest-line@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca"
+  integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==
+  dependencies:
+    string-width "^4.0.0"
+
 windows-release@^3.1.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.2.0.tgz#8122dad5afc303d833422380680a79cdfa91785f"
@@ -9068,6 +9922,16 @@
     imurmurhash "^0.1.4"
     signal-exit "^3.0.2"
 
+write-file-atomic@^3.0.0, write-file-atomic@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8"
+  integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==
+  dependencies:
+    imurmurhash "^0.1.4"
+    is-typedarray "^1.0.0"
+    signal-exit "^3.0.2"
+    typedarray-to-buffer "^3.1.5"
+
 write@1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3"
@@ -9099,6 +9963,11 @@
   resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
   integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=
 
+xdg-basedir@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
+  integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==
+
 xmlbuilder@8.2.2:
   version "8.2.2"
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773"
@@ -9129,6 +9998,14 @@
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9"
   integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==
 
+yargs-parser@^18.1.3:
+  version "18.1.3"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
+  integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
+  dependencies:
+    camelcase "^5.0.0"
+    decamelize "^1.2.0"
+
 yauzl@^2.10.0:
   version "2.10.0"
   resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"